Decorators in Python are a powerful tool that allows you to modify the behavior of functions and classes. In this tutorial, we'll take a deeper look at advanced decorators and how they can be used to solve common programming challenges.
This tutorial will talk about the different kinds of decorators, decorator chaining, and some common useful decorators
Decorator Functions
A decorator function takes a function as an argument and returns a modified version of it. It is used to modify the behavior of the original function.
Here's an example of a simple decorator function
# The decorator function adds two numbers to the result of the original function
def add_two(func):
def wrapper(*args, **kwargs):
# Original Function is invoked
result = func(*args, **kwargs)
# Result of original function is modified by adding 2
return result + 2
return wrapper
@add_two
def multiply(a, b):
return a * b
@add_two
def divide(a,b):
return int(a/b)
print(multiply(3, 4)) # 14
print(divide(10,2)) #7
In this example, the add_two
decorator function takes the multiply
function as an argument and returns the wrapper
function, which adds two to the result of multiply
. The @add_two
syntax before the multiply
function is a shorthand for the following:
def multiply(a, b):
return a * b
multiply = add_two(multiply)
Since we do not know the number of parameters the original function might have we use args
and kwargs
Decorator Classes
In addition to decorator functions, you can also use decorator classes to modify the behavior of functions and classes.
Here's an example of a simple decorator class that adds two numbers to the result of the original function:
class AddTwo:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
result = self.func(*args, **kwargs)
return result + 2
@AddTwo
def multiply(a, b):
return a * b
@AddTwo
def divide(a,b):
return int(a/b)
print(multiply(3, 4)) # 14
print(divide(10,2)) #7
__init__
and __call__
are called dunder methods or magic methods
In this example, the AddTwo
decorator class takes the multiply
function as an argument and returns a callable object (an instance of AddTwo
) that adds two to the result of multiply
. The @AddTwo
syntax before the multiply
function is a shorthand for the following
def multiply(a, b):
return a * b
multiply = AddTwo(multiply)
Parametrized Decorators
So far, we've looked at simple decorators that modify the behavior of a function in a fixed way. However, you can also create parametrized decorators that take parameters to control their behavior.
Here's an example of a parametrized decorator function that adds a specified number to the result of the original function:
def add_n(n):
def decorator(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result + n
return wrapper
return decorator
@add_n(10)
def multiply(a, b):
return a * b
@add_n(12)
def divide(a,b):
return int(a/b)
print(multiply(3, 4)) # 22
print(divide(10,2)) # 17
In this example, the add_n
decorator function takes a parameter n
and returns a decorator function decorator
that adds n
to the result of the original function. The @add_n(10)
syntax before the multiply
function is a shorthand for the following:
def multiply(a, b):
return a * b
multiply = add_n(10)(multiply)
Below is an image comparing our first decorator add_two
and the parameterized decorator add_n
Before we were wrapping the original function and returning the wrapper
Now, we are wrapping the decorator (that wraps the original function) and returning the decorator
Decorating Classes
In our previous examples, we looked at how to decorate functions. But you can decorate classes as well
Here's an example of a simple class decorator that adds a class attribute n
to the decorated class:
def add_attribute(n):
def class_decorator(cls):
cls.n = n
return cls
return class_decorator
@add_attribute(10)
class MyClass:
pass
print(MyClass.n) # 10
In this example, the add_attribute
decorator function takes a parameter n
and returns a class decorator function class_decorator
that adds the class attribute n
to the decorated class. The @add_attribute(10)
syntax before the MyClass
class is a shorthand for the following:
class MyClass:
pass
MyClass = add_attribute(10)(MyClass)
Below is another example fo decorating classes
def add_method(cls):
def new_method(self):
return "Hello, World!"
setattr(cls, 'new_method', new_method)
return cls
@add_method
class MyClass:
pass
my_obj = MyClass()
print(my_obj.new_method()) # Hello, World
It simply adds new_method
to every instance of MyClass
Decorator Chaining
One of the most powerful features of decorators is the ability to chain them together to modify the behavior of a function or class in multiple ways. Remember, a decorator simply returns a modified function. This modified function can be passed to another decorator to further modify it.
Here's an example of how to chain two decorators to modify the behavior of a function:
# Adds n to the result of original function
def add_n(n):
def decorator(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result + n
return wrapper
return decorator
# Multiplies result of original function by n
def multiply_by_m(m):
def decorator(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result * m
return wrapper
return decorator
@add_n(10)
@multiply_by_m(5)
def multiply(a, b):
return a * b
@multiply_by_m(5)
@add_n(12)
def divide(a,b):
return int(a/b)
print(multiply(3, 4)) # 70
print(divide(10, 2)) #85
We have two parameterized decorators to multiply and add. The order of applications of the decorators chained is from bottom to top. I.e the last decorator is applied first and the first decorator is applied last.
In this example, the multiply
function is decorated by both the add_n(10)
and multiply_by_m(5)
decorators. The multiply_by_m(5)
decorator is applied first, followed by the add_n(10)
decorator.
In the second case, however, The add_n(12)
decorator is applied first and multiply_by_m(5)
is applied second.
Use Cases of Decorators
1. Timing Decorator
A timing decorator can be used to measure the time taken by a function to execute. Here's an example:
import time
def timing_decorator(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"Time taken: {end - start:.2f} seconds")
return result
return wrapper
@timing_decorator
def long_running_function():
time.sleep(3)
long_running_function()
In this example, the timing_decorator
decorator adds timing information to the long_running_function
function. When the long_running_function
is executed, the wrapper
function provided by the decorator measures the time taken for the function to execute and prints the result.
2. Authorization Decorator
An authorization decorator can be used to enforce access control on a function. Here's an example:
def authorized(func):
def wrapper(*args, **kwargs):
user = get_current_user()
if not user.is_authenticated():
raise Exception("Unauthorized")
return func(*args, **kwargs)
return wrapper
@authorized
def restricted_function():
print("Access granted")
In this example, the authorized
decorator adds an authorization check to the restricted_function
function. When the restricted_function
is executed, the wrapper
function provided by the decorator retrieves the current user and checks if the user is authenticated. If the user is not authenticated, the wrapper raises an exception.
3. Logging Decorator
A logging decorator can be used to log the execution of a function. Here's an example:
def logging_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling function: {func.__name__}")
result = func(*args, **kwargs)
print(f"Returning from function: {func.__name__}")
return result
return wrapper
@logging_decorator
def some_function(a, b):
return a + b
print(some_function(3, 4)) # 7
In this example, the logging_decorator
decorator adds logging information to the some_function
function. When the some_function
is executed, the wrapper
function provided by the decorator logs the function name before and after the function is executed.
We can chain this with the timing decorator
@logging_decorator
@timing_decorator
def long_running_function():
time.sleep(3)
'''
Calling function: wrapper
Time taken: 3.00 seconds
Returning from function: wrapper
'''
long_running_function()
4. Cache Decorator
A cache decorator can be used to cache the results of a function for faster access. Here's an example:
def cache_decorator(func):
cache = {}
def wrapper(*args, **kwargs):
key = f"{args[0]}"
print(cache)
if key not in cache:
print("Calling function")
cache[key] = func(*args, **kwargs)
else:
print("Getting value from cache")
return cache[key]
return wrapper
@cache_decorator
def expensive_function(n):
result = 0
for i in range(n):
result += i
return result
'''
{}
Calling function
4950
{'100': 4950}
Getting value from cache
4950
'''
print(expensive_function(100)) # 4950
print(expensive_function(100)) # 4950
In this example, the cache_decorator
decorator adds a cache to the expensive_function
function. When the expensive_function
is executed, the wrapper
function provided by the decorator checks if the result is already present in the cache. If it is, the cached result is returned, otherwise the function is executed and the result is added to the cache.
Notice how the cache
a dictionary is still able to persist between calls to the decorated function because the wrapper function created by the decorator retains a reference to the cache
dictionary. This allows the wrapper function to access and modify the cache
dictionary.
If a new function is decorated with the cache
decorator, the wrapper function will have a reference to a new empty cache
dictionary
'''
{}
Calling function
100
'''
print(outputResult(100))
'''
{'100': 4950}
Getting value from cache
4950
'''
print(expensive_function(100))
5. Singleton Class Decorator
A singleton class decorator can be used to ensure that only one instance of a class is created. Here's an example:
def singleton(cls):
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class MyClass:
pass
obj1 = MyClass()
obj2 = MyClass()
obj3 = MyClass()
print(obj1 is obj2) # True
print(obj2 is obj3) # True
print(obj1 is obj3) # True
In this example, the singleton
decorator is used to decorate the MyClass
class. The decorator works by creating a get_instance
function that stores an instance of the class in a dictionary. When the get_instance
function is called, it checks if an instance of the class already exists and returns it if it does, otherwise, it creates a new instance of the class and adds it to the dictionary.
Conclusion
In conclusion, decorators are a powerful feature in Python that allow you to add functionality to a function without modifying its original code. Decorators can be used to add caching, logging, validation, and other functionality to functions in a modular and reusable way.
In this tutorial, we learned how to define and use decorators in Python, including how to define decorator functions and decorator classes, and how to use decorators to add caching to a function.
If you'd like a more in-depth tutorial about decorators, you should watch this awesome Pycon Talk
Overall, decorators are a valuable tool in your Python toolbox, and mastering decorators can help you write more modular, reusable, and maintainable code
This article was originally published on RealPythonProject.com on March 4th,2023.