Python @Decorators

by Bautista Bambozzi — 15 min

example

What's that @decorator thing in Python all about? It's really just a function that returns another function. Still confused? We'll clear it up in this article! Intermediate Python knowledge is required.

What is it? Why use it?

Let start by saying we want to do a very trivial (and imprecise) function that benchmarks how much another function takes to execute. We'd like for it to be able to modify another function so that instead of returning its usual output, it returns a tuple that contains both the result of the function, and the time taken '(result, time_taken)'.



from time import perf_counter

def time_function(func):
    def helper(*args, **kwargs): # ①
        start = perf_counter()
        result = func(*args, **kwargs) # ②
        end = perf_counter()
        benchmark = end - start
        return (result, benchmark) # ③
    return helper # ④

In ①, we're declaring a helper function that takes in both args and kwargs. We don't really know what the function that is being benchmarked will be receiving as an argument, so we rely on these to be able to pass them on to the targeted function ②. We then ③ return a tuple containing both the result of the function and the benchmark that we've calculated. And the final trick ④ is returning the new helper function and not the old function! We've effectively extended the function! Let us use this 'to_sorted_list' as an example.


def to_sorted_list(x) -> list:
    return list(sorted(x))


modified_to_sorted_list = time_function(to_sorted_list) # ①
sample = [x for x in range(10, -1, -1)] # ②
answer, time_taken = modified_to_sorted_list(sample) # ③

We then ① modify the function, by passing it as an argument without calling it. It returns a new, modified function that we've defined as 'helper' in the time_function. We then create a small sample list using list comprehensions and finally ③, we get what we wanted: a tuple containing both the answer as well as the time taken! Try to take a few seconds to think about other possibilities: logging, benchmarking, mutating functions!

The final step

So far, we've managed to modify the function at runtime to fit our needs. But what if we wanted to modify the function before runtime? What if we wanted to modify many functions, even! This is where decorators come in: they allow us to modify the function when it's defined rather than at runtime. That is why the following snippet of code, using the @decorator syntax, is more elegant and concise!


@time_function
def to_sorted_list(x) -> list:
    return list(sorted(x))


answer, time_taken = to_sorted_list(sample_list)

Example Applications

The most classic examples of Decorators lie within frameworks. If you've ever worked with Django or Spring, you've likely noticed the generous use of these annotations. But the applications of decorators are not limited only to framework authors. In the following section, we're going to be building a @Memoize and a @Log function to demonstrate. Try and build it yourself first, so you can compare with the result provided in this article. Try doing @Log first, as it is simpler to implement.

@Log

The @Log function is pretty self explanatory. Define the wrapper, log something to the stdout, and then return the result of the function.


def log(func):
    def wrapper(*args, **kwargs): # ①
        print("logging..") # ②
        return func(*args, **kwargs)
    return wrapper

@log
def say_hello():
    print("hello!")

@Memoize

In the memoize function, we'd start off by ① creating a memo which will store the results that we've accumulated previously. This has the advantage of very quick retrieval costs (𝑶(𝟏)), at the expense of linear memory. In the wrapper, we'd then ② string the function arguments , thus grouping the arguments in a hashable and immutable string. This allows us to ③ check if already has been computed before. If that is the case, we simply return the already computed value. If it is not, we would ④ calculate the result of the function and immediately store it again.


def memoize(func):
    memo = {} # ①
    def wrapper(*args, **kwargs):
        stringedArgs = str(args) + str(kwargs) # ②
        if stringedArgs in memo: # ③
            return memo[stringedArgs]
        memo[stringedArgs] = func(*args, **kwargs) # ④
        return memo[stringedArgs]
    return wrapper

@memoize
def bruteFibonacci(x: int) -> int:
    if x <= 1:
        return x
    return bruteFibonacci(x - 1) + bruteFibonacci(x - 2)