A Comprehensive Guide to Decorators in Python

·

11 min read

You might have encountered functions or classes decorated with functions prefixed with "@", for example, @random. These are known as decorators as they are placed above your class or function.

In this tutorial, you will learn about:

  • Decorators in Python

  • How to create a custom decorator

  • The working of a decorator and how it modifies the original function

  • How to create a custom decorator that accepts arguments and how it works

  • Applying multiple decorators on top of a function and how they operate on the original function

Decorator

What is a decorator in Python? A decorator is an advanced function in Python that modifies the original function without changing its source code. It offers a way to add functionality to existing functions.

If you want to create a class but don't want to write the required magic methods (such as the __init__ method) inside it, you can use the @dataclass decorator on top of the class, and it will take care of the rest.

# @dataclass decorator example
from dataclasses import dataclass

@dataclass
class Pokemon:
    name: str
    high_power: int
    combat_power: int

    def double_hp(self):
        return self.high_power * 2

monster = Pokemon("Charizard", 200, 180)
print(monster.__dict__)

charizard_hp_doubled = monster.double_hp()
print(charizard_hp_doubled)

The @dataclass decorator adds the required magic method within the Pokemon class without changing the source code of the class.

This class works just like any other normal class that contains the __init__ method in Python, and you can access its attributes, and create methods.

{'name': 'Charizard', 'high_power': 200, 'combat_power': 180}
400

By decorating @dataclass on top of the class eliminates the need to write the initializer method and other required methods within the class.

Creating a Custom Decorator

Although there are several pre-built decorator functions available, there is also a way to design a custom decorator function for a particular task.

Consider the following simple decorator function that logs a message on the console whenever the original function is called.

# Decorator function
def log_message(func):
    def wrapper():
        print(f"{func.__name__} function is called")
        func()
    return wrapper

The log_message() function is a decorator function that accepts another function (func) as its argument. Inside log_message(), a wrapper() function is defined which prints a message and calls func.

The log_message() returns the wrapper, effectively changing the behaviour of the original function (func).

You can now create a normal function and decorate it with the log_message() decorator function.

# Decorator function
...

@log_message
def greet():
    print("Welcome to GeekPython")

greet()

Observe how the greet() function is decorated with the log_message() (@log_message) function. When decorating a function on top of another function, you have to stick to this convention.

When you run this code, you'll get the following result.

greet function is called
Welcome to GeekPython

Notice that the greet() function prints a simple message but the @log_message decorator altered the behaviour of the greet() function by adding a message before calling the original function. This modification happens while preserving the signature of the original function (greet()).

How Decorators Work?

Look at this part of the code from the above section where you used the @log_message to decorate the greet() function.

@log_message
def greet():
    print("Welcome to GeekPython")

The above code is equivalent to the following expression.

greeting = log_message(greet)

You will obtain the same outcome as before if you execute the code after making the following modifications.

# Decorator function
def log_message(func):
    def wrapper():
        print(f"{func.__name__} function is called")
        func()
    return wrapper

def greet():
    print("Welcome to GeekPython")

greeting = log_message(greet)
greeting()

--------------------
greet function is called
Welcome to GeekPython

The greet() function is passed to the log_message() function and stored inside the greeting. In the next line, the greeting is called just like any other function. What is happening and how does it work?

After this line (greeting = log_message(greet)) is executed, the variable greeting points to the wrapper() returned by log_message(). If you print the variable greeting, you'll get the reference of the wrapper() function.

greeting = log_message(greet)
print(greeting)

--------------------
<function log_message.<locals>.wrapper at 0x0000024EF60C4C20>

This wrapper() function prints a message and has a reference to the greet() function as func and it calls this function within its own body to maintain the original functionality while adding extra behaviour.

Defining Decorator Without Inner Function

One may wonder why the code in the wrapper() function cannot be inserted inside the scope of the log_message() function like in the following code.

# Decorator function
def log_message(func):
    print(f"{func.__name__} function is called")
    func()
    return log_message

@log_message
def greet():
    print("Welcome to GeekPython")

greet()

In the above code, the code inside the wrapper() function is now placed within the log_message() function's scope. When you run the, you'll see that the greet() function's behaviour has changed but you get an error.

greet function is called
Welcome to GeekPython
Traceback (most recent call last):
  ...
    greet()
TypeError: log_message() missing 1 required positional argument: 'func'

It says one argument is missing when you called the greet() function which means that the greet() function is now pointing to the log_message() function. But when you simply don't call the greet function, it won't throw any error.

...

@log_message
def greet():
    print("Welcome to GeekPython")

greet

--------------------
greet function is called
Welcome to GeekPython

There is little flexibility and very little you can do with it, yet in certain instances it will work.

