Understanding decorators in Python with a case study

6 minute read

What are decorators ?

Today, I would like to speak about decorators. This is quite an advanced technique in Python but you'll see
that it's not a very difficult concept to understand and once you master it, it will really improve your code quality, making it more concise, more understandable and even faster in some cases.

So what are these fellow decorators ?

You may have encountered while reading some code a function definition with a fancy @ above. Like so:

@decorator #<-- this is a decorator
def func():
    #do something

It may seem very weird but it's actually no big deal. A decorator is a function that takes a function as input and returns another function that is able to do new things. It's like enhancing or giving new super powers to an existing function, got it ? :)

def my_decorator(input_func):
    #do something with input_func
    return output_func

To use it in a more convenient way there is this special syntax:

@my_decorator
def func():
    #do something

And this is it you are good to go !

Well not quite yet, first let's get back to the previous definition and let's elaborate a bit on it because there is something important to understand.

As you are returning a new function that didn't exist before (output_func), it means that you need to define it inside the decorator my_decorator like so:

def my_decorator(input_func):
    def output_func():
        #do something with input_func
        input_func(...)
    return output_func

If you are not used to nested function it might seem weird but in fact it's a very common thing to define a function inside another one.

Case Study - map function with multiple arguments

Do you now the function called map ? It applies a function to each element of an iterable. Here is an example:

int_list = [2, 3, 4]
my_func = lambda x: x**2

map(my_func, int_list)

#>>> map at 0x1069aa400> #it returns a map object.

list(map(my_func, int_list)) #let's convert it to a list to see the result

#>>> [4, 9, 16]

Now the thing is that map doesn't allow you to handle multiple function arguments. If my_func has to take multiple inputs, this just can't work. An example bellow:

my_list = [('a', 'b'), ('c', 'd')]

def concat(x, y):
    return x+y 

list(map(concat, my_list))

#>>> TypeError: () missing 1 required positional argument: 'y'

What's happening here is that each tuple of the list is interpreted as one argument. It's basically like trying to execute this line:

concat(x= ('a', 'b')) # we want concat(x='a', y='b') instead

We want to somehow unpack the tuples inside the function concat beforehand. To do so, we could create a function that unpacks tuples.

my_list = [('a', 'b'), ('c', 'd')]

def concat(x, y):
    return x+y 

def unpacker(items):
    return concat(*items)  #the start operator * unpacks the tuple

list(map(unpacker, my_list))

#>>> ['ab', 'cd']

It does work fine but there are many reasons not to code it this way.

  • Firstly, when you read the code map(unpacker, my_list) it's not very clear that you are applying a function that does concatenations. You don't want to make your code implicit.

  • Secondly, the unpacker function is very specific, its only job is to unpack tuples for the concat function.Instead you would want such a function to be more generic so you can use it for other functions, not only concat.

  • ...

To define a more generic unpacker we can use decorators !

#define the decorator
def unpacker(func):
    #creating a nested function to unpack args for `func`
    def star_func(args):
        #unpack args
        return func(*args)
    #return the new function
    return star_func

@unpacker
def _concat(x, y):
    return x+y 

list(map(_concat, my_list))

#>>> ['ab', 'cd']

Isn't it beautiful ? :)

the function _concat now has the ability to unpack the input arguments ! :). Also this is done in a very clean fashion, very little code and eveything is explicit. Moreover if I need to do this for another function, I can just use @unpacker above it !

As a final exercice we can definie a function starmap that uses map as core function and that can handle multiple arguments.

def starmap(func, iterable):
    """apply map on an iterable where each element of this iterable is a tuple"""

    #_func derives from func, it just unpacks arguments beforehand.
    @unpacker
    def _func(*args):
        return func(*args)

    return map(_func, iterable)

list(starmap(concat, my_list))

#>>> ['ab', 'cd']

If you prefer you can code it this way. It's slightly more compact but it may seem weird for someone used to decorators :D .

def starmap(func, iterable):
    """apply map on an iterable where each element of this iterable is a tuple"""
    _func = unpacker(func)

    return map(_func, iterable)

list(starmap(concat, my_list))

#>>> ['ab', 'cd']

Keep in mind this exercice was made for pedagogical purpose, it may not be the most straight forward way to code a starmap function. If you ever need to use map with multiple arguments, I recommand you to use the starmap function from itertools

Some decorators to know

@cache and @lru_cache:

Most recent calls are cached in a dictionnary. It can save time when using a function that periodically calls same arguments.

from functools import lru_cache
@lru_cache
def factorial(n):
    return n * factorial(n-1) if n else 1

@classmethod

Another built in decorator! Once again the name says it all. It allows you

to define a method or a function that is not associated with an instance but with

the class itself. So as a reference argument we use cls that stands for class

rather than self that is used to symbolize an instance.

class MyClass:
    @classmethod
    #we use cls as it stands for class rather than self that symbolizes the instance.
    def my_class_method(cls, x, y):
        return x + y

#Notice that i didn't create any instance beforehand.
MyClass.my_class_method(1, 2)
#>>> 3

#Of course you can also access this method with an instance of the class.
instance = MyClass()
instance.my_class_method(1, 2)
#>>> 3

@staticmethod

This is a built in decorator, it is widely used when defining methods inside a class. Its use is to flag functions that are static as its name suggests. A function is static when it doesn't interact with other functions or attributes of the class. You can see that a function is static when self is not used at all inside its definition.

In fact, a staticmethod can't have cls or self as parameter nor it can access class atributes or instance attributes.

A staticmethod is kind of a very lonely function that is not able to interact with anyone or anything excepts its arguments :D

class MyClass:
    my_attribute = 0
    @staticmethod
    #note that it didn't have to put `self` as an argument.
    def my_static_method(x,y):
        return x + y

    #This function is dynamic as it interacts with an attribute of the instance.
    def my_dynamic_method(self, x, y):
        self.my_attribute = x + y
        return self.my_attribute

    #note that for a dynamic method i have to put `self` as an argument but
    #it feels weird as it's not using any attribute or method of the instance.
    def my_weird_method(self, x, y):
        return x + y

instance = MyClass()
instance.my_static_method(1,2)
#>>>3

@app.route in Flask

This decorator is very important when using the framework Flask. It's designed to tell Flask what URL should trigger the function you are defining. Similarly there is @app.get for FastAPI.

from flask import Flask

app = Flask(__name__)

@app.route("/my_url")
def hello_world():
    return "<p>Hello, World!</p>"

#suppose the url to your website is www.mywebsite.com
#when you'll go to www.mywebsite.com/my_url it will trigger the function `hello_world`