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.