This tutorial will teach you everything you need to know about Python decorators. You’ll learn how to create, use, and combine decorators to enhance your Python code. By the end, you’ll be able to apply decorators in real-world scenarios and solve complex problems with ease.

Beginner30 minutes

Step 1: Understanding Functions as First-Class Objects

In Python, functions are first-class objects, meaning they can be passed as arguments, returned from other functions, and assigned to variables. This is the foundation of decorators.

- Key Concepts:

- Functions can be assigned to variables.

- Functions can be passed as arguments to other functions.

- Functions can return other functions.

- Why It Matters: Decorators rely on these properties to modify or extend the behavior of functions.

def greet(name):
    return f'Hello, {name}!'

# Assigning a function to a variable
my_greet = greet
print(my_greet('Alice'))  # Output: Hello, Alice!

Practice Exercise

Create a function multiply(a, b) that returns the product of two numbers. Assign this function to a variable my_multiply and call it with the values 5 and 3. What is the output?

Show Solution
def multiply(a, b):
    return a * b

my_multiply = multiply
print(my_multiply(5, 3))  # Output: 15

Step 2: Creating a Simple Decorator

A decorator is a function that takes another function as input and returns a new function with added behavior.

- Steps to Create a Decorator:

1. Define a decorator function that accepts a function as an argument.

2. Define a wrapper function inside the decorator to add new behavior.

3. Return the wrapper function.

- Example: A decorator that logs the execution of a function.

def log_execution(func):
    def wrapper(*args, **kwargs):
        print(f'Executing {func.__name__}')
        return func(*args, **kwargs)
    return wrapper

@log_execution
def greet(name):
    return f'Hello, {name}!'

print(greet('Bob'))  # Output: Executing greet
                     #         Hello, Bob!

Practice Exercise

Create a decorator uppercase_result that converts the result of a function to uppercase. Apply it to a function greet(name) that returns 'Hello, {name}!'. What happens when you call greet('Alice')?

Show Solution
def uppercase_result(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

@uppercase_result
def greet(name):
    return f'Hello, {name}!'

print(greet('Alice'))  # Output: HELLO, ALICE!

Step 3: Decorators with Arguments

Sometimes, you need decorators that accept arguments. To achieve this, you need an additional layer of nesting.

- Steps:

1. Define a decorator factory function that accepts arguments.

2. Inside the factory, define the decorator function.

3. Return the decorator function.

- Example: A decorator that repeats a function's execution a specified number of times.

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

@repeat(3)
def greet(name):
    print(f'Hello, {name}!')

greet('Charlie')  # Output: Hello, Charlie!
                  #         Hello, Charlie!
                  #         Hello, Charlie!

Practice Exercise

Create a decorator delay(seconds) that delays the execution of a function by a specified number of seconds. Use the time.sleep() function. Apply it to a function say_hello() that prints 'Hello!'. What happens when you call say_hello() with a delay of 2 seconds?

Show Solution
import time

def delay(seconds):
    def decorator(func):
        def wrapper(*args, **kwargs):
            time.sleep(seconds)
            return func(*args, **kwargs)
        return wrapper
    return decorator

@delay(2)
def say_hello():
    print('Hello!')

say_hello()  # Output: (After 2 seconds) Hello!

Step 4: Chaining Decorators

You can apply multiple decorators to a single function. The decorators are applied from the bottom up.

- Example: Combining log_execution and uppercase_result decorators.

@log_execution
@uppercase_result
def greet(name):
    return f'Hello, {name}!'

print(greet('Dave'))  # Output: Executing wrapper
                     #         HELLO, DAVE!

Practice Exercise

Create two decorators: add_prefix(prefix) that adds a prefix to the result of a function, and add_suffix(suffix) that adds a suffix. Apply both decorators to a function greet(name) that returns 'Hello, {name}!'. What happens when you call greet('Eve') with a prefix of '>>> ' and a suffix of ' <<<'?

Show Solution
def add_prefix(prefix):
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return prefix + result
        return wrapper
    return decorator

def add_suffix(suffix):
    def decorator(func):
        def wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            return result + suffix
        return wrapper
    return decorator

@add_prefix('>>> ')
@add_suffix(' <<

Step 5: Using `functools.wraps` to Preserve Metadata

When using decorators, the original function's metadata (e.g., __name__, __doc__) is lost. The functools.wraps decorator preserves this metadata.

- Example: Using wraps to maintain the original function's name.

from functools import wraps

def log_execution(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f'Executing {func.__name__}')
        return func(*args, **kwargs)
    return wrapper

@log_execution
def greet(name):
    return f'Hello, {name}!'

print(greet.__name__)  # Output: greet

Practice Exercise

Create a decorator timing that measures the execution time of a function using time.time(). Use functools.wraps to preserve the original function's metadata. Apply it to a function calculate_sum(n) that calculates the sum of numbers from 1 to n. What is the execution time for calculate_sum(1000000)?

Show Solution
import time
from functools import wraps

def timing(func):
    @wraps(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:.4f} seconds')
        return result
    return wrapper

@timing
def calculate_sum(n):
    return sum(range(1, n + 1))

calculate_sum(1000000)  # Output: calculate_sum took X.XXXX seconds

Step 6: Real-World Use Case: Caching with Decorators

Decorators are often used for caching expensive function calls. The functools.lru_cache decorator is a built-in solution, but you can also create your own.

- Example: A custom caching decorator.

def cache(func):
    cached_results = {}
    def wrapper(*args):
        if args in cached_results:
            return cached_results[args]
        result = func(*args)
        cached_results[args] = result
        return result
    return wrapper

@cache
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(10))  # Output: 55

Practice Exercise

Create a decorator memoize that caches the results of a function based on its arguments. Apply it to a recursive function factorial(n) that calculates the factorial of n. What is the result of factorial(10)?

Show Solution
def memoize(func):
    cache = {}
    def wrapper(*args):
        if args in cache:
            return cache[args]
        result = func(*args)
        cache[args] = result
        return result
    return wrapper

@memoize
def factorial(n):
    if n == 0 or n == 1:
        return 1
    return n * factorial(n - 1)

print(factorial(10))  # Output: 3628800

Step 7: Advanced: Class-Based Decorators

Decorators can also be implemented as classes. This is useful when you need to maintain state or provide additional functionality.

- Steps:

1. Define a class with a __call__ method.

2. Use the class instance as a decorator.

- Example: A class-based decorator that counts function calls.

class CallCounter:
    def __init__(self, func):
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f'{self.func.__name__} has been called {self.count} times')
        return self.func(*args, **kwargs)

@CallCounter
def greet(name):
    return f'Hello, {name}!'

print(greet('Frank'))  # Output: greet has been called 1 times
                      #         Hello, Frank!

Practice Exercise

Create a class-based decorator Timer that measures the execution time of a function. Use the time.time() function. Apply it to a function calculate_product(n) that calculates the product of numbers from 1 to n. What is the execution time for calculate_product(1000)?

Show Solution
import time

class Timer:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        start_time = time.time()
        result = self.func(*args, **kwargs)
        end_time = time.time()
        print(f'{self.func.__name__} took {end_time - start_time:.4f} seconds')
        return result

@Timer
def calculate_product(n):
    product = 1
    for i in range(1, n + 1):
        product *= i
    return product

calculate_product(1000)  # Output: calculate_product took X.XXXX seconds

Sign in to take Cornell notes on this lesson — they save automatically and stay with your account.

Sign in

Click to access the login or register cheese