Dev & EngARTICLE

Mastering decorators in Python: a complete guide with examples

Decorators in Python are fascinating and powerful features that can improve your code. They allow you to modify or extend the behavior of

Mastering decorators in Python: a complete guide with examples
Image: Matheus Reis Eidt

Decorators in Python are fascinating and powerful features that can improve your code. They allow you to modify or extend the behavior of functions and methods without changing the original code. By using them, you can add features to existing functions in a fluid and sustainable way, making it more readable and efficient.

In this article, we'll explore:

  • How functions work in Python;
  • The concept of decorators;
  • How decorators work in Python;
  • Practical examples to illustrate the usefulness of decorators.

Whether you're an experienced Python developer or someone who's just starting out, understanding decorators can open up new possibilities in your programming skill set.

Let's get started!

How functions work in Python

In Python, functions are first-class objects. This means you can work with them the same way you work with other objects, such as strings, integers, or lists. The fact that they are first-class objects gives your functions powerful properties:

  • A function is an instance of the Object type;
  • It can be stored in a variable;
  • It can be passed as a parameter to another function;
  • It can be returned from another function;
  • It can be stored in data structures such as lists or dictionaries.

To better understand functions, let's create some examples:

def add_numbers(number1, number2):
   return number1 + number2

def subtract_numbers(number1, number2):
   return number1 - number2

def apply_function(operation_func):
   return operation_func(10, 5)

print(f"Addition: {apply_function(add_numbers)}")
print(f"Subtraction: {apply_function(subtract_numbers)}")

Running the Python script we created, we'll get the following result:

Addition: 15
Subtraction: 5

In this example, we created two regular functions, add_numbers and subtract_numbers, which take two numbers as input. We also created another function, apply_function, which accepts a function as an argument. Then, we used apply_function twice, first with add_numbers and then with subtract_numbers.

Here's an important distinction to note. When we pass add_numbers to apply_function, we use add_numbers without parentheses. This means we're passing a reference to the function itself, rather than calling it directly. On the other hand, when we write apply_function(...) with parentheses, we're actively calling the function, and it executes as usual.

This demonstrates the concept of functions as first-class objects. A function without parentheses is just a reference, like a variable. But when you add parentheses, it's executed, returning its result.

Inner function: defining functions inside others

It's also possible to define functions inside other functions, which is called inner-functions, and we'll soon see how this is relevant to creating decorators in Python.

Let's create a new example with two inner functions:

def greetings(name):
   def hello():
       return "Hello"
  
   def say_something_nice():
       return "Have a nice day!"
  
   return f"{hello()} {name}. {say_something_nice()}"


print(greetings("John"))

Running this, you'll get the following output:

Hello John. Have a nice day!

Inside the greetings function, we created two other functions, hello and say_something_nice. They're called inner functions because they're defined inside another function.

It's important to understand that inner functions have local scope to their outer function. This means you can't call an inner function directly unless the outer one has already been called. If you try to run hello outside of greetings, you'll get an error.

Here's an example where we modify the code to call hello outside the greetings function to see what happens.

def greetings(name):
   def hello():
       return "Hello"
  
   def say_something_nice():
       return "Have a nice day!"
  
   return f"{hello()} {name}. {say_something_nice()}"


print(greetings("John"))


print(hello())

Now, when you run it, you'll get this output:

c
Hello John. Have a nice day!
Traceback (most recent call last):
  . . .   
NameError: name 'hello' is not defined

You can see that the function runs correctly when you call the outer one (greetings), but if you try to run just the inner function hello, you'll get an error. This happens because of the local scope of the inner function, which isn't available outside the outer one.

Functions that return other functions

Functions can also return other functions. Here's a clearer example:

def classify_number(number):
  
   def even():
       return "Number is even"

   def odd():
       return "Number is odd"
  
   if number % 2 == 0:
       return even
   else:
       return odd
  
number = 10
result = classify_number(number)
print(f"Return from classify_number: {result}")
print(f"Executing the function returned by classify_number: {result()}")

print("\n")

number = 11
result = classify_number(number)
print(f"Return from classify_number: {result}")
print(f"Executing the function returned by classify_number: {result()}")

When you run this code, the output will be:

Return from classify_number: <function classify_number.<locals>.even at 0x104beb040>
Executing the function returned by classify_number: Number is even

Return from classify_number: <function classify_number.<locals>.odd at 0x104beb160>
Executing the function returned by classify_number: Number is odd

Explanation

  1. The classify_number function contains two inner functions: even and odd, which return a text (string) object that describes the type of number.
  2. Depending on whether the input number is even or odd, the classify_number function returns a reference to the even or odd function. It doesn't execute them yet, it just provides a reference.
  3. Once the returned function (result) is stored, you can call it later, like result(), to execute it and get the appropriate message.

By returning a reference to the function, you have the flexibility to call it when you need to, instead of getting the result immediately during the initial function call.

Getting started with decorators in Python

Now that we understand how functions work in Python, let's learn about decorators and why they're so powerful. A decorator is essentially a function that modifies the behavior of another function.

Here's a simple example:

def decorator(func):
   def wrapper(*args, **kwargs):
       print(f"Function {func.__name__} is called")
       func(*args, **kwargs)
       print(f"Function {func.__name__} is completed")
   return wrapper

