Decorators provide an elegant way to enhance the functionality of existing code without altering its structure. Understanding decorators is a crucial step towards mastering Python’s expressive and flexible nature. In this article, we’ll embark on a journey to demystify decorators and explore their power and usage.
To gain a thorough understanding of decorators, it is essential to familiarize yourself with the concepts of closures and local functions. If you haven’t already done so, please refer to the following links that delve into these concepts.
Understanding Decorators
In a more abstract manner, we can define a decorator as a callable object that takes one callable as an argument and produces another callable as a result.
Now, let’s delve into the aforementioned statement and gain a broader understanding of decorators. To accomplish this, let’s first explore the concept of callable objects and examine the criteria for determining if we can call an object.
Callable
A callable is any object that we can call as a function. It includes built-in functions, user-defined functions, methods, or objects that implement the call() method. In essence, a callable is something we can invoke using parentheses and arguments to execute an action or return a value.
To determine if an object is callable, you can use the callable()
function. It takes an object as an argument and returns True
if the object is callable, and False
otherwise.
def my_function():
print("Hello, world!")
class MyClass:
def __call__(self):
print("I am callable!")
obj1 = my_function
obj2 = MyClass()
print(callable(obj1)) # Output: True
print(callable(obj2)) # Output: True
print(callable(42)) # Output: False
As demonstrated in the previous example, by defining the __call__
method within a class, you can make instance objects of that class callable. Without implementing this method, instances cannot invoke themselves as functions.
Decorators
The diagram below showcases a straightforward example that illustrates the syntax of decorators and how Python binds the function object with a modified version of the function.
Having gained a comprehensive understanding of the underlying concepts of decorators, we are now ready to explore the various types of decorators.
Function based
import time
def time_duration(func):
def inner(*args, **kwargs):
start_time = time.time()
print(start_time)
f = func(*args, **kwargs)
end_time = time.time()
print(end_time)
print("Difference in time", end_time - start_time)
return f
return inner
@time_duration
def fib(n):
a, b = 0, 1
while a < n:
print(a, end=' ')
a, b = b, a+b
print()
# Output
>>>fib(10000000)
1688101232.4530354
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765 10946 17711 28657 46368 75025 121393 196418 317811 514229 832040 1346269 2178309 3524578 5702887 9227465
1688101232.4540386
Difference in time 0.00100326538085937
The provided code calculates the Fibonacci series up to a specified number of items. The time_duration
function is another function that acts as a decorator by accepting a function and returning a modified version of that function. This allows us to measure the execution time of the fib
function.
When the fib
function is compiled, Python creates an object representing that function and passes it as an argument to the time_duration
decorator. Within the local function of the decorator, we perform modifications, although in this specific example, we only calculate the time required to complete the execution without altering the function itself.
Class based
import functools
import time
class KlassTimeDuration:
def __init__(self, f):
self.f = f
functools.update_wrapper(self, f)
def __call__(self, *args, **kwargs):
"""
Calculating the execution time of decorated function
"""
start_time = time.time()
x = self.f(*args, **kwargs)
end_time = time.time()
print("Difference between time duration", end_time - start_time)
return x
@KlassTimeDuration
def fib(n):
""" Fibonacci function to print items upto specific number."""
a, b = 0, 1
while a < n:
print(a, end=' ')
a, b = b, a + b
print()
#Output
In [1]: fib(1000000)
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765 10946 17711 28657 46368 75025 121393 196418 317811 514229 832040
Difference between time duration 0.0050008296966552734
The functionality in the above example remains consistent with the previous one, but now we utilize a class-based decorator approach. Within the __init__
method, we pass the function object, and the __call__
method handles the enhancement of the invoked function. The usage of the functools
module will be further elaborated in the subsequent section.
Instance type
When it comes to instance decorators, we have the ability to apply decorator instances to functions. In Python, the __call__
method of the instance is utilized when it is used as the decorator, allowing it to accept the function object reference. We then bind the resulting value from this process to the original function as the new function object. In the given example, we can conveniently enable or disable the start_time
tracker by utilizing the instance of the decorator.
class KlassTimeDuration:
def __init__(self):
self.time_tracker = False
def __call__(self, f):
"""
Calculating the execution time of decorated function
"""
def wrap(*args, **kwargs):
start_time = time.time()
if self.time_tracker:
print(f"Execution of {f} started at {start_time}")
x = f(*args, **kwargs)
end_time = time.time()
print("Difference between time duration", end_time - start_time)
return x
return wrap
>>> time_enabler = KlassTimeDuration() <- Instantiate the decorator class
@time_enabler
def fib(n):
""" Fibonacci function to print items upto specific number."""
a, b = 0, 1
while a < n:
print(a, end=' ')
a, b = b, a + b
print()
# Output
>>> fib(10000000)
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765 10946 17711 28657 46368 75025 121393 196418 317811 514229 832040 1346269 2178309 3524578 5702887 9227465
Difference between time duration 0.005959987640380859
>>> time_enabler.time_tracker = True
>>> fib(10000000)
Execution of <function fib at 0x017FCA08> started at 1688104575.2360008
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765 10946 17711 28657 46368 75025 121393 196418 317811 514229 832040 1346269 2178309 3524578 5702887 9227465
Difference between time duration 0.004999637603759766
Use of functools
When a function is decorated with another function, the associated metadata such as __doc__
or __name__
is lost, and instead, the metadata of the decorator is applied. To address this concern, we can utilize the functools
module to preserve the metadata of the original function.
By employing the functools
module, we can observe that the metadata associated with the fib
function is retained, while the metadata pertaining to the KlassTimeDuration
decorator is not visible.
In [2]: fib.__doc__
Out[2]: ' Fibonacci function to print items upto specific number.'
In [3]: fib.__name__
Out[3]: 'fib'
Conclusion
In conclusion, decorators in Python provide a powerful mechanism to enhance and modify the behavior of functions and classes. They allow us to wrap existing code with additional functionality without modifying the original implementation. By leveraging decorators, we can improve code readability, promote code reuse, and separate concerns in our applications.
Understanding concepts like callables, closures, and local functions enables the creation of flexible code, regardless of the decorator type used.