Python Intermediate: Decorators

Decorators are a fundamental tool for achieving separation of concerns and keeping code concise and readable. They provide a clean and reusable way to add functionality to existing code without changing its core logic.

Python Intermediate: Decorators

For more posts about Python, follow the link below.👇👇👇

Exploring Python
Python is frequently utilized in creating websites and software, as well as for automating tasks, analyzing data, and visualizing information.

Decorators are a powerful and flexible way to modify or enhance the behavior of functions or methods. They allow us to add functionality to existing code without altering the original code itself. Decorators are often used for tasks such as logging, authentication, memoization, and more. They work by wrapping the target function with another function, which can perform some actions before and/or after the target function is called.

Basics

Let's start by defining a simple function that will print some messages on the terminal output, named say_hello.

def say_hello():
    print(f'Hello!')

There is nothing incredible here, it just prints "Hello!" on the terminal.

>>> say_hello()
Hello!

Now we can try to write a decorator to decorate the function.

def my_decorator(func):
    def wrapper():
        print(f'Before')
        func()
        print(f'After')
    return wrapper

A decorator is a function that takes another function as its argument. It returns a new function that usually extends or modifies the behavior of the original function.

What can be decorated? Description
functions we can decorate regular functions
methods we can decorate methods within classes
classes we can also decorate entire classes

The @ symbol is used to apply a decorator to a function. Let's call the function again.

>>> say_hello()
Before
Hello!
After

Now we can see that calling say_hello function results in printing three words in the terminal.

Another interesting feature of decorators is they can accept arguments to make them more flexible. We can define a decorator that takes parameters and returns a decorator function. Instead of printing something before and after the call, let's write a decorator that just repeats the function call.

def repeat(num):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(num):
                func(*args, **kwargs)
        return wrapper
    return decorator

The provided code defines a decorator factory function called repeat(num) that generates decorators capable of repeating the execution of functions a specified number of times. The innermost function wrapper(*args, **kwargs) is where the actual repetition occurs, with *args and **kwargs used to collect and pass varying arguments and keyword arguments to the decorated function. This decorator can be applied to functions or methods using @repeat(num) notation, allowing for flexible and reusable repetition of function calls, and it plays a useful role in scenarios where we need to execute functions with different arguments multiple times. Let’s try it out!

>>> @repeat(3)
... def say_hello(name: str = f'Anna'):
...     print(f'Hello, {name}!')
...     
>>> say_hello()
Hello, Anna!
Hello, Anna!
Hello, Anna!

We can see that the decorated function is called three times. Pretty charming. 😄

Commonly-used decorators

Python has some commonly used decorators, each serving a specific purpose. They help define and control the behavior of methods and classes in a structured way, making our code more organized and maintainable.

@staticmethod

This decorator is used to define a static method within a class. Static methods are associated with the class rather than an instance of the class.

>>> class MathUtils:
...     @staticmethod
...     def add(x, y):
...         return x + y
... 
>>> MathUtils.add(3, 5)
8

The provided code defines a class called MathUtils. Inside this class, there is a static method called add. Static methods are associated with the class itself rather than with instances of the class. In this case, the add method takes two arguments, x and y, and returns their sum.

After defining the MathUtils class and its static method, the code invokes the add method using the class itself, not an instance of the class, as in MathUtils.add(3, 5). This call results in the addition of 3 and 5, producing the output 8.

@classmethod

This decorator is used to define a class method within a class. Class methods can access and modify class-level attributes.

>>> class MyClass:
...     class_variable = 42
... 
...     @classmethod
...     def modify_class_variable(cls, new_value):
...         cls.class_variable = new_value
... 
>>> MyClass.modify_class_variable(100)
>>> MyClass.class_variable
100

The provided code defines a Python class called MyClass. Inside this class, there is a class variable named class_variable initially set to the value 42.

Additionally, there is a class method called modify_class_variable. Class methods are associated with the class itself and can access and modify class-level attributes. In this case, the modify_class_variable method takes two arguments: cls, which represents the class, and new_value, which is used to update the class_variable of the class to the provided value.