def add_numbers(number1, number2):
   print(number1 + number2)

add_numbers = decorator(add_numbers)

Explanation

  1. We created two functions:
    • decorator: the decorator function.
    • add_numbers: function that prints the sum of two numbers.
  2. Inside decorator, we define an inner function called wrapper.
    • This function accepts any arguments (*args) and keyword arguments (**kwargs), which can be passed to the original function.
    • It prints a message before and after calling the original function.
  3. We apply the decorator to add_numbers by reassigning add_numbers to the result of decorator(add_numbers). Now, add_numbers points to the wrapper function.

When run in a Python shell:

>>> from decorator1 import add_numbers
>>> add_numbers(10, 5)

This will produce:

Function add_numbers is called
15
Function add_numbers is completed

 

What's happening

  • The add_numbers function now refers to the wrapper function.
  • The wrapper, in turn, calls the original add_numbers function (the one we defined initially) and adds the extra functionality (printing messages) before and after its execution.
>>> from decorator1 import add_numbers
>>> add_numbers
<function decorator.<locals>.wrapper at 0x104f6c700>

You know that a decorator basically wraps a function, modifying its behavior. But the way we applied the decorator in the previous example is peculiar. Below is a more suitable alternative:

The Pythonic way of using decorators

The way we applied the decorator above works, but there's a cleaner, more Pythonic approach using the @ symbol (called "pie syntax"). Using it, our example looks like this:

def decorator(func):
   def wrapper(*args, **kwargs):
       print(f"Function {func.__name__} is called")
       func(*args, **kwargs)
       print(f"Function {func.__name__} is completed")
   return wrapper

@decorator
def add_numbers(number1, number2):
   print(number1 + number2)

Here's what changed:

  • By adding @decorator above the add_numbers function, we automatically apply the decorator without needing to manually reassign add_numbers.
  • The behavior stays the same, but the code becomes much cleaner and easier to read.

Overall, this demonstrates how decorators let you wrap a function with additional behavior in an elegant and reusable way

Real-world examples of Python decorators

Now that we know how to create decorators, let's look at some practical examples to understand how they can be useful in real-world scenarios.

Example 1: measuring execution time

A common use of decorators is measuring how long a function takes to run. This can be especially useful for performance optimization. Here's how we can create this decorator:

import time

def execution_timer(func):

   """Print the runtime of the decorated function"""
   def wrapper_timer(*args, **kwargs):
       start_time = time.perf_counter()
       value = func(*args, **kwargs)
       end_time = time.perf_counter()
       run_time = end_time - start_time
       print(f"Finished {func.__name__}() in {run_time:.4f} secs")
       return value
   return wrapper_timer

Now, let's use this decorator with a function that takes time to run and check the output:

>>> from execution_timer_decorator import execution_timer
>>> @execution_timer
... def amazing_function():
...     for _ in range(500):
...             sum([number*number for number in range(10000)])
...
>>> amazing_function()
Finished amazing_function() in 0.1985 secs

How it works

  1. The decorator stores the start time (start_time) right before the function runs.
  2. After the function finishes, it calculates the total execution time (run_time) by subtracting start_time from the end time.
  3. Finally, it prints the execution time to the terminal console.

This can help you identify slow functions in your code.

Example 2: checking headers in a Django view

Another useful decorator can check whether a request contains specific headers before the endpoint processes it. For example, you might want to ensure a request contains certain headers before accepting it.

Here's how you can create this decorator:

import logging
from functools import wraps
from typing import List

from rest_framework import status
from rest_framework.response import Response


def request_has_headers(headers_names: List[str]):
   def decorator(function):
       @wraps(function)
       def wrapper(request, *args, **kwargs):
           for header in headers_names:
               if header.capitalize() not in request.headers:
                   return Response(status=status.HTTP_400_BAD_REQUEST, data=f"Header {header} is missing")
           return function(request, *args, **kwargs)

       return wrapper

   return decorator

Using the decorator in a Django View

Now you can use this decorator to check the headers without overloading your view's logic:

   @method_decorator(request_has_headers(["my-first-header", “my-second-header]))
   def list(self, request):
	my_data = MyModel.objects.all()
	return Response(self.serializer_class(my_data, many=True, context={"request": request}).data, status=status.HTTP_200_OK)

How it works

  1. Pass a list of required headers (["my-first-header", "my-second-header"]) to the decorator.
  2. The decorator goes through the required headers and checks whether each one is present in the request headers.
  3. If a header is missing, it immediately returns a 400 Bad Request response with an error message.
  4. If all headers are present, the original function (for example, the Django view) runs.

This approach can help ensure your requests meet specific requirements, without overloading the view with repetitive checks.

Conclusion

In this article, we explored how functions in Python can be passed like other objects, how to define inner functions, and how to use and create decorators. Decorators are a powerful and flexible feature in Python that let you modify the behavior of functions or methods without changing their source code. They make it easy to add reusable functionality, such as measuring execution time or validating requests, while keeping your code clean and organized. Overall, it's an incredibly useful tool in Python development.

Translated from the Brazilian Portuguese original · Read the original