Decorators in python allow you to dynamically change the functionality of another function, without altering it’s code.
What? Is that possible?
Yes.
This covers:
1. What is a decorator and how to create one?
2. Easier way to decorate functions
3. Class decorators
4. Problem with docstrings on decorated functions and how to solve.
What is a decorator in Python?
Decorator is a function that takes another function as an argument, adds some additional functionality, thereby enhancing it and then returns an enhanced function.
All of this happens without altering the source code of the original function.
Let’s see it in action.
Let’s suppose, you have a function that computes the hypotenuse of a triangle.
# Compute Hypotenuse
def hypotenuse(a, b):
return round(float((a*a) + (b*b))**0.5, 2)
hypotenuse(1,2)
Output:
#> 2.24
Example use case:
Let’s just say, you happen to have many such functions defined in your python code, getting executed in a elaborate way.
To keep a track, you want to print out what function is getting executed before actually running it, so you can monitor the flow of logic in your python code.
Here, at the same time, you don’t want to change the actual content of 'Hypotenuse'
or any of the other functions, because obviously since it’s harder to manage larger functions.
So what do we do?
Create a decorator of course.
# Decorator that takes and print the name of a func.
def decorator_showname(myfunc):
def wrapper_func(*args, **kwargs):
print("I am going to execute: ", myfunc.__name__)
return myfunc(*args, **kwargs)
return wrapper_func
Note, wrapper_func
receives (*args
and **kwargs
)
# Decorate Hypotenuse
decorated_hyp = decorator_showname(hypotenuse)
decorated_hyp(1,2)
#> I am going to execute: hypotenuse #> 2.24
Nice. It displayed the custom message showing the name of the function before executing hypotenuse()
.
Notice, the content of hypotenuse
itself has not changed. Very nice!
The great news is: it can decorate any function and not just 'hypotenuse
‘.
So, if you want to do the same for, say a func to calculate circumference
, you can simply decorate it like this and it will work just fine.
# Dummy example
decorated_circ = decorator_showname(circumference)
Nice.
Easier way to decorate functions
But, is there an easier way? Yes.
Simply add @decorator_showname
before the function you want to decorate.
# Method 1: Decorate WITH the @ syntax
@decorator_showname
def hypotenuse2(a, b):
return round(float((a*a) + (b*b))**0.5, 2)
hypotenuse2(1,2)
#> I am going to execute: hypotenuse2 #> 2.24
Basically what you are doing here is, decorate hypotenuse2
and reassign the decorated function to the same name (hypotenuse2
).
# Method 2: Decorate WITHOUT the @ syntax.
def hypotenuse2(a, b):
return round(float((a*a) + (b*b))**0.5, 2)
hypotenuse2 = decorator_showname(hypotenuse2)
hypotenuse2(1,2)
#> I am going to execute: hypotenuse2 #> 2.24
Both approaches are really the same. In fact, adding the @decorator_func
wrapper does what method 2 did.
How to create Class Decorators?
While decorator functions are common in practice. Decorators can also be created as classes, bringing in more structure to it.
Let’s create one for the same logic but using class.
class decorator_showname_class(object):
def __init__(self, myfunc):
self.myfunc = myfunc
def __call__(self, *args, **kwargs):
print("I am going to execute: ", self.myfunc.__name__)
return self.myfunc(*args, **kwargs)
To make this work, you need to make sure:
- The
__init__
method takes the original function to be decorated as the input. This allows the class to take an input. - You define the wrapper on the dunder
__call__()
method, so that the class becomes callable in order to function as a decorator.
@decorator_showname_class
def hypotenuse3(a, b):
return round(float((a*a) + (b*b))**0.5, 2)
hypotenuse3(1,2)
Output:
#> I am going to execute: hypotenuse3 #> 2.24
Problem with Decorators: The docstring help is gone?!
When you decorate a function, the docstring of the original decorated function becomes inaccessible.
why?
Because the decorator takes in and returns an enhanced but a different function. Remember?
# Before decoration
def hypotenuse2(a, b):
"""Compute the hypotenuse"""
return round(float((a*a) + (b*b))**0.5, 2)
help(hypotenuse2)
Help on function hypotenuse2 in module main:
hypotenuse2(a, b)
Compute the hypotenuse
Now, let’s decorate and try again.
# Docstring becomes inaccesible
@decorator_showname
def hypotenuse2(a, b):
"""Compute the hypotenuse"""
return round(float((a*a) + (b*b))**0.5, 2)
help(hypotenuse2)
#> Help on function wrapper_func in module main: #> wrapper_func(*args, **kwargs)
The help does not show the docstring :(.
So how to deal with this?
The Solution
It’s because of this reason, everytime when someone writes a decorator, they always wrap the wrapping function with another decorator called @functools.wraps(func)
from the functools
package.
It simply updates the wrapper function with the docstring of the original function.
It’s quite easy to use:
- Just make sure
functools.wraps
decorates the wrapper function that the decorator returns. - It receives the function whose documentation to adopt as the argument.
import functools
# Add functools docstring updation functionality
def decorator_showname(myfunc):
@functools.wraps(myfunc)
def wrapper_func(*args, **kwargs):
print("I am going to execute: ", myfunc.__name__)
return myfunc(*args, **kwargs)
return wrapper_func
Try decorating now, the docstring should show.
# decorating will show docstring now.
@decorator_showname
def hypotenuse2(a, b):
"""Compute the hypotenuse"""
return round(float((a*a) + (b*b))**0.5, 2)
help(hypotenuse2)
Practice Problems:
Create a decorator to log start time, end time and the total time taken by the function to run.