Handling Function Arguments Within Decorator

What if you have a complex function that accepts arguments and processes them, then you can't approach this problem in this way.

# Decorator function
def log_message(func):
    print(f"{func.__name__} function is called")
    func()
    return log_message

@log_message
def greet(user):
    print(f"Welcome to GeekPython: {user}")

greet("Sachin")

This code will result in an error as the log_message() function doesn't have a helper function to handle the argument the greet() function accepts.

greet function is called
Traceback (most recent call last):
  ...
    @log_message
     ^^^^^^^^^^^
  ...
    func()
TypeError: greet() missing 1 required positional argument: 'user'

Defining Decorator With Inner Function to Handle Function Arguments

You can manage the arguments received by the greet() function by incorporating a nested function (wrapper()) within the log_message() decorator function, using *args and **kwargs as parameters.

# Decorator function
def log_message(func):
    def wrapper(*args, **kwargs):
        print(f"{func.__name__} function is called")
        func(*args, **kwargs)
    return wrapper

@log_message
def greet(user):
    print(f"Welcome to GeekPython: {user}")

greet("Sachin")

--------------------
greet function is called
Welcome to GeekPython: Sachin

This time, the code printed the argument ("Sachin") supplied to the greet() function when it was called, so you didn't receive any errors.

The *args and **kwargs passed to the wrapper() is used to pass on the arguments to func (a reference for the original function) that enables the decorator function to handle the arguments accepted by the original function.

Returning Values from Decorator

In the example above, using greet("Sachin") resulted in the output. However, what if you wanted to return a value from the decorator?

@log_message
def greet(user):
    print(f"Welcome to GeekPython: {user}")
    return f"User: {user}"

# Trying to return a value
greeting = greet("Sachin")
print(greeting)

Since your decorator @log_message doesn't return a value directly, this code will return None.

greet function is called
Welcome to GeekPython: Sachin
None

To handle this situation, you need to ensure that the wrapper() function returns the return value of the original function.

# Decorator function
def log_message(func):
    def wrapper(*args, **kwargs):
        print(f"{func.__name__} function is called")
        return func(*args, **kwargs)
    return wrapper

When you run the following code, you'll get the value returned by the greet() function.

# Decorator function
def log_message(func):
    def wrapper(*args, **kwargs):
        print(f"{func.__name__} function is called")
        return func(*args, **kwargs)
    return wrapper

@log_message
def greet(user):
    print(f"Welcome to GeekPython: {user}")
    return f"User: {user}"

greeting = greet("Sachin")
print(greeting)

--------------------
greet function is called
Welcome to GeekPython: Sachin
User: Sachin

Creating Decorator that Accepts Argument

So far you've created simple decorators but decorators can also accept arguments. Consider the following decorator that accepts arguments.

# Decorator function to slice a string
def slice_string(start=0, end=0, step=None):
    def slice_decorator(func):
        def slice_wrapper(*args, **kwargs):
            print(f"Sliced from char {start} to char {end}.")
            if func(*args, **kwargs) == "":
                print("Text is not long enough.")
            result = func(*args, **kwargs)
            return result[start: end: step]

        return slice_wrapper

    return slice_decorator

In the above code, a decorator function slice_string() is defined. This (slice_string()) decorator function accepts three arguments: start (defaults to 0), end (defaults to 0), and step (defaults to None).

Within this (slice_string()) function, the inner function, slice_decorator(), takes another function (func) as an argument and within the slice_decorator() function, a wrapper function (slice_wrapper()) is defined.

The slice_wrapper() function takes any positional (*args) and keyword (**kwargs) arguments required to handle arguments if any accepted by the original function.

The slice_wrapper() function prints a simple message, and in the next line, checks if the argument is an empty string, if it is then a message is printed otherwise, the result is sliced from the specified range.

This slice_wrapper() function is returned by the slice_decorator() function and eventually, the slice_decorator() function is returned by the slice_string() function.

Now you can create a function and decorate @slice_string on top of it.

# Decorator function to slice a string
...

@slice_string(2, 7)
def intro(text):
    return text

The intro() function is defined that takes text as an argument and returns it. Two arguments (2 and 7) are passed to the @slice_string decorator, meaning the text will be sliced from the character at index 2 to index 7 (excluding the character at the 7th index).

# Decorator function to slice a string
...

chars = intro("Welcome to GeekPython")
print(chars)

--------------------
Sliced from char 2 to char 7.
lcome

Overall, a decorator function that accepts arguments typically involves the interaction of three functions: the outer function (the decorator itself) that accepts arguments, an inner function (the wrapper) that receives the original function, and a nested function (the innermost wrapper) that modifies the behaviour of the original function.

Here is another example of a decorator that accepts an argument.

import time

