Home Python Decorators, A Beginners Guide (With Code Examples)
Post
Cancel

Python Decorators, A Beginners Guide (With Code Examples)

When looking through Python code of other programmers or though the code of Python modules, you might have come across functions or methods which have a string on top of their signature starting with a @.

Those markers on top of functions and methods are called decorators and in this article you are going to learn how Python decorators work and how they can help you to improve your Python code.

How do Python Decorators Work?

Python functions are first class objects. This means a Python functions have the same properties as any other object:

  • A function has a type (function).
  • A function can be stored in a variable.
  • A function can be passed as an argument to another function.
  • A function can be returned from a function.
  • A function has a hash-value.
  • A function can be stored in container data types such as lists, sets, tuples, etc.

This first-class nature of Python gives you the ability to encapsulate a Python function within another function. The following example shows how the first-class nature of Python functions can be used:

1
2
3
4
5
6
7
8
9
10
11
12
def succ(function):
    def wrapper(*args, **kwargs):
        result = function(*args, **kwargs)
        return result+1
    return wrapper

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

if __name__ == "__main__":
    add_plus_one = succ(add)
    print(add_plus_one(23, 42))

Output

1
66

The example above shows that the add() function can be passed as an argument to the succ() (short for successor because the function adds +1) function, which then returns the function wrapper() that encapsulates add(). The returned wrapper() function is assigned to the variable add_plus_one. Because add_plus_one contains the wrapper() function, it can also be used like a function. When calling add_plus_one() the wrapper() function is called which passes the arguments to the encapsulated add() function and adds +1 to the return value of add().

Passing Arguments to Python Decorators

Instead of passing a function as an argument to another function to return a third function, we can use decorators as a shortcut. The following example removes the need for a add_plus_one() function and uses the @succ function as a decorator for add():

1
2
3
4
5
6
7
8
9
10
11
12
def succ(function):
    def wrapper(*args, **kwargs):
        result = function(*args, **kwargs)
        return result+1
    return wrapper

@succ
def add(x, y):
    return x+y

if __name__ == "__main__":
    print(add(23, 42))

Output

1
66

The example above is one of the simplest Python decorators you can define. Generally, simple decorators always follow the same structure:

  • A decorator function after which the decorator is named
  • and a wrapper function inside the decorator function which contains the code that describes the behavior of the decorator itself and encapsulates the function which was decorated.

Python Decorators with Arguments

Python decorators can also have parameters. However, a decorator with arguments should return a function that gets passed a function and returns another function. Which results in three nested functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
def succ(number=1):
    def decorator(func):
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs) + number
        return wrapper
    return decorator

@succ(number=3)
def add(x, y):
    return x+y

if __name__ == "__main__":
    print(add(23, 42))

Output

1
68

In the example above, succ() has the parameter number, and returns the function decorator() which gets passed a reference to the decorated function. Inside the function decorator() is the wrapper() function, which encapsulates the decorated function and contains the logic of the decorator.

A decorator with arguments allows you to have more flexible decorators that can be customized when decorating a function.

And to make the confusion complete, you can even pass functions as decorator arguments:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def succ(number=1, f=lambda x: x+1):
    def decorator(func):
        def wrapper(*args, **kwargs):
            return f(func(*args, **kwargs)) + number
        return wrapper
    return decorator

def mul_two(x):
    return x*2

@succ(f=mul_two)
def add(x, y):
    return x+y

if __name__ == "__main__":
    print(add(23, 42))

Output

1
131

In the code above, the mul_two() is passed as f to the decorator. Inside the wrapper() function, the result of the decorated function is passed to f and then number is added to the return value of f.

Nesting Python Decorators

While passing functions as arguments to decorators seems like a fun idea, it makes the code even more confusing as it already is. Therefore, it is better to nest decorators. So instead of passing the mul_two() function to the succ decorator as a parameter, turn the mul_two() function into its own decorator and add it as an additional decorator to the add() function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def mul_two(function):
    def wrapper(*args, **kwargs):
        result = function(*args, **kwargs)
        return result*2
    return wrapper

def succ(number=1):
    def decorator(func):
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs) + number
        return wrapper
    return decorator

@succ(number=1)
@mul_two
def add(x, y):
    return x+y

if __name__ == "__main__":
    print(add(23, 42))

Output

1
131

The order of the decorators is important. The decorator that is closest to the function signature is applied first, and then all decorators from the bottom to the top are applied in ascending order. So changing the decorators of the add() function to:

1
2
3
4
@mul_two
@succ(number=1)
def add(x, y):
    return x+y

results in:

Output

1
132

Because first, +1 is added to the decorated function and then the result of this is multiplied by two using the mul_two decorator.

Python Classes as Decorators

