In this appendix, I would like to introduce decorators, another topic that allows us to express solutions to cross-cutting functionalities elegantly. Decorators are useful for parameter checks, for example, and are used primarily in this book for advanced recursion topics.
Decorators allow you to add already existing functionality to new functionality transparently, without extensions in the implementation of a function itself. Although writing decorators is pretty straightforward, there are a few specifics to keep in mind. Let’s look at this a little more closely when examining parameters for functions.
B.1 Argument Checks by Decorator
Previously, you performed
various argument checks, such as to ensure a valid range of values. In Python, these sanity checks can be outsourced to a decorator. Consequently, the actual function code can stay as close as possible to the problem to be solved, without special treatment.
A function to check for positive integers can be implemented as follows where you pass a function as a parameter and use a function as a return:
def check_argument_is_positive_integer(unary_func):
def helper(n):
if type(n) == int and n > 0:
return unary_func(n)
else:
raise ValueError("n must be positive and of type int")
As a simple example of usage, let’s consider the calculation of the factorial where the parameter check is still included:
def factorial(n):
if n <= 0:
raise ValueError("n must be >= 1")
return n * factorial(n - 1)
To activate the check, you can wrap the above function with the argument check as follows:
factorial = check_argument_is_positive_integer(factorial)
It is also possible to define a new function as follows:
# wrapping results in new function
checked_factorial = check_argument_is_positive_integer(factorial)
print(checked_factorial(5))
# print(checked_factorial(-5)) # => ValueError
B.2 Syntactic Sugar for Decorators
In Python, there is the
variant with
@. This allows you to place the decorator name directly on top of the function definition:
@check_argument_is_positive_integer
def factorial(n):
if n <= 0:
raise ValueError("n must be >= 1")
return n * factorial(n - 1)
Now you can omit the two lines
if n <= 0:
raise ValueError("n must be >= 1")
and write the function in a shorter and clearer way, as follows:
@check_argument_is_positive_integer
def factorial(n):
if n == 1:
return 1
return n * factorial(n - 1)
B.3 Checking Multiple Parameters
The
check for positive integers can also be extended to multiple parameters (two, in the following code). For example, this may be used for simple arithmetic operations like + and
− for natural numbers:
def check_arguments_are_positive_integers(binary_func):
def helper(param1, param2):
if type(param1) == int and param1 > 0 and
type(param2) == int and param2 > 0:
return binary_func(param1, param2)
else:
raise ValueError("both params must be positive and of type int")
@check_arguments_are_positive_integers
def add(value1, value2):
return value1 + value2
@check_arguments_are_positive_integers
def subtract(value1, value2):
return value1 - value2
B.4 Logging Function Calls and Parameter Passing
Previously, you considered the somewhat simplified cases of one or two
parameters. However, for various decorators, it is important to be able to be called for an arbitrary number of parameters, such as for logging calls or for measuring execution times. For this purpose, the decorator can be defined more generally as follows:
def audit_decorator(func):
def wrapper(*args, **kwargs):
print("Before calling " + func.__name__)
result = func(*args, **kwargs)
print("After calling " + func.__name__)
return result
Let’s use this decorator once for logging. You write the following as a combination of two decorators:
@audit_decorator
@check_arguments_are_positive_integers
def add(value1, value2):
return value1 + value2
When executing
>>> print("add", add(2, 7))
You get, however, the name of the inner decorator, here
helper, instead of the original function
add, which was probably of interest:
Before calling helper
After calling helper
add 9
Based on these issues, I would like to address one more point explicitly: Decorating has worked quite smoothly so far, but you should consider that in this way, the following attributes of the function are lost:
__name__ (the name of the function),
__doc__ (the documentation, the docstring) and
__module__ (the module where the function was defined).
B.5 Improvement with wraps from the functools Module
Previously, you saw the somewhat irritating output of the wrapping instead of the
wrapped function. A workaround is to use
wraps from the module
functools
as follows:
def check_arguments_are_positive_integers(binary_func):
@wraps(binary_func)
def helper(param1, param2):
if type(param1) == int and param1 > 0 and
type(param2) == int and param2 > 0:
return binary_func(param1, param2)
else:
raise ValueError("both params must be positive and of type int")
As a result, the output is as expected:
Before calling add
After calling add
add 9
In addition, you should add @wraps in audit_decorator.
Let’s finish the short introduction with profiling and the measurement of the execution time of functions. For this purpose, you define the following decorator on yourself:
def timed_execution(func):
@wraps(func)
def timed_execute(*args, **kwargs):
start_time = time.process_time()
result = func(*args, **kwargs)
end_time = time.process_time()
run_time = end_time - start_time
print(f"'{func.__name__}' took {run_time * 1000:.2f} ms")
return result