© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2022
M. IndenPython Challengeshttps://doi.org/10.1007/978-1-4842-7398-2_12

Short Introduction to Decorators

Michael Inden1  
(1)
Zurich, Switzerland
 

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")
    return helper
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")
    if n == 1:
        return 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
Note: Higher Order Functions

In this example, the decorator is created using functions or nested functions. There are also higher order functions, which are when a function receives another function as a parameter and returns a function as a result.

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")
    if n == 1:
        return 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)
Note: Difference Between types of Decoration
For @<decorator>, the decoration is always done. If you call <decorator>(function) you can also call the function regularly and later switch on the decorator explicitly. For this example, you call the check as follows, where the second call produces a ValueError:
# invocation with "factorial" as parameter
print(check_argument_is_positive_integer(factorial)(5))
print(check_argument_is_positive_integer(factorial)(-5))

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")
    return helper
@check_arguments_are_positive_integers
def add(value1, value2):
    return value1 + value2
@check_arguments_are_positive_integers
def subtract(value1, value2):
    return value1 - value2
Note: Explicit Checks or Decorator?

Consider the following: As the number of parameters increases, the complexity of the checks also increases, and the comprehensibility potentially decreases. Thus, from about three parameters to be checked, an explicit examination within the respective functions or methods is probably more appropriate. This helps to maintain or even increase traceability and maintainability.

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
    return wrapper
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")
    return helper
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
    return timed_execute
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset