Skip to content

Decorators

Tiago Silva edited this page Oct 20, 2020 · 1 revision

Introduction

Decorators by themselves can have quite niche use cases, however they can provide any function with extra functionality, such as logging or timing a function (examples below).
Decorators will also be extremely useful in the next chapter, OOP in Python.

First Class Functions ~ Functions as variables

During the Functions chapter we discussed how to declare and call functions, however they are much more versatile. A language that has 'first class function' can be defined with the following sentence:

This means the language supports passing functions as arguments to other functions, returning them as the values from other functions, and assigning them to variables or storing them in data structures.

Abelson, Harold; Sussman, Gerald Jay (1984). Structure and Interpretation of Computer Programs.

In simpler terms, in Python we can treat functions exactly as you would any other variables:

def sum2(a, b):
    print('I was called')
    return a + b

f_sum2 = sum2  # f_sum2 contains the funcion sum2
print(f_sum)  # <function sum2 at 0x000002BBFBD06160>

res = f_sum2(2, 3) # Will print 'I was called'
print(res)  # 5

res = f_sum2(7, 7) # Will print 'I was called'
print(res)  # 14

As you can see from the example above, f_sum2 contains the function sum2, not it's return value. This is because the variable stores the function and it is only called later, alongside its arguments.

Wrappers

We can use this concept to help on us on the following abstract situation: We want to call the inner function (which is in a different scope) from the rest of our program, how could we go about it?

def outer_function():
    message = "Hi!"

    def inner_function():
        print(message)
    
    return inner_function()

my_func = outer_function
my_func()  # Hi!

This solution simply calls the inner function from the outer_function, but it is still quite limited. What if we wanted to pass along the message argument as well but still wanted to be able to call the function without arguments?

def outer_function(message):

    def inner_function():
        print(message)
    
    return inner_function

ieee_func = outer_function('IEEE')
python_func = outer_function('Python')

ieee_func()  # IEEE
python_func()  # Python

This solution achieves that by never calling the function in the first place, notice how the parenthesis are gone from the return statement. Now we are returning the function itself instead of its return value.

Decorator Shorthand

This time let's give a more suitable name to our functions:

def decorator_function(original_function):
    def wrapper_function():
        print(f'Wrapper executed this before {original_function.__name__}')
        return original_function()
    return wrapper_function

def hi():
    print("Hi!")

decorated_hi = decorator_function(hi)
decorated_hi()
# wrapper executed this before hi
# Hi!

In this example we took our hi function and added something to it, in this case just a print statement.
What if we wanted to simply add new functionality to the hi function without needing to change the code? You can simply write:

hi = decorator_function(hi)
hi()

This example works because the function/variable hi (remember, functions can be treated as variables) is now bound to the decorator_function() called with the original hi function.

This syntax can be hard to see and isn't very transparent, so we can (and usually do) write it like this:

@decorator_function
def hi():
    print("Hi!")

hi()

This is adding a decorator to a function. This shorthand raises one final question though: If we can simply add this notation to any function, how will the arguments work? (spoiler: They won't)

# THIS CODE IS INCORRECT
@decorator_function
def sum2(a, b):
    return a + b

print(sum2(2, 4))

Trying to run this example will have the interpreter spit out the following error: wrapper_function() takes 0 positional arguments but 2 were given

Basic Decorator

To solve that issue, we can resort to variable argument functions:

def decorator_function(original_function):
    """Decorator docstring"""
    def wrapper_function(*args, **kwargs):
        """Wrapper function docstring"""
        print(f'Wrapper executed this before {original_function.__name__}')
        return original_function(*args, **kwargs)
    return wrapper_function

# Find the min between two or more numbers
@decorator_function
def my_min(a, b, *args):
    """My min docstring"""
    res = a if a < b else b
    
    for val in args:
        res = res if res < val else val
    return res

print(my_min(4, 7, -3, 9))
# Wrapper executed this before my_min
# -3

Note: You can also use classes to create decorators, although they are less common and, as such, will not be mentioned in this workshop

Functools and wraps

Some of you may have already noticed that the original function is being replaced by the wrapper function, losing information along the way, such as it's name, docstring, etc.
This can be clearly seen by running the following code on the previous example of a basic decorator.

print(my_min.__name__)  # wrapper_function
print(my_min.__doc__)  # Wrapper function docstring

To solve this issue, Python's functools module ironically provides another decorator: wraps.

from functools import wraps
def my_decorator(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        print('Calling decorated function')
        return f(*args, **kwargs)
    return wrapper

Chaining Decorators

At the start of this chapter a good use case was promised, as such, a good use case will now be provided.
Let's say you needed to time how long a functions takes to run (without resorting an external library) and to print a separator on the top and bottom of each function call:

from functools import wraps
import time
def timer(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        start = time.time()
        ret = f(*args, **kwargs)
        end = time.time()
        print(f"{f.__name__} ran in: {end - start} seconds")
        return ret
    return wrapper

def pretty(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        time.sleep(1)  # Here for test purpuoses
        print("-" * 20)
        ret =  f(*args, **kwargs)
        print("-" * 20)
        return ret
    return wrapper

@pretty
@timer
def f1(a, b):
    return a + b

@timer
@pretty
def f2(a, b):
    return a + b

f1(1, 2)
f2(3, 4)

# --------------------
# f1 ran in: 0.0 seconds
# --------------------
# --------------------
# --------------------
# f2 ran in: 1.000044822692871 seconds

Oh lord, what happened to f2()? Whenever chaining decorators their order is of great importance. In order to understand what is happening you can think back to the start of how applying a decorator works:

def f(a, b):
    return a + b

f1 = pretty(timer(f))
f2 = timer(pretty(f))

As you now see more clearly, decorators are applied from top to bottom, just like a stack. That's why f2() had such an odd result: We were trying to use timer to time how long the decorator pretty() and it's original f function.

Decorator Arguments

Sometimes we need more flexibility in the decorator, so we need to pass arguments to the decorator itself.
If you want a real pratical example of where/how this could be used, check Discord.py's Cog/Command decorators.

from functools import wraps
def decorator(var1, var2, sep=""):
    def inner_decorator(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            print(var1, var2, sep=sep)
            return f(*args, **kwargs)
        return wrapper
    return inner_decorator

@decorator("Decorator", "arguments", sep=" : ")
def f(a, b):
    return a + b

print(f(1, 2))

# Decorator : arguments
# 3

Sections

Previous: Comprehensions and Generators
Next: Object-Oriented-Programming