Decorators

Decorators are used to modify or enhance functions dynamically.

Decorators are used to wrap or modify the behaviour of functions without permanently changing their source code. Decorators are implemented as functions that take another function as input and return a new function with the desired modifications.

Here's an example of a simple Timer decorator that prints the time it took a function to execute before returning the function's result:

const Timer = (func) => {
    return (...args) => {
        const start = clock()
        var value = func(...args)
        const duration = clock() - start
        println(f"Duration (${func.info().name}): ", duration * 1000)
        return value
    }
}

@Timer
const somefunc = () => {
    return 5 * 20
}

println(somefunc())

/* Output:

Duration (somefunc): 0.004
100

*/

There's a small gotcha with decorators that you should be aware of.

If we print the name of the somefunc function after applying a decorator to it, you'll get an empty string. Why?

If you look carefully, the decorator simply returns a lambda. Lambdas inherently do not have names. So how do we fix this?

We'll use a module in our standard library called functools to rename our lambda before returning it:

import [rename] : functools

const Timer = (func) => {
    return ((...args) => {
        const start = clock()
        var value = func(...args)
        const duration = clock() - start
        println(f"Duration (${func.info().name}): ", duration * 1000)
        return value
    }).rename(func.info().name)
}

Now instead of returning a nameless lambda, we rename it to the original function's name using the rename function provided by functools.

If we print the function's name now, you'll get the correct string back.

Decorators can accept functions with arguments as well. Since we're returning a function that takes a variable number of parameters, it will simply pass those on to the original function call within the decorator.

@Timer
const somefunc = (a, b) => {
    return a * b
}

println(somefunc(5, 20))

/* Output:

Duration (somefunc): 0.002
256

*/

Note: Much like how the returned function loses its name without interverntion from functools, its arity will also be lost. This is because the variadic parameter (...args) is only one parameter, thus the new arity becomes 1. This is not something that can be modified.

You can attach multiple decorators to the same function like so:

@[Timer, Timer]
const somefunc = (a, b) => {
    return a * b
}

somefunc(8, 32).println()

/* Output:

Duration (somefunc): 0.004
Duration (somefunc): 0.002
256

*/

Decorators can also accept multiple arguments. When constructing a decorator, the first parameter is reserved for the function you'll be wrapping. You're free to keep adding parameters after that.

const Timer = (func, diff = 1) => {
    return ((...args) => {
        const start = clock()
        var value = func(...args)
        const duration = clock() - start
        println(f"Duration (${func.info().name}): ", duration * 1000 * diff)
        return value
    }).rename(func.info().name)
}

@[Timer(1000), Timer]
const somefunc = (a, b) => {
    return a * b
}

somefunc(8, 32).println()

/* Output:

Duration (somefunc): 2
Duration (somefunc): 0.057
256

*/

Last updated