After defining the MyClass class and its class method, the code calls MyClass.modify_class_variable(100), which modifies the class_variable of the class to the value 100.

Finally, when MyClass.class_variable is accessed, it returns 100, reflecting the updated value of the class variable.

@property

This decorator is used to define a method as a read-only property of a class. It allows us to access the method like an attribute.

>>> class Circle:
...     def __init__(self, radius):
...         self._radius = radius
... 
...     @property
...     def radius(self):
...         return self._radius
...
>>> circle = Circle(5)
>>> print(circle.radius)
5

The provided code defines a class called Circle, which represents a geometric circle. The class has an __init__ method used for initializing instances of the class, taking a radius as an argument, and setting it as an instance variable _radius.

Additionally, there is a property method called radius defined using the @property decorator. Properties allow us to access class attributes like methods, but they are accessed like attributes, making the code more readable and intuitive. In this case, the radius property method returns the value of the _radius instance variable.

After defining the Circle class and its property, an instance of the class circle is created with a radius of 5 using Circle(5). Then, print(circle.radius) is called, and it retrieves the radius property, which returns the value 5. This demonstrates how the @property decorator allows us to access an attribute (in this case, radius) in a class as if it were an attribute rather than a method, improving code clarity and usability.

@setter

This decorator is used in conjunction with @property to define a method as a setter for a property.

>>> class Circle:
...     def __init__(self, radius):
...         self._radius = radius
... 
...     @property
...     def radius(self):
...         return self._radius
... 
...     @radius.setter
...     def radius(self, value):
...         if value < 0:
...             raise ValueError(f'Radius cannot be negative')
...         self._radius = value
...         
>>> circle = Circle(5)
>>> circle.radius = 7
>>> print(circle.radius)
7

Comparing to the previous example, there's been a setter method added for the radius property, defined with the @radius.setter decorator. This setter method is responsible for validating the value passed when attempting to set the radius. It checks if the value is negative and raises a ValueError if so.

After defining the Circle class, an instance of the class circle is created with an initial radius of 5 using Circle(5). Then, circle.radius = 7 is used to set a new value for the radius property, and since 7 is not negative, it assigns this value to the _radius instance variable. Finally, print(circle.radius) is called, and it retrieves the updated radius property, which returns the value 7. This code demonstrates how the @property and @radius.setter decorators can be used together to create a property with custom validation logic.

@abstractmethod

This decorator is used within an abstract base class (defined using the abc module) to declare abstract methods that must be implemented by subclasses.

>>> from abc import ABC, abstractmethod
>>> class Shape(ABC):
...     @abstractmethod
...     def area(self):
...         pass
...         
>>> class Circle(Shape):
...     def __init__(self, radius):
...         self.radius = radius
... 
...     def area(self):
...         return 3.1415 * self.radius * self.radius
...         

The provided code demonstrates the use of abstract classes and methods in Python. First, it imports the ABC (Abstract Base Class) and abstractmethod from the abc module.

Next, it defines an abstract base class called Shape, which inherits from ABC. Inside the Shape class, there is an abstract method area decorated with @abstractmethod. Abstract methods are methods that are declared but not implemented in the abstract base class. Subclasses of Shape are required to implement this method; otherwise, they cannot be instantiated.

Then, a concrete class called Circle is defined, which inherits from Shape. The Circle class has an __init__ method that initializes instances with a radius value, and it also implements the area method, which calculates the area of a circle based on its radius using the formula for the area of a circle.

By defining the Shape class with an abstract method area and then subclassing it with Circle, we ensure that any subclass of Shape must provide an implementation for the area method. This enforces a design pattern where all shapes must have an area calculation method, but the specific implementation details are left to the individual shape subclasses, promoting code consistency and structure.

@dataclass

The @dataclass decorator is used to automatically generate special methods (e.g., __init__(), __repr__(), __eq__(), etc.) for a class based on its class variables. It reduces the boilerplate code we would typically need to write when defining classes with attributes. This makes the code more concise and easier to read.

>>> from dataclasses import dataclass
>>> @dataclass
... class Point:
...     x: int
...     y: int
...     
>>> p1 = Point(1, 2)
>>> p1
Point(x=1, y=2)

