Back to Course

Intermediate Python

0% Complete
0/0 Steps
Lesson 10 of 33
In Progress

Function Decorators

Function decorators are a powerful feature in Python that allow you to modify the behavior of a function by wrapping it in another function. They are often used to add additional functionality to a function, such as logging, caching, or authentication.

To create a function decorator, you define a function that takes a function as input and returns another function. The inner function is the decorator, and the outer function is the decorated function. For example:

def my_decorator(func):
    def wrapper():
        print('Before calling the decorated function')
        func()
        print('After calling the decorated function')
    return wrapper

@my_decorator
def hello():
    print('Hello!')

hello()  # Output: Before calling the decorated function, Hello!, After calling the decorated function

In this example, the hello() function is decorated with the my_decorator() function. When the hello() function is called, it is first passed to the my_decorator() function, which returns the wrapper() function. The wrapper() function is then called, which prints some messages and calls the original hello() function.

You can also use the functools.wrap() function to preserve the metadata of the decorated function, such as its name and docstring. For example:

import functools

def my_decorator(func):
    @functools.wrap(func)
    def wrapper():
        print('Before calling the decorated function')
        func()
        print('After calling the decorated function')
    return wrapper

@my_decorator
def hello():
    """Prints a greeting."""
    print('Hello!')

print(hello.__name__)  # Output: hello
print(hello.__doc__)  # Output: Prints a greeting.

Function decorators can also accept arguments. To do this, you define the decorator function to take additional arguments, and pass them to the decorated function as needed. For example:

import functools

def repeat(num):
    def my_decorator(func):
        @functools.wrap(func)
        def wrapper(*args, **kwargs):
            for i in range(num):
                func(*args, **kwargs)
        return wrapper
    return my_decorator

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

hello('Alice')  # Output: Hello, Alice!, Hello, Alice!, Hello, Alice!

Conclusion

Function decorators can be useful for adding common functionality to multiple functions without duplicating code. However, it is important to use them judiciously, as they can make code harder to read and understand if overused.

Exercises

To review these concepts, we will go through a series of exercises designed to test your understanding and apply what you have learned.

Write a function decorator that logs the arguments and return value of a function.

import functools

def log_function(func):
    @functools.wrap(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f'Function {func.__name__} called with arguments {args} and keyword arguments {kwargs}')
        print(f'Return value: {result}')
        return result
    return wrapper

@log_function
def add(x, y):
    return x + y

print(add(3, 4))  # Output: Function add called with arguments (3, 4) and keyword arguments {}
                 #        Return value: 7

print(add(x=3, y=4))  # Output: Function add called with arguments () and keyword arguments {'x': 3, 'y': 4}
                      #        Return value: 7

Write a function decorator that caches the return value of a function based on its arguments.

import functools

def cache(func):
    cache = {}
    @functools.wrap(func)
    def wrapper(*args, **kwargs):
        key = f'{args}-{kwargs}'
        if key in cache:
            return cache[key]
        result = func(*args, **kwargs)
        cache[key] = result
        return result
    return wrapper

@cache
def expensive_function(x, y):
    return x + y

print(expensive_function(3, 4))  # Output: 7
print(expensive_function(3, 4))  # Output: 7
print(expensive_function(x=3, y=4))  # Output: 7
print(expensive_function(x=3, y=4))  # Output: 7

Write a function decorator that authenticates a user before allowing them to call a function.

import functools

def authenticate(func):
    @functools.wrap(func)
    def wrapper(*args, **kwargs):
        username = input('Enter your username: ')
        password = input('Enter your password: ')
        if username == 'Alice' and password == 'secret':
            return func(*args, **kwargs)
        else:
            print('Access denied')
    return wrapper

@authenticate
def protected_function():
    print('You have access to this function')

protected_function()  # Output: Enter your username: Alice
                     #        Enter your password: secret
                     #        You have access to this function

protected_function()  # Output: Enter your username: Bob
                     #        Enter your password: password
                     #        Access denied

Write a function decorator that counts the number of times a function has been called.

import functools

def count_calls(func):
    def wrapper(*args, **kwargs):
        wrapper.count += 1
        return func(*args, **kwargs)
    wrapper.count = 0
    return wrapper

@count_calls
def my_function():
    pass

print(my_function.count)  # Output: 0
my_function()
print(my_function.count)  # Output: 1
my_function()
print(my_function.count)  # Output: 2

Write a function decorator that measures the execution time of a function.

import functools
import time

def measure_time(func):
    @functools.wrap(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        end = time.perf_counter()
        print(f'Execution time: {end - start:.6f} seconds')
        return result
    return wrapper

@measure_time
def long_running_function():
    time.sleep(1)

long_running_function()  # Output: Execution time: 1.000080 seconds