10. Decorators
Decorators are annotations that modify the behavior of functions. The mental model of a decorator is that it is an outer function wrapped around a target function. The outer function has a chance to intercept the arguments and modify or interact with them, before invoking the target function. Using decorators is sometimes referred to as meta-programming
since we are programming the program.
10.1. Basic
Below, we have a log()
function that takes a function f
as an argument. Inside log()
function is an inner function decorated()
. Notice that decorated()
is annotated itself with @wraps
and also it takes in 2 arguments (a non-keyworded, variable-length argument *args
and a key-worded, variable-length argument **kwargs
). The @wraps
annotation ensures the reflection against the target function is perserved (reflection is using Python and its inspection functions to get metadata about an object). Without the @wraps
annotation/decoration, getting the name and docstrings of the target function will return the wrapper function’s information instead. The decorated
function invokes the target function and returns its results. The log()
function returns the decorated()
function.
After we define the decorator, we can use it to annotate other fuctions. Below, we annotate add_one()
and minus_one()
with @log
.
1from functools import wraps
2
3def log(f):
4 @wraps(f)
5 def decorated(*args, **kwargs):
6 print(f'init: {args}, {kwargs}')
7 output = f(*args, **kwargs)
8 print(f'output = {output}')
9 print('finished')
10 return output
11 return decorated
12
13@log
14def add_one(a):
15 print('add one')
16 return a + 1
17
18@log
19def minus_one(a):
20 print('minus one')
21 return a - 1
22
23print(add_one(a=10))
24print(minus_one(a=10))
10.1.1. Exercise
Write a decorator to ensure that the value passed into the function below is always in the range [0, 100]. If the value passed in is not in this range, throw an exception (which exception is appropriate?).
1def add_one(a):
2 return a + 1
Solution.
1from functools import wraps
2
3def ensure_legal_range(f):
4 @wraps(f)
5 def decorated(*args, **kwargs):
6 for arg in args:
7 if arg < 0 or arg > 100:
8 raise ValueError(f'{arg} is not in [0, 100]')
9
10 for k, v in kwargs.items():
11 if v < 0 or v > 100:
12 raise ValueError(f'{k}={v} is not in [0, 100]')
13
14 output = f(*args, **kwargs)
15 return output
16 return decorated
17
18@ensure_legal_range
19def add_one(a):
20 return a + 1
21
22print(add_one(10))
23print(add_one(a=10))
24
25print(add_one(-10))
26print(add_one(a=-10))
10.2. Multiple decorators
Here’s another example of defining two decorators and using them together. The log()
decorators simply logs the inputs and outputs of a function. The positive_inputs()
decorator modifies the inputs to always be positive. We decorate the add_one()
and minus_one()
functions with @log
and @positive_inputs
.
1from functools import wraps
2
3def log(f):
4 @wraps(f)
5 def decorated(*args, **kwargs):
6 output = f(*args, **kwargs)
7 print(f'name = {f.__name__}, inputs={args}, {kwargs}, output = {output}')
8 return output
9 return decorated
10
11def positive_inputs(f):
12 @wraps(f)
13 def decorated(*args, **kwargs):
14 nargs = [abs(a) for a in args]
15 nkwargs = {k: abs(v) for k, v in kwargs.items()}
16
17 output = f(*nargs, **nkwargs)
18
19 return output
20 return decorated
21
22@log
23@positive_inputs
24def add_one(a):
25 return a + 1
26
27@log
28@positive_inputs
29def minus_one(a):
30 return a - 1
31
32print(add_one(a=-10))
33print(add_one(a=10))
34print(minus_one(a=10))
35print(minus_one(a=-10))
10.3. Parameterized decorators
Your decorator can also be parameterized to change its own behavior. To achieve a parameterized decorator, we wrap the real decorator yet inside another wrapper function.
1from functools import wraps
2
3def range_check(*a, **k):
4 lower = 10 if 'lower' not in k else k['lower']
5 upper = 100 if 'upper' not in k else k['upper']
6
7 def real_decorator(f):
8 @wraps(f)
9 def wrapper(*args, **kwargs):
10 for arg in args:
11 if arg < lower or arg > upper:
12 raise ValueError(f'{arg} is not in [{lower}, {upper}]')
13
14 for k, v in kwargs.items():
15 if v < lower or v > upper:
16 raise ValueError(f'{k}={v} is not in [{lower}, {upper}]')
17
18 retval = f(*args, **kwargs)
19 return retval
20 return wrapper
21 return real_decorator
22
23@range_check(lower=-10, upper=100)
24def add_one(a):
25 return a + 1
26
27print(add_one(10))
28print(add_one(-10))
29print(add_one(-100))
10.3.1. Exercise
Write a decorator to time the following functions. Make sure to parameterize the time duration in seconds s
or milliseconds ms
. The use of the time
module will help in capturing duration.
1# capture the time duration in seconds
2def get_numbers_s(n=50000000):
3 return [i for i in range(n)]
4
5# capture the time duration in milliseconds
6def get_numbers_ms(n=50000000):
7 return [i for i in range(n)]
8
9# capture the time duration in seconds
10def get_numbers_slow(n=50000000):
11 numbers = []
12 for i in range(n):
13 numbers.append(i)
14 return numbers
Solution.
1from functools import wraps
2import time
3
4def benchmark(*a, **k):
5 units = 's' if 'unit' not in k else k['unit']
6 units = units if units in {'s', 'ms'} else 's'
7
8 def decorator(f):
9 @wraps(f)
10 def wrapper(*args, **kwargs):
11 start = time.time()
12 retval = f(*args, **kwargs)
13 stop = time.time()
14 diff = stop - start
15 if units == 'ms':
16 diff = diff * 1000
17 print(f'diff = {diff}')
18 return retval
19 return wrapper
20 return decorator
21
22
23@benchmark(unit='s')
24def get_numbers_s(n=50000000):
25 return [i for i in range(n)]
26
27@benchmark(unit='ms')
28def get_numbers_ms(n=50000000):
29 return [i for i in range(n)]
30
31@benchmark(unit='s')
32def get_numbers_slow(n=50000000):
33 numbers = []
34 for i in range(n):
35 numbers.append(i)
36 return numbers
37
38get_numbers_s()
39get_numbers_ms()
40get_numbers_slow()
10.4. Built-in decorators
There are many built-in decorators. Use @lru_cache
to cache outputs based on inputs to a function. The second and third calls to get_data()
below will be super fast.
1from functools import lru_cache
2import time
3
4@lru_cache
5def get_data(n=50000000):
6 return [i for i in range(n)]
7
8# first call
9start = time.time()
10get_data()
11diff = time.time() - start
12print(f'{diff:.10f} seconds')
13
14# second call
15start = time.time()
16get_data()
17diff = time.time() - start
18print(f'{diff:.10f} seconds')
19
20# third call
21start = time.time()
22get_data()
23diff = time.time() - start
24print(f'{diff:.10f} seconds')
The @property
decorator will enable you to treat a function as if it was a property of an object. The @staticmethod
decorator will turn a method into a static one (a static method is associated with a class not an instance of that class; you do not have to instantiate an instance of the class to use a static method).
1import math
2
3class Circle(object):
4 def __init__(self, radius):
5 self.radius = radius
6
7 @property
8 def area(self):
9 return Circle.__get_area(self.radius)
10
11 @property
12 def circumference(self):
13 return Circle.__get_circumference(self.radius)
14
15 @staticmethod
16 def __get_area(r):
17 return math.pi * r ** 2
18
19 @staticmethod
20 def __get_circumference(r):
21 return 2.0 * math.pi * r
22
23circle = Circle(10)
24
25print(circle.radius)
26print(circle.area)
27print(circle.circumference)
A list of decorators has been curated.