Decorators are to Python what annotations are to Java. They were introduced since Python 2 and are still widely used in order to maintain a clean code and to separate logic which is not strictly related to the behavior of a block of code.
NOTE: decorators in Python are not the same as the decorator design pattern.
In this post, I will talk about Python decorators with the aim of delivering a better understanding of how to write them and how to use them, providing examples as we progress.
Decorators are, simply put, wrappers over existing code. They are used for the purpose of changing or augmenting the behavior of functions and classes. This is where the name comes from, they actually improve code, making it cleaner, thus the word decorate. In this post, we will talk about function decorators.
Let us take for example the case when we have test methods which are susceptible to throwing out errors like assertions errors or other error that might not be handled in the code. In order to avoid writing duplicated code in each method, we can simply define a decorator that does this and “wrap” it around our test methods. We would have something like this:
@handle_exception def test_one(): do_the_testing()
Now let’s see how we can implement this @handle_exception decorator. The base structure for a decorator is like this:
def decorator(func) def wrapper(*args, **kwargs): do_something_before_func() func(args, kwargs) do_something_after_func() return wrapper
Now let’s see what we have here:
- decorator is the actual decorator, which takes as input a parameter called “func” even though when we used it before, we did not specify this parameter. This is because the functions in Python take as first argument the objects it is been called over. In our case a function.
- func is a reference to the original function. In this way, we have access to the original behavior of the function.
- wrapper is a nested function which gives us access to the original parameters with which “func” was called. For example, if our initial function would be like this: test_one(p, p1=2), parameter “p” would have been accessed through args and parameter “p1” through kwargs.
- do_something_before_func is executed before we execute our original function
- func(args, kwargs) executes the original function together with the original parameters.
- do_something_after_func is executed after we execute our original function.
Using this, let’s write out handle_exception decorator:
def handle_exception(func) def wrapper(*args, **kwargs): try: func(args, kwargs) except Exception as ex: print(ex) return wrapper
We are simply surrounding our original function with a try-catch mechanism and logging the exception to the standard output.
Now you might wonder what if I want to parametrize a decorator and use it like this:
@handle_exception(log_to_file=True) def test_one(): do_the_testing()
In this case, the solution is simple: we just want to have access to additional information in our nested decorator functions. For this, we define another function of the top of the decorator itself:
def handle_exception(log_to_file=False) def decorator(func) def wrapper(*args, **kwargs): try: func(args, kwargs) except Exception as ex: if log_to_file: store_error_to_file(ex) print(ex) return wrapper return decorator
This way we can add an unlimited number of parameters in order to further customize the decorator.
Decorators, in general, let you play with the architecture and the structure of your program. They offer more flexibility to the programmer and provide a different way of modularizing your code leading to better maintainability and extensibility. Function decorators, more specifically allow you to add an extra layer of logic, usually generic like a filer, in order to keep the code, which actually does the important part, clean and readable.