Top Python Decorators for Elegant Code: Must-Know Tools for Python Developers
Python decorators are a powerful feature of the language that can be used to modify or extend the behavior of functions and classes. In this article, we'll explore a few of my favorite decorators that you may need frequently.
@dataclass
The @dataclass
decorator is a relatively new addition to Python, introduced in version 3.7. It automatically generates methods like __init__()
, __repr__()
, and __eq__()
based on the class's attributes, making it faster and easier to write code for classes that are mainly used for data storage.
Here's an example of how to use @dataclass
:
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
job: str = "unemployed"
# Create an instance of the Person class
p = Person("Alice", 25, "programmer")
# Print out the attributes of the Person object
print(p.name, p.age, p.job)
In this example, the @dataclass
decorator is used to define a Person
class with three attributes: name
, age
, and job
. The job
attribute has a default value of "unemployed"
. When an instance of the class is created, its attributes can be accessed and modified like any other Python object.
@lrucache
The @lru_cache
decorator is used to cache the results of a function with a limited-size cache. This can be useful when calling a function that is computationally expensive or time-consuming usually recursion problems with overlapping subproblems -bottom-up dynamic programming.
from functools import lru_cache
@lru_cache(maxsize=5)
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
print(fibonacci(5)) # Output: 5
print(fibonacci(10)) # Output: 55
print(fibonacci(15)) # Output: 610
print(fibonacci(20)) # Output: 6765
In this example, we define a fibonacci
function that recursively calculates the nth Fibonacci number. We decorate the function with @lru_cache(maxsize=5)
, which caches up to 5 most recently used function calls, allowing for faster execution when the same arguments are provided again.
Note that the @lru_cache
decorator is especially useful for computationally expensive functions that are called repeatedly with the same arguments, as it can significantly improve their performance. However, it's important to be mindful of the maxsize
argument to avoid excessive memory usage.
@retry
The @retry
decorator is used to retry a function if it raises an exception. This can be useful when calling a function that depends on an external resource that may be temporarily unavailable, such as a network service.
Here's an example of how to use @retry
:
from retry import retry
@retry(Exception, tries=3, delay=1)
def connect_to_service():
# Code to connect to external service
pass
In this example, the @retry
decorator is used to define a function connect_to_service()
that will retry three times with a one-second delay if it raises any exception.
@contextmanager
The @contextmanager
decorator is used to define a context manager, which is a way of managing resources that need to be acquired and released in a specific order. Context managers can be used to ensure that resources are properly cleaned up, even if an exception is raised.
Here's an example of how to use @contextmanager
:
from contextlib import contextmanager
@contextmanager
def open_file(filename):
f = open(filename)
try:
yield f
finally:
f.close()
with open_file('example.txt') as f:
# Code to read from file
pass
In this example, the @contextmanager
decorator is used to define a context manager open_file()
that opens a file and yields the file object to the caller. The finally
block ensures that the file is closed when the context is exited, regardless of whether an exception is raised.
Database Connection Use-case
Another important example of how to use the @contextmanager
decorator in Python to create a context manager for a database connection class:
from contextlib import contextmanager
import psycopg2
class DatabaseConnection:
def __init__(self, dbname, user, password, host, port):
self.conn = psycopg2.connect(
dbname=dbname,
user=user,
password=password,
host=host,
port=port
)
def query(self, sql):
cur = self.conn.cursor()
cur.execute(sql)
return cur.fetchall()
def close(self):
self.conn.close()
@contextmanager
def get_db_connection(dbname, user, password, host, port):
conn = DatabaseConnection(dbname, user, password, host, port)
try:
yield conn
finally:
conn.close()
# Example usage:
with get_db_connection('mydb', 'myuser', 'mypassword', 'localhost', '5432') as conn:
results = conn.query('SELECT * FROM mytable')
print(results)
In this example, we define a DatabaseConnection
class that encapsulates a PostgreSQL database connection, with a query
method to execute SQL statements and a close
method to close the connection.
We then use the @contextmanager
decorator to define a generator function get_db_connection
that creates a DatabaseConnection
object and yields it to the with statement block, allowing us to use the database connection within the block. When the block exits, the finally
block is executed, ensuring that the connection is closed properly.
@timeout
The @timeout
decorator can be used to limit the amount of time a function is allowed to execute before raising a TimeoutError
. This can be useful when calling a function that may take a long time to execute, or when there is a risk of the function getting stuck in an infinite loop.
Here's an example of how to use @timeout
:
from timeout_decorator import timeout
@timeout(5)
def long_running_function():
# Code that takes a long time to execute
pass
In this example, the @timeout
decorator is used to define a function long_running_function()
that will raise a TimeoutError
if it takes longer than 5 seconds to execute.
@property
It is similar to the getter
setter
provided in languages like Java.
allows us to define a method that can be accessed like an attribute. It allows us to define getter methods for attributes that can be accessed without invoking a method.
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("Radius must be positive.")
self._radius = value
@property
def diameter(self):
return self._radius * 2
@diameter.setter
def diameter(self, value):
self.radius = value / 2
@property
def area(self):
return 3.14159 * self._radius ** 2
c = Circle(5)
print(c.radius) # Output: 5
print(c.diameter) # Output: 10
print(c.area) # Output: 78.53975
c.radius = 7
print(c.diameter) # Output: 14
print(c.area) # Output: 153.93791
c.diameter = 12
print(c.radius) # Output: 6
print(c.area) # Output: 113.09724
In this example, we have a Circle
class with a radius
attribute. We define radius
, diameter
, and area
as read-only properties using the @property
decorator.
We also define setter methods for radius
and diameter
using the @radius.setter
and @diameter.setter
decorators. This allows us to set the radius
and diameter
properties using a single method call.
In the example, we create a Circle
object with radius 5, and then print out the radius
, diameter
, and area
properties. We then set the radius
and diameter
properties using the setter methods, and print out the properties again to see the updated values.
@wraps
Creating a custom @debug
decorator using wraps
This decorator can be used to print out debugging information when a function is called, including its arguments and return value.
Here's an example of how to create @debug
:
from functools import wraps
def debug(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}() with args {args} and kwargs {kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__}() returned {result}")
return result
return wrapper
@debug
def add_numbers(x, y):
return x + y
result = add_numbers(2, 3)
print(result)
# Output
# Calling add_numbers() with args (2, 3) and kwargs {}
# add_numbers() returned 5
# 5
In the example above, we defined the debug()
function as a decorator that takes in a function func
as its argument. The wrapper()
function is defined inside the debug()
function and takes in *args
and **kwargs
as its parameters.
Inside the wrapper()
function, we print out the name of the function being called along with its input parameters using f-strings. We then call the original function func()
with the input parameters and store the result in the result
variable.
Finally, we print out the name of the function being called along with its return value using f-strings and return the result.
That's s wrap.
Your engagements are welcome. For more Python
tips and tricks subscribe to my blog