Python Decorator

A decorator in Python is the design pattern that enables one to modify or extend the behavior of functions or methods without permanently changing them. This is a powerful, flexible feature of Python, extensively applied for logging, access control enforcement, instrumentation, memoization, and much more.

Key Concepts

  1. First-Class Functions:
  • Functions are first-class objects in Python; that means they can be passed as arguments to other functions, returned from functions, and assigned to variables.

2. Higher-Order Functions:

    • A function that takes another function as an argument or returns a function is called a higher-order function.


    3. Closures:

    • A closure is a function object that has access to variables in its scope even after the outer function has finished executing.

    What is a Decorator?

    A decorator is essentially a higher-order function that takes a function as input and returns a new function with added functionality.

    Syntax

    The @decorator_name syntax is a shorthand for applying a decorator to a function. For example:

    @decorator_name
    def my_function():
        pass

    is equivalent to:

    def my_function():
        pass
    my_function = decorator_name(my_function)

    Basic Example

    def simple_decorator(func):
        def wrapper():
            print("Before the function call")
            func()
            print("After the function call")
        return wrapper
    
    @simple_decorator
    def say_hello():
        print("Hello!")
    
    say_hello()

    Output:

    Before the function call
    Hello!
    After the function call
    • simple_decorator: Takes say_hello as an argument and wraps its behavior in wrapper.
    • wrapper: Adds additional behavior before and after calling the original function.

    Decorators with Arguments

    To create a decorator that accepts arguments, you need a function that returns a decorator.

    def decorator_with_args(arg):
        def decorator(func):
            def wrapper(*args, **kwargs):
                print(f"Decorator argument: {arg}")
                return func(*args, **kwargs)
            return wrapper
        return decorator
    
    @decorator_with_args("Hello")
    def greet(name):
        print(f"Greetings, {name}!")
    
    greet("Alice")

    Output:

    Decorator argument: Hello
    Greetings, Alice!

    Common Use Cases

    1. Logging

    def log_decorator(func):
        def wrapper(*args, **kwargs):
            print(f"Calling {func.__name__} with arguments {args} and {kwargs}")
            result = func(*args, **kwargs)
            print(f"{func.__name__} returned {result}")
            return result
        return wrapper
    
    @log_decorator
    def add(a, b):
        return a + b
    
    add(2, 3)

    Output:

    Calling add with arguments (2, 3) and {}
    add returned 5

    2. Authentication

    def authenticate_decorator(func):
        def wrapper(user, *args, **kwargs):
            if user.get("is_authenticated"):
                return func(user, *args, **kwargs)
            else:
                print("Authentication failed!")
        return wrapper
    
    @authenticate_decorator
    def show_dashboard(user):
        print(f"Welcome, {user['name']}!")
    
    user = {"name": "Alice", "is_authenticated": True}
    show_dashboard(user)

    Output:

    Welcome, Alice!

    3. Memoization (Caching)

    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:
            return 1
        return n * factorial(n - 1)
    
    print(factorial(5))  # Calculated
    print(factorial(5))  # Cached

    Using functools.wraps

    To preserve the metadata of the original function (like its name, docstring, etc.), use functools.wraps.

    from functools import wraps
    
    def simple_decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print("Before the function call")
            result = func(*args, **kwargs)
            print("After the function call")
            return result
        return wrapper
    
    @simple_decorator
    def say_hello():
        """This is a greeting function."""
        print("Hello!")
    
    print(say_hello.__name__)  # Outputs: say_hello
    print(say_hello.__doc__)   # Outputs: This is a greeting function.

    Key Points to Remember

    1. Decorators are one of the ways to extend the functionality of functions.
    2. The @decorator syntax is a short form of passing a function to a decorator.
    3. Use *args and **kwargs to handle functions with varying arguments.
    4. Use functools.wraps to retain the original function’s metadata.
    5. Decorators can be stacked. Many decorators can be applied to one function.