Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 3

Decorators: Modifying Function Behavior

Recommended resources

Hello! Welcome to the third lesson in our Advanced Python Fundamentals module.

In our last session, we mastered *args and **kwargs, learning how to create functions that accept a variable number of arguments. As I hinted then, this skill is a direct prerequisite for today's topic.

Today's lesson will address the learning outcome: Implement decorators to modify function behavior. Decorators are a powerful concept in Python that allow you to extend or modify the functionality of functions or methods without changing their source code. This is a common pattern in Python, especially in web frameworks and, as we'll see, it has very useful applications in machine learning for tasks like logging, timing, and data validation.

The "Why": Separating Concerns

Imagine you have several functions in your code, and for each one, you want to log when it's called and how long it takes to run. The naive approach would be to add logging and timing code to the beginning and end of every single function.

import time

def function_one():
    start_time = time.time()
    print("Starting function_one...")
    # Original logic of function_one
    time.sleep(1)
    end_time = time.time()
    print(f"function_one finished in {end_time - start_time:.2f} seconds.")

def function_two():
    start_time = time.time()
    print("Starting function_two...")
    # Original logic of function_two
    time.sleep(0.5)
    end_time = time.time()
    print(f"function_two finished in {end_time - start_time:.2f} seconds.")

This approach has two major problems:

  1. Code Duplication: The timing and logging logic is repeated everywhere. If you need to change it, you have to find and edit every instance.
  2. Violates Single Responsibility Principle: Each function is now doing two things: its primary task and also timing/logging. This makes the code harder to read and maintain.

Decorators provide an elegant solution to this by separating these "cross-cutting concerns" (like logging, timing, or authentication) from the core business logic of the function.


Part 1: The Foundation of Decorators

To understand decorators, we first need to embrace a core Python feature: functions are first-class objects. This means you can treat a function just like any other variable—you can pass it as an argument to another function, and you can have a function return another function.

This is the foundation of how decorators work. A decorator is essentially a function that takes another function as input, adds some functionality to it (by "wrapping" it in another function), and returns the new, enhanced function.

To see this foundational concept in action, the following video provides a clear, visual explanation.

Python Decorators - Visually Explained

Watch this segment from 'Python Decorators - Visually Explained' to understand the problem decorators solve and to see a step-by-step guide on creating a basic timer decorator.

Watch from 00:56 to 04:22. Focus on how the decorator is a function that defines and returns an 'enhanced' inner function.

As the video explains, the core structure is a nested function. The outer function is the decorator, and the inner function is the "wrapper" that contains the added logic and a call to the original function.

Python Decorator Implementation Example
This diagram shows the manual process of decoration. The name `function_to_be_used` is reassigned to the new, wrapped function returned by `hello_decorator`.

Here's that manual process in code:

def my_decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()  # This is where the original function runs
        print("Something is happening after the function is called.")
    return wrapper

def say_whee():
    print("Whee!")

# Now, let's manually "decorate" our function
say_whee = my_decorator(say_whee)

# When we call say_whee now, we're actually calling the wrapper
say_whee()

Part 2: Syntactic Sugar and Real-World Flexibility

That manual reassignment (say_whee = my_decorator(say_whee)) works, but it's a bit clunky. Python provides a much cleaner syntax for this, the @ symbol, which is just "syntactic sugar" for the same process.

@my_decorator
def say_whee():
    print("Whee!")

say_whee() # This does the exact same thing as the manual example above

This is much more readable and explicitly states that say_whee is being decorated.

Connecting to the Previous Lesson

Our simple decorator has a major flaw: it only works for functions that take zero arguments. What happens if we try to decorate a function like greet(name)?

# @my_decorator  <-- If you uncomment this, it will raise a TypeError!
def greet(name):
    print(f"Hello, {name}!")

# greet("Alice") 

The wrapper function was defined with no parameters (def wrapper():), so it can't accept the "Alice" argument.

This is where your knowledge of *args and **kwargs from the last lesson becomes essential. To make our decorator general-purpose, we can define the wrapper to accept any combination of positional and keyword arguments and then pass them along to the original function.

Let's revise our decorator:

def general_purpose_decorator(func):
    def wrapper(*args, **kwargs): # Accept any arguments
        print("Something is happening before the function is called.")
        result = func(*args, **kwargs) # Pass them to the original function
        print("Something is happening after the function is called.")
        return result # Return the result of the original function
    return wrapper

@general_purpose_decorator
def greet(name):
    print(f"Hello, {name}!")

@general_purpose_decorator
def add(a, b):
    return a + b

greet("Alice")
sum_result = add(5, 3)
print(f"The sum is: {sum_result}")

Now our decorator is flexible and can be applied to a wide range of functions!

The following video provides an excellent visual walkthrough of this exact process, showing why *args and **kwargs are needed and how to handle return values correctly.

Python Decorators - Visually Explained

This segment clearly demonstrates how to make a decorator flexible enough to handle functions with any number of arguments using *args and **kwargs.

Watch from 07:18 to 15:24. Pay close attention to how *args and **kwargs are used in the wrapper's definition and then unpacked in the call to the base function. Also, note how the wrapper must capture and return the base function's return value.