def sleep_code(t):
    def sleep_decorator(func):
        def sleep_wrapper(*args, **kwargs):

            # Calculate start time
            start = time.perf_counter()
            print(f"Execution Delayed: {t} Seconds")
            # Sleep for t seconds
            time.sleep(t)
            # Calculate end time
            end = time.perf_counter()
            # Evaluate execution time
            print(f"Execution Took   : {round(end - start)} Seconds")

            return func(*args, **kwargs)
        return sleep_wrapper
    return sleep_decorator

@sleep_code(5)
def slow_down(x, y):
    return x**y

obj = slow_down(2, 3)
print(obj)

The @sleep_code decorator takes an argument t representing time in seconds. It modifies the behaviour of the original function (slow_down()) by delaying its execution using time.sleep(t) within the innermost function (sleep_wrapper()). Additionally, before returning the result, it prints the execution time taken by the code, which is measured using time.perf_counter().

When you run the code, you'll get the following result.

Execution Delayed: 5 Seconds
Execution Took   : 5 Seconds
8

Stacking Multiple Decorators on Top of a Function

So far you might have a pretty good idea about decorators and in this section, you'll see that multiple decorator functions can be stacked on top of another function. Here's a simple example.

# First decorator function
def decorator__1(func):
    def wrapper_d1(*args, **kwargs):
        print(f"Called decorator 1")
        return func(*args, **kwargs)
    return wrapper_d1

# Second decorator function
def decorator_2(func):
    def wrapper_d2(*args, **kwargs):
        print(f"Called decorator 2")
        return func(*args, **kwargs)
    return wrapper_d2

# Decorated with multiple decorators
@decorator_1
@decorator_2
def log_message():
    return "Message logged"

message = log_message()
print(message)

Both decorator_1() and decorator_2() have the same boilerplate and log a simple message.

The log_message() is decorated with both (@decorator_1 and @decorator_2) decorators with the @decorator_1 being on the topmost level followed by the @decorator_2.

When you run this code, you'll get the following result.

Called decorator 1
Called decorator 2
Message logged

You can see that messages logged by the decorators are in the exact order as they are stacked on top of the log_message() function.

If you reverse the order of these decorators, the messages will be logged in the same order as well.

# Reversed the order of the decorators
@decorator_2
@decorator_1
def log_message():
    return "Message logged"

message = log_message()
print(message)

--------------------
Called decorator 2
Called decorator 1
Message logged

The code is equivalent to passing log_message() through decorator_1() first, and then passing the result (decorator_1(log_message)) through decorator_2().

message = decorator_2(decorator_1(log_message)

Note: When you are stacking multiple decorators on top of the function, their order matters.

Practical Example

Here's an example that shows when decorating a function with multiple decorators, they need to be in order.

@slice_string(2, 7)
@sleep_code(2)
def intro(text):
    return text

chars = intro("Welcome to GeekPython")
print(chars)

When you run this code, the execution will delayed for 4 seconds because the sleep_code() will be invoked twice.

Sliced from char 2 to char 7.
Execution Delayed: 2 Seconds
Execution Took   : 2 Seconds
Execution Delayed: 2 Seconds
Execution Took   : 2 Seconds
lcome

If you just reverse the order of the decorators in the above code, that would just work fine.

@sleep_code(2)
@slice_string(2, 7)
def intro(text):
    return text

chars = intro("Welcome to GeekPython")
print(chars)

Output

Execution Delayed: 2 Seconds
Execution Took   : 2 Seconds
Sliced from char 2 to char 7.
lcome

You can observe the difference in the output in which the execution of the code took only 2 seconds. That's why you need to ensure that the decorators are in the correct order above the function.

Conclusion

Decorators modify the behaviour of the original function without changing the source code of the original function. They are advanced functions that do modification while preserving the original function's signature.

Python has several built-in decorator functions, and you can also create the custom decorator your program may need.

You saw when you define a custom decorator, you create a function returning a wrapper function. This wrapper function handles the modification and if your decorated function accepts arguments then it uses *args and **kwargs to pass on arguments. If the decorator function accepts arguments then you end up nesting the wrapper function into another function.

You also observed that in order to get the appropriate outcome, decorators must be stacked correctly on top of any function.


🏆Other articles you might be interested in if you liked this one

Why if __name__ == "__main__" is used in Python programs?

Serialize and deserialize Python objects using the pickle module.

Create a WebSocket server and client in Python.

Create and integrate MySQL database with Flask app using Python.

What is __getitem__ method in Python class?

What is the yield keyword in Python and how it is different from the return keyword?


That's all for now

Keep Coding✌✌

Did you find this article valuable?

Support Team - GeekPython by becoming a sponsor. Any amount is appreciated!