Defining Python decorators as standalone functions is a great way to add more modularity and flexibility to your code. However, a function and therefore a decorator is stateless which means that no information can be stored when calling the function/decorator without using global variables, which should generally be avoided.

But object-oriented programming is made for bundling data with functionality. And Python can use classes as decorators. The following code is a minimal example using the Count class, which is used as a decorator to track how often a function was called.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Count:
    def __init__(self, func):
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Current call count of '{self.func.__name__}' is {self.count}")
        return self.func(*args, **kwargs)

@Count
def add(x, y):
    return x+y

@Count
def mul(x, y):
    return x*y

if __name__ == "__main__":
    add(23, 42)
    add(23, 42)
    mul(12, 2)
    add(23, 42)
    mul(12, 2)

Output

1
2
3
4
5
Current call count of 'add' is 1
Current call count of 'add' is 2
Current call count of 'mul' is 1
Current call count of 'add' is 3
Current call count of 'mul' is 2

In the code above, the functions mul() and add() are decorated with the Count class. For each decorated function, a new instance of the Count class is created, which holds a reference to the function (self.func) it decorates. When calling the decorated function, the __call__ method of the decorator class is called instead, which encapsulates the decorated function and contains the logic of the decorator.

It is also possible to create decorators from a class that accept arguments. Decorator classes with arguments follow the same logic as decorator functions that accept arguments. Instead of directly implementing the logic of the decorator in the __call__ method, the __call__ method returns a wrapper() function which contains the decorator logic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Count:
    def __init__(self, arg):
        self.arg = arg
        self.count = 0

    def __call__(self, func):
        self.func = func
        def wrapper(*args, **kwargs):
            self.count += 1
            print(f"Current call count of '{self.func.__name__}' is {self.count} with arg='{self.arg}'")
            return func(*args, **kwargs)
        return wrapper

@Count("Hello")
def add(x, y):
    return x+y

@Count("World")
def mul(x, y):
    return x*y

if __name__ == "__main__":
    add(23, 42)
    add(23, 42)
    mul(12, 2)
    add(23, 42)
    mul(12, 2)

Now the decorated function is not stored as an attribute in the constructor (__init__) because the constructor is now responsible for storing the argument that was passed to the decorator. Storing the reference to the decorated function is now done in the __call__ method, which is only called once, in contrast to the previous example where __call__ was called every time the decorated function was called.

Decorating Classes in Python

While in all previous examples, decorators were applied to functions, you are now going to apply a decorator to a class. The following code defines a decorator which can be applied to classes to count how many instances of a class have been created:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def count_instances(cls):
    class CountInstances(cls):
        count = 0

        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            CountInstances.count += 1

    return CountInstances

@count_instances
class Foo:
    def __init__(self, bar):
        self.bar = bar

if __name__ == "__main__":
    foo0 = Foo(42)
    print(f"{Foo.count} instances of class Foo have been created")

    foo1 = Foo(23)
    print(f"{Foo.count} instances of class Foo have been created")

    print(foo0.bar, foo1.bar)

    print(type(foo0))
    print(isinstance(foo0, Foo))

Compared to decorators applied to a function, a decorator applied to classes does not return a wrapper function, it returns a wrapper class instead. The wrapper class (here called CountInstances) inherits from the decorated class and extends its abilities. A notable side effect of decorating classes is that the type of the decorated class is not the wrapper class. However, using the built-in isinstance() function will return True when checked against the undecorated base class.

And finally, a decorator for a class can also accept arguments, and you have been following all the examples carefully you might already anticipate how the code above has to be modified to accept an argument. The decorator class has to be encapsulated in another function which is returned by the outermost function, while the function that wraps around the class returns the class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def count_instances(msg="new instance of"):
    def decorator(cls):
        class CountInstances(cls):
            count = 0

            def __init__(self, *args, **kwargs):
                super().__init__(*args, **kwargs)
                self.msg = msg
                CountInstances.count += 1

            def print_msg(self):
                print(f"{self.msg}")

        return CountInstances
    return decorator


@count_instances("Hello")
class Foo:
    def __init__(self, bar):
        self.bar = bar

if __name__ == "__main__":
    foo0 = Foo(42)
    foo0.print_msg()

    foo1 = Foo(23)
    foo1.print_msg()

Conclusion

Using decorators gives you the ability to extend functions and classes with custom behaviors, which can be useful in many different scenarios. For example, if you want to know when a class was instantiated and how many instances have been created you don’t have to inherit from the class itself, you can just decorate with a timer and/or counter.

Another use case is to slow down the execution of functions for educational purposes using decorators. This can help to better understand the execution of complex code and follow it step-by-step.

Make sure to get the free Python Cheat Sheets in my Gumroad shop. If you have any questions about this article, feel free to join our Discord community to ask them over there.

This post is licensed under CC BY 4.0 by the author.