The provided code demonstrates the use of the @dataclass decorator. First, it imports the dataclass decorator from the dataclasses module.

Next, it defines a class called Point and decorates it with @dataclass. When a class is decorated with @dataclass, Python automatically generates special methods for it, such as __init__, __repr__, and __eq__, based on the class variables and annotations.

In this example, the Point class has two attributes: x and y, both of type int. With the @dataclass decorator, Python generates an __init__ method to initialize instances of the class, a __repr__ method to provide a human-readable string representation and an __eq__ method to compare instances for equality.

After defining the Point class and creating an instance p1 with values 1 for x and 2 for y, when we print p1, Python uses the automatically generated __repr__ method to display a formatted string representing the Point instance, which appears as Point(x=1, y=2). This simplifies the process of creating and inspecting objects, making code more concise and readable when dealing with data classes.

Useful examples

Decorators find valuable use cases like performance measurement, access control, and enforcing design patterns. This collection of examples demonstrates the practical utility of decorators in enhancing the functionality and maintainability of Python code, showcasing their versatility in various programming scenarios.

Logging

Decorators can be used to log function calls, making it easier to debug and trace program execution.

>>> def log_function_call(func):
...     def wrapper(*args, **kwargs):
...         print(f'Calling {func.__name__} with args: {args}, kwargs  {kwargs}')
...         result = func(*args, **kwargs)
...         print(f'{func.__name__} returned: {result}')
...         return result
...     return wrapper
...     
>>> @log_function_call
... def add(a, b):
...     return a + b
...     
>>> add(2, 3)
Calling add with args: (2, 3), kwargs: {}
add returned: 5
5

The provided code defines a decorator function log_function_call, which takes another function func as its argument. Inside the decorator, there is a nested function wrapper that logs information about the function call, such as its name, arguments, and keyword arguments, before and after invoking the original func. Then, the decorator returns the wrapper function. The @log_function_call syntax is used to apply this decorator to the add function. When add(2, 3) is called, it actually calls the wrapper function, which logs the details of the function call and its return value.

Timing

We can measure the execution time of functions using decorators.

>>> import time
>>> def measure_time(func):
...     def wrapper(*args, **kwargs):
...         start_time = time.time()
...         result = func(*args, **kwargs)
...         end_time = time.time()
...         print(f"{func.__name__} took {end_time - start_time} seconds to execute")
...         return result
...     return wrapper
...     
>>> @measure_time
... def slow_function():
...     time.sleep(2)
...     
>>> slow_function()
slow_function took 2.0050320625305176 seconds to execute

The provided code defines a decorator measure_time, which takes a function func as its argument. Inside the decorator, there is a nested function wrapper that records the start time, then calls the original func with any arguments and keyword arguments, records the end time, calculates the execution duration, and prints the elapsed time in seconds along with the name of the decorated function. The @measure_time decorator is applied to the slow_function. When slow_function() is called, it measures and prints the time it takes to execute. In this case, it will sleep for 2 seconds.

Summary

Decorators allow developers to dynamically alter the behavior of functions, methods, or classes. By wrapping these entities with additional functionality, decorators enhance code modularity, reusability, and readability. They simplify common tasks such as logging, authentication, and caching, reducing boilerplate code and promoting a more organized codebase. Python's built-in decorators, such as @staticmethod, @classmethod, and @property, provide specialized functionality, while custom decorators offer tailored solutions to specific use cases.

What’s next?

Subscribe to receive more posts like this directly to your email!

Ideas💡, questions❔❓ ? Feel free to start the discussion 🗣️🎙️!

Python Intermediate: Resource management
Resource management in Python primarily involves handling system resources such as memory, file handles, and network connections efficiently and safely. This is crucial to ensure that your application runs smoothly and avoids issues such as memory leaks or file corruption.

Ready for challenges? 👇👇👇

Python Intermediate: Brain teasers
In the world of programming, the road to expertise is paved with challenges. Don’t shy away from solving programming problems; instead, embrace them as opportunities for growth.

References

PEP 557 – Data Classes | peps.python.org
Python Enhancement Proposals (PEPs)