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.