Part 3: A Crucial Refinement: Preserving Metadata

Decorators have one subtle "gotcha". When you decorate a function, you are technically replacing it with the wrapper function. This means the original function's metadata, like its name (__name__) and its docstring (__doc__), is lost.

@general_purpose_decorator
def say_hello():
    """A simple function that says hello."""
    print("Hello!")

print(say_hello.__name__)
# Expected: 'say_hello'
# Actual: 'wrapper'

print(say_hello.__doc__)
# Expected: 'A simple function that says hello.'
# Actual: None

This can be problematic for debugging and introspection tools. Fortunately, Python's standard library has a fix: another decorator called @functools.wraps. You use it to decorate your wrapper function inside your decorator. It copies the metadata from the original function to the wrapper.

To see how to apply this, let's watch a quick clip.

Python Decorators: The Complete Guide

This video from ArjanCodes quickly explains the metadata problem and demonstrates the solution using @functools.wraps.

Watch from 17:16 to 18:35.

Here is our "best practice" decorator template, which you should use as a starting point for your own decorators:

import functools

def decorator_template(func):
    @functools.wraps(func) # Use this to preserve metadata
    def wrapper(*args, **kwargs):
        # --- Code to run BEFORE the original function ---
        result = func(*args, **kwargs)
        # --- Code to run AFTER the original function ---
        return result
    return wrapper
Test your understanding!

Write a decorator called log_function_call that prints the name of the function being called and its arguments before execution. Make sure it can handle any function and preserves the function's metadata.

Example usage:

@log_function_call
def multiply(x, y, z=1):
    """Returns the product of three numbers."""
    return x * y * z

multiply(2, 3) 
# Should print: "Calling function 'multiply' with args=(2, 3), kwargs={}"

multiply(2, 3, z=5)
# Should print: "Calling function 'multiply' with args=(2, 3), kwargs={'z': 5}"
Show answer
import functools

def log_function_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling function '{func.__name__}' with args={args}, kwargs={kwargs}")
        return func(*args, **kwargs)
    return wrapper

@log_function_call
def multiply(x, y, z=1):
    """Returns the product of three numbers."""
    return x * y * z

product1 = multiply(2, 3)
product2 = multiply(2, 3, z=5)

print(f"Result 1: {product1}")
print(f"Result 2: {product2}")

# Verify metadata
print(multiply.__name__) # Should be 'multiply'
print(multiply.__doc__)  # Should be 'Returns the product of three numbers.'

Part 4: Practical Use Cases

Decorators are not just a theoretical exercise. They are used extensively in real-world code.

Use Case 1: Logging

The example in the exercise is a common one. Here is a complete, visual example of a logging decorator.

Decorator for Logging in Python
This code implements a `logger` decorator that logs the function name, arguments, and return value. Notice the use of `@functools.wraps`, `*args`, and `**kwargs` to make it robust.

Use Case 2: Caching / Memoization

For functions that are computationally expensive, you can use a decorator to cache their results. The first time the function is called with specific arguments, it computes the result and stores it. On subsequent calls with the same arguments, it returns the stored result instantly. Python has a built-in decorator for this!

A Gentle Introduction to Decorators in Python

This article, 'A Gentle Introduction to Decorators in Python', provides an excellent explanation and example of memoization, a powerful technique for optimizing performance.

Read the subsection titled 'Memoization'. Pay attention to how the decorator avoids re-running expensive calculations. Also, note the mention of the built-in @functools.lru_cache, which you should prefer in your own code.

Here's an example using the built-in decorator:

import functools
import time

@functools.lru_cache(maxsize=None) # 'lru' stands for 'Least Recently Used'
def slow_fibonacci(n):
    """Calculates the n-th Fibonacci number recursively (slowly)."""
    if n < 2:
        return n
    return slow_fibonacci(n - 1) + slow_fibonacci(n - 2)

# First call is slow, as it computes and caches all values up to 35
start = time.time()
result = slow_fibonacci(35)
end = time.time()
print(f"Result: {result}. First call took {end - start:.4f} seconds.")

# Second call is instantaneous because the result for 35 is already cached
start = time.time()
result = slow_fibonacci(35)
end = time.time()
print(f"Result: {result}. Second call took {end - start:.4f} seconds.")

This technique can be a lifesaver when working with complex calculations in data analysis or machine learning.


Conclusion

Congratulations! You've just learned one of Python's most powerful and elegant features. You can now modify and extend functions in a clean, reusable, and maintainable way.

Key Takeaways:

  • Decorators are functions that wrap other functions to add new behavior without altering the source code of the wrapped function.
  • They rely on functions being "first-class citizens" in Python.
  • The @decorator syntax is syntactic sugar for the manual assignment my_func = decorator(my_func).
  • To create a general-purpose decorator, its inner wrapper function must accept *args and **kwargs and pass them to the original function.
  • Always use @functools.wraps on your inner wrapper function to preserve the original function's metadata for easier debugging.

Preview of the Next Lesson:
In our next lesson, we will cover exception handling with try-except blocks. You'll learn how to gracefully manage errors in your code. As you might guess, decorators can also be used to create standardized error-handling logic for a group of functions, which is another powerful application of what you've learned today.