No history yet

Decorators and Closures

Functions as First-Class Objects

In Python, functions aren't just blocks of code. They are treated like any other object, such as a string, integer, or list. This means you can assign them to variables, pass them as arguments to other functions, and even return them from other functions. This concept is fundamental to understanding decorators.

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

# Assign the function to a variable
welcome = greet

# Call the function through the new variable
print(welcome("Alice"))  # Output: Hello, Alice!

Because functions are objects, you can also define functions inside other functions. These are called inner or nested functions.

Inner Functions and Closures

An inner function is defined within another function's scope. It can access variables from its containing (or enclosing) scope, even after the outer function has finished executing. This behaviour creates a powerful feature called a closure.

closure

noun

An inner function that remembers and has access to variables in its enclosing scope, even after the outer function has returned.

Let's see this in action. The following function outer_func returns its inner function inner_func. Notice how inner_func uses the message variable from outer_func.

def outer_func(message):
    # This is the enclosing scope
    def inner_func():
        # Accesses 'message' from the outer scope
        print(message)

    # Return the inner function itself, not its result
    return inner_func

# Create a closure
hello_func = outer_func("Hello, World!")

# Call the closure
hello_func()  # Output: Hello, World!

Even though outer_func has finished running, the hello_func closure still remembers the value of message. It has 'closed over' that variable, capturing its state. This ability to maintain state without using a class is what makes closures so useful, and it's the mechanism that powers decorators.

Decorators

A decorator is essentially a function that takes another function as an argument, adds some functionality to it, and returns a new function without altering the original function's code. It's a clean way to wrap one piece of logic with another.

Decorators allow you to add 'extra layers' of functionality to your functions, like logging, timing, or access control, keeping the core logic clean and separate.

Here is the basic structure of a decorator:

def my_decorator(func):
    def wrapper():
        # 1. Do something before the original function is called
        print("Something is happening before the function is called.")
        
        # 2. Call the original function
        func()
        
        # 3. Do something after the original function is called
        print("Something is happening after the function is called.")
    return wrapper

To apply this decorator, you can pass a function to it directly. But Python provides a much cleaner syntax for this: the @ symbol, often called 'syntactic sugar'.

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

say_whee()

# This is equivalent to:
# say_whee = my_decorator(say_whee)
# say_whee()

When you run this code, the output shows the wrapper's print statements sandwiching the original function's output.

Something is happening before the function is called. Whee! Something is happening after the function is called.

Practical Decorator Examples

Decorators become truly powerful when you use them to solve common problems. Let's look at a decorator that times how long a function takes to run.

import time

def timer_decorator(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs) # Call the original function
        end_time = time.time()
        print(f"{func.__name__} ran in: {end_time - start_time:.4f} secs")
        return result
    return wrapper

@timer_decorator
def waste_some_time(num_times):
    for _ in range(num_times):
        sum([i**2 for i in range(10000)])

waste_some_time(1)
waste_some_time(100)

Notice the wrapper function now accepts *args and **kwargs. This is crucial for making decorators generic. It ensures that your decorator can wrap any function, regardless of the arguments it takes.

So, by using a decorator, you can add common functionality that is utilized across several functions without repeating the same code in each function, simplifying the creation of those functions.

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # ... decorator logic ...
        return func(*args, **kwargs)
    return wrapper

You can also create decorators that accept arguments themselves. This requires an extra layer of nesting — a function that returns a decorator.

def repeat(num_times):
    def decorator_repeat(func):
        @functools.wraps(func)
        def wrapper_repeat(*args, **kwargs):
            for _ in range(num_times):
                value = func(*args, **kwargs)
            return value
        return wrapper_repeat
    return decorator_repeat

@repeat(num_times=3)
def greet(name):
    print(f"Hello {name}")

greet("World")
# Output:
# Hello World
# Hello World
# Hello World

Finally, you can also use classes to create decorators. A class-based decorator works by implementing the __init__ and __call__ methods. The __init__ method stores the function to be decorated, and the __call__ method implements the wrapper logic. This approach is useful for decorators that need to maintain a more complex state.

class Counter:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.num_calls = 0

    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        print(f"Call {self.num_calls} of {self.func.__name__!r}")
        return self.func(*args, **kwargs)

@Counter
def say_hello():
    print("Hello!")

say_hello()
say_hello()
# Output:
# Call 1 of 'say_hello'
# Hello!
# Call 2 of 'say_hello'
# Hello!

Now let's review the key concepts we've covered.

Time to test your understanding.

Quiz Questions 1/6

What fundamental concept in Python allows decorators to exist?

Quiz Questions 2/6

What is a closure in Python?

By mastering closures and decorators, you can write more modular, reusable, and maintainable Python code, separating concerns cleanly and elegantly.