In this article, I’m going to talk about Python decorators, briefly explain what they are and put special emphasis on class-based decorators. In the end, I’ll present a real-life example of using them in my work. Let’s get started!
What is a decorator?
To truly understand what decorators are, you should be familiar with terms like first-class functions or closures. We’re not going to cover them in detail in this article, but essentially a programming language has first-class functions when they can be passed as parameters to other functions.
Consider the code above. Function foo, upon execution, returns another function called bar, which preserves it’s enclosing scope. Such kind of object is called closure. This allows them to be passed to other functions as arguments. Decorator is essentially a function that accepts another function and performs some additional logic – so „extends” or „decorates” the original function.
How does it work?
It’s a simple example of the function-based decorator. Note that @decorator_function used above definition of original_function is exactly the same thing as original_function = decorator_function(original_function). The decorator accepts the original function and may perform some logic before it or afterward. Properties of the inner function persist through this process as explained in case of closures.
This is just a superficial explanation of function-based decorators. We’re going to dive more in-depth into class-based decorators, as they are the main subject of this article.
Class-based decorators and why do I prefer them.
In general, Object-Oriented Programming might look a bit complicated at first glance. Its syntax follows certain specific rules, which functions don’t. But there’s a reason for it, as these rules ultimately make it much more convenient to manage. the code in bigger projects.
The same goes for decorators. While at first, class-based ones might look more complex, they are simply more convenient once you understand them. As you can see below, a class-based decorator is simply a class with 2 dunder methods: __init__() and __call__().
The first gets called upon instantiation of a decorating object and accepts decorator-related parameters. The later gets called when that instantiated object gets executed (since in Python, everything is an object and everything can have its __call__() method defined). It accepts the original function and preserves its properties.
While most experienced Python developers tend to use function-based decorators. For me, class-based ones make the code more readable and nicely separates the definition of a decorator itself, with the logic that wraps original function.
class DecoratorClass:
def __init__(self, decorator_argument):
"""
Instantiation of decorator_object, like decorator_object = DecoratorClass(arg)
:param arg: Argument of decorator.
"""
self.decorator_argument = decorator_argument
def __call__(self, original_function):
"""
Called when the decorator_object gets called, like decorator_closure = decorator_object(original_function).
Returns actual decorator_closure ready to be executed.
@decorator syntax executes this step automatically.
"""
def wrapper(*args, **kwargs):
# Adds logic before and after the original_function
print('Logic before')
result = original_function(*args, **kwargs) # function execution
print('Logic after')
print(f'Decorator_argument = {self.decorator_argument}')
# Any argument you need is still accessible inside the wrapper
return result
return wrapper
The whole magic behind the execution of class-based decorators appears to be just as simple as in function-based ones. Below you can see an example of a manual step by step execution and using @decorator syntax. Note that both methods return exactly the same results.
@DecoratorClass(decorator_argument=5)
def original_function2(a, b):
print(f'This is original function. It has arguments a = {a} and b = {b}')
original_function2(1, 2)
Execution using @decorator syntax
def original_function1(a, b):
print(f'This is original function. It has arguments a = {a} and b = {b}')
# __INIT__
decorator_object = DecoratorClass(decorator_argument=5)
# __CALL__
decorator_closure = decorator_object(original_function1)
# Closure execution
decorator_closure(1, 2)
Manual step by step execution
What makes decorators so great?
What I love most about decorators is the fact that they support popular Don’t Repeat Yourself rule. In the area, where I found it hardest to fulfill – extending existing methods with redundant logic. Have a class with 20 methods returning some statistics? Want to save results to a CSV file? There you go – write a decorator! Have 20 view functions at your backend and want to assign them to a particular URL? That’s exactly how it’s done in Flask.
Once you’ve got a decorator, it’s backward compatible. Also, it’s trivial to apply and does not require any changes in the executing code. In fact, you could simply write another method that would do the same job. But this would force everyone using your code, to update his code in place of execution. In my project, it appeared to be crucial.
Practical example – @LogException
Assume you’ve got a number of different methods parsing some data, which is likely to be corrupted in some way. This is a very common scenario in the life of a data scientist. What you can do is to write a LogException decorator that handles potential Exceptions by logging them to a file.
Such decorators can be parametrized in whatever way you need. In the example below we’re accepting the name of the log file, the mode in which it should be opened (for example ‘w’ to create a new file or ‘a’ to append to existing one) and optional add_time parameter, which defaults to False.
class LogException:
def __init__(self, filename, mode, add_time=False):
self.add_time = add_time
def __call__(self, parse_method):
def wrapper(*args, **kwargs):
file = open(filename, mode)
try:
result = parse_method(*args, **kwargs)
except Exception as error_msg:
if self.add_time:
error_msg = f"{datetime.datetime.now()}: {error_msg}"
self.file.write(error_msg)
else:
return result
finally:
file.close()
return wrapper
@LogException(filename='error_file', mode='w', add_time=True)
def parse_method():
... # method logic
return data
Decoration of the parsing method
Note that there’s nothing stopping you from using more advanced logic in your wrapper functions, like try/except/else/finally blocks or loops. This example also nicely shows how clean and readable class-based decorators are, thanks to the clear separation of __init__() and __call__() logic.
And best of all, this single decorator can be reused in every parsing method you’ve got and doesn’t affect execution code at all! If this post didn’t dispel your doubts, please contact us!