DEV Community

Cover image for Python decorators in a nutshell.
Sebastian G. Vinci
Sebastian G. Vinci

Posted on

Python decorators in a nutshell.

What's a decorator anyway?

Decorators are a way to extend the behavior of pieces of software without actually having to modify them.

In Python, in particular, they are functions that can change the behavior of other functions without changing their code. They provide a transparent way of adding satelital functionalities to already defined functions.

For example, imagine we want to measure the time it takes to execute a couple functions in our program. We could code a timer around their behavior by modifying them all, or we can create a decorator, centralizing the logic of timing a function within one place and applying it to several functions.

A couple concepts we need to know first...

In Python, functions are first-class objects.

This means that functions can be assigned to variables and parameters, and even be returned by other functions.

Functions that return other functions, or that receive other functions as arguments, are called higher order functions.

def prepare_hello(name: str):
    def say_hello():
        print("Hello, {}!".format(name))
    return say_hello

hello_brian = prepare_hello("Brian")
hello_brian()

On the above snippet, prepare_hello is a higher order function, as it returns another function. Also, we can see how we can assign functions to variables, as hello_brian is a variable that holds the function returned by prepare_hello.

A couple known higher order functions in Python are map, filter, sort and reduce. These are functions that receive a collection and a function as argument, to let us process the collection using the given function in different ways.

*args and **kwargs.

You may already know these guys, they are pretty famous around Python. Both *args and **kwargs allow you to pass a variable number of arguments to a function. Your function is basically a blank check if the signature contains one or both of these guys.

def blank_check(*args, **kwargs):
    for arg in args:
        print("Arg: {}".format(arg))
    for keyword, arg in kwargs.items():
        print("Kw: {}, Arg: {}".format(keyword, arg))

blank_check(1, 2, 3, hello="Hello", word="World")

Here's the output of the above function:

Arg: 1
Arg: 2
Arg: 3
Kw: hello, Arg: Hello
Kw: word, Arg: World

As you can see, arguments passed without a keyword are handled by *args, and arguments passed with a keyword are handled by **kwargs.

This allows us to write functions that do not care at all about the arguments they receive, which will make a hell of a lot easier to write versatile decorators.

A thing to notice is that the important part of *args and **kwargs are the aesterisks. The name can be anything, as any other variable, args and kwargs are used by convention, as this practice is dark-ish and needs to be spotted right away.

Now, decorators!

Decorators are, basically, functions that wrap other functions. In Python it really is as easy as that.

def a_decorator(wrapped_function):
    def wrapper(*args, **kwargs):
        print('I am the decorator!')
        return wrapped_function(*args, **kwargs)
    return wrapper

That's the definition of a decorator, right there. You can see two interesting things there:

  • The decorator receives the function it is supposed to wrap as an argument, and returns a wrapper function. Therefore, a decorator is a higher order function.
  • The decorator, by using *args and **kwargs, does not care at all what are the arguments of the wrapped function, it just does its thing and calls the wrapped function with all the given arguments.

Now, we can use it wherever we want:

@a_decorator
def sum_numbers(num_a, num_b):
    return num_a + num_b

print(sum_numbers(1, 2))

The output will be:

I am the decorator!
3

It is really transparent, see? And although it is pretty magical, it is really explicit. You go to the definition of the function and you see right there, in your face, what decorators are being executed.

This implementation, though, has a subtle issue:

print(sum_numbers.__name__)

That will print out wrapper, as that's the name of the wrapper function the decorator returns. Now, when debugging, that thing right there will be a pain in the butt. But we can fix it! And we can fix it with the standard library! :D

There is a module in python called functools that provides a lot of awesome things to work with functions. I really encourage you to go and see what it has to offer, but today we are just going to use one decorator it provides.

The decorator functools.wraps receives a function as argument and basically overrides the wrapped function properties with the ones of the given function.

Let's change our decorator using functools.wraps to see this in action.

from functools import wraps

def a_decorator(wrapped_function):
    @wraps(wrapped_function)
    def wrapper(*args, **kwargs):
        print('I am the decorator!')
        return wrapped_function(*args, **kwargs)
    return wrapper

@a_decorator
def sum_numbers(num_a, num_b):
    return num_a + num_b

print(sum_numbers.__name__)

That will print sum_numbers again, fixing our issue elegantly. Now, how come wraps receives parameters? How do I do that? You'll need an intermediate function that receives those parameters and returns the wrapper function.

Your decorator will not actually be a decorator, it will be a decorator builder, which receives a couple of arguments and returns a decorator.

def a_decorator(name):
    def actual_decorator(wrapped_function):
        @wraps(wrapped_function)
        def wrapper(*args, **kwargs):
            print('I am the decorator, {}!'.format(name))
            return wrapped_function(*args, **kwargs)
        return wrapper
    return actual_decorator

@a_decorator("Sum Numbers")
def sum_numbers(num_a, num_b):
    return num_a + num_b

print(sum_numbers(1, 2))

The actual problem we wanted to solve

Let's say, we have a function we want to time:

def fibonacci(n, a = 0, b = 1):
    if n == 0: 
        return a 
    if n == 1: 
        return b 
    return fibonacci(n - 1, b, a + b)

Now, let's just drop the idea of adding timing functionality directly to it, as it would pollute its code, making it more complex to follow, just because we wanted to add non-critical behavior to it.

So, let's write a decorator called timer that receives a cool operation name (to make it more readable in the logs) and add @timer("Fibonacci calculator") to the original function.

from datetime import datetime
from functools import wraps

def timer(operation_name):
        def timer_decorator(wrapped_function):
                @wraps(wrapped_function)
                def wrapper(*args, **kwargs):
                        start = datetime.now()
                        result = wrapped_function(*args, **kwargs)
                        end = datetime.now()
                        print("{} took {}.".format(operation_name, end - start))
                        return result
                return wrapper
        return timer_decorator

@timer("Fibonacci calculator")
def fibonacci(n, a = 0, b = 1):
    if n == 0:
        return a
    if n == 1:
        return b
    return fibonacci(n - 1, b, a + b)

fibonacci(30)

Now, in the output we'll see how much time each execution of fibonacci took, taking into account it is a recursive function and for n=30 it will execute itself 30 times. So we'll see in the output Fibonacci calculator took <time> thirty times.

This experiment also makes it very clear that Python doesn't do tail recursion optimization, so keep that one in mind :P.

Conclusion

Python makes decorators fairly easy, and they are really awesome for this kind of features we all need in production environments.

Caching, unit testing, routing, these are all problems that can be elegantly solved using decorators, and I think, particulary in Python, they are really beginner friendly.

Discussion (0)