A Comprehensive Guide to Decorators in Python
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✌✌