Python Class Decorators
A class decorator is a function that takes a class as input and returns either a modified version of that class or an entirely new one, without directly modifying the class’s original source code. It is applied to the class definition to add functionality or alter behavior dynamically. Class decorators are similar to function decorators, but they operate on classes instead of functions.
In this guide, you will learn how to create your own custom decorator to tackle real-world programming challenges, as well as how to effectively use Python’s built-in class decorators.
Basic Syntax of Class Decorator
def my_decorator(cls):
# Modify the class or add attributes
return cls # Or return a new modified class
@my_decorator
class MyClass:
pass
How Class Decorators Work Internally
When Python encounters a decorated class, the following sequence of events occurs:
- The class body is executed.
- The class object is created.
- The class object is passed to the decorator function.
- The decorator receives the class as an argument and can modify its attributes or add new methods.
- The decorator returns the modified or new class, which is then used as the final class definition.
This means class decorators run at class definition time, not when you create an instance of the class.
For example, when you use a decorator like this:
@my_decorator
class MyClass:
pass
It is equivalent to:
class MyClass:
pass
MyClass = my_decorator(MyClass)
In both cases, the class object MyClass is passed as as argument to the my_decorator function during its definiton, allowing the decorator to modify or add to the class before it is used.
Note: In Python, classes are first-class objects, meaning they can be passed around like any variable.
Creating Your First Class Decorator
Let’s start with a simple yet practical example that adds an attribute to a class.
Example: Adding a Version Attribute
def add_version(cls):
cls.version = "1.0"
return cls
@add_version
class Product:
pass
print(Product.version) # Output: 1.0
In this example, the function add_version acts as a decorator. It takes a class as its argument, adds a new class attribute version with the value "1.0", and then returns the modifed class.
When the Product class is defined, it is preceded by @add_version decorator, which means Python automatically passes the Product class to the add_version function right after the class is created. Inside the decorator, cls.version = "1.0" dynamically attaches the version attribute to the Product class.
As a result, even though the Product class itself contains no logic, it still gains a version attribute, and printing Product.version outputs "1.0".
Modifying Existing Class Methods
You can also modify an existing method of a class within the decorator.
For example:
def add_version(cls):
cls.version = "1.0"
# Replace the 'apply_discount' method with one that applies a 20% discount
def apply_new_discount(self, price):
return price * (1 - 20 / 100) # Aoplying 20% discount
# Assign the modified method to the class
cls.apply_discount = apply_new_discount
return cls
@add_version
class Product:
# Applying 10% discount, but we will replace it in the decorator
def apply_discount(self, price):
return price * (1 - 10 / 100)
product = Product()
print(product.apply_discount(100)) # Output: 80.0 (after applying 20% discount)
In this example, the Product class initially defines an apply_discount method that applies a 10% discount to the price of the product. However, inside the add_version decorator, we modify this behavior. The decorator defines a new apply_new_discount method (you can name it anything you like) that applies a 20% discount instead. This new method is then assigned to the class’s apply_discount attribute replacing the original apply_discount method defined in the Product class, effectively overriding its behavior. As a result, when we call apply_discount on an instance of Product, it applies a 20% discount instead of the original 10%, demostrating how decorators can be used to modify or replace class method dynamically.
Class Decorators With Arguments
Class decorators can also accept arguments, which makes them more flexible. By adding parameters to the decorator, you can pass custom values or configurations to alter the behavior of the class or its methods.
Unlike simple class decorators, a class decorator with arguments works in two steps. The outer function first receives the decorator’s arguments and returns a decorator function. That decorator function then receives the class and modifies it, such as adding or replacing methods.
Let’s create a decorator that accepts a discount percentage and modifies the class’s method to apply that discount.
def apply_custom_discount(discount_percentage):
def my_decorator(cls):
# Modify the apply_discount method to use the custom discount percentage
def apply_new_discount(self, price):
return price * (1 - discount_percentage / 100)
cls.apply_discount = apply_new_discount
return cls
return my_decorator
@apply_custom_discount(30)
class Product:
def apply_discount(self, price):
return price * (1 - 10 /100) # Default is 10% discount
product = Product()
print(product.apply_discount(100)) # Output: 70.0 (applying 30% discount from the decorator)
In this example, apply_custom_discount() is the outer function that accepts discount_percentage as input. Its purpose is to capture this value and make it available to the decorator logic. This function doesn’t modify the class directly; instead, it returns the actual decorator function, my_decorator.
The my_decorator function is the actual class decorator, and it receives the class (Product) as an argument. Inside my_decorator, we define a new apply_new_discount method that uses the discount percentage provided earlier. This new method is then assigned to the class’s apply_discount attribute, replacing the original apply_discount method defined in the class.
When we use @apply_custom_discount(30) above the Product class, Python first calls the apply_custom_discount(30) function. This call remembers the discount percentage and returns the inner decorator function, my_decorator. Next, the Product class is created and passed to my_decorator, which modifies the class by replacing its original apply_discount method. Now calling apply_discount on a Product instance (object) applies a 30% discount instead.
Built-in Class Decorators
The most frequently used built-in class decorators are:
(1) @property
The @property decorator allows a method to be accessed like an attribute, without parentheses.
For example:
class BankAccount:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
account = BankAccount(5000)
print(account.balance) # Output: 5000 (No parentheses)
In this example, the balance is defined using the @property decorator, allowing it to be accessed like an attribute instead of being called as a method, so no parentheses are required.
(2) @<property>.setter
It is used to define a setter for a property.
For example:
class BankAccount:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, amount):
if amount < 0:
raise ValueError("Balance cannot be negative")
self._balance = amount
account = BankAccount(5000)
account.balance = 3000 # set the balance to 3000 using the balance setter
print(account.balance) # Output: 3000
In this example, we have added a balance setter using @balance.setter. This allows us to assign a new value to account.balance directly, without calling a method.
(3) @staticmethod
It defines a method within a class that doesn’t receive the instance (self) or class (cls) as its first argument. It behaves like a normal function but is part of the class’s namespace for logical grouping.
For example:
class BankAccount:
def __init__(self, balance):
self._balance = balance
@staticmethod
def validate_balance(amount):
if amount < 0:
raise ValueError("Balance cannot be nagative")
return True
print(BankAccount.validate_balance(5000)) # Output: True
print(BankAccount.validate_balance(-50)) # Raises ValueError
In this example, the validate_balance method is a static method because it doesn’t need access to the instance (self) or the class (cls), just the input amount.
(4) @classmethod
It makes a class receive the class (cls) instead of the instance (self) as first argument.
For example:
class BankAccount:
total_accounts = 0 # Class-level attribute to track the number of accounts
def __init__(self, balance):
self._balance = balance
# Increment class-level counter each time an account is created
BankAccount.total_accounts += 1
@classmethod
def get_total_accounts(cls):
return cls.total_accounts
account1 = BankAccount(2000)
account2 = BankAccount(3000)
print(BankAccount.get_total_accounts()) # Output: 2
In this example, the class method get_total_accounts uses cls to access and return the total_accounts class-level attribute.