Decorators Part 1: Introduction to Python Decorators
Decorators are one of Python's most powerful and elegant features, enabling you to modify or enhance functions and classes without changing their source code. Used extensively in web frameworks like Flask and Django, decorators are built on a simple concept: functions are first-class objects that can be passed around, wrapped, and returned. Once you understand this foundation, decorators reveal themselves as a clean, reusable way to add cross-cutting concerns—logging, authentication, caching, timing—to any function.
Key Takeaways:
- Functions are first-class objects: you can assign them to variables, pass them as arguments, and return them from other functions
- A decorator is a function that takes another function as input, wraps it with additional behavior, and returns the modified function
- The
@decoratorsyntax is syntactic sugar forfunction = decorator(function), applied automatically at function definition - Decorators enable code reuse for cross-cutting concerns (logging, timing, authentication) without modifying original functions
What Does It Mean That Functions Are First-Class Objects?
In Python, functions are objects just like integers, strings, or lists. This means you can:
- Assign a function to a variable
- Pass a function as an argument to another function
- Return a function from another function
This flexibility is the foundation of decorators.
Assigning Functions to Variables
def say_hello(name):
return f"Hello, {name}!"
# Assign the function object to a variable
greet = say_hello
# Call the function through the new variable
print(greet("Alice")) # Output: Hello, Alice!
print(greet("Bob")) # Output: Hello, Bob!
Both say_hello and greet refer to the exact same function object.
Passing Functions as Arguments
def say_hello(name):
return f"Hello, {name}!"
def say_goodbye(name):
return f"Goodbye, {name}!"
def process_greeting(greeter_func, name):
"""Takes a function and a name, and calls the function."""
result = greeter_func(name)
print(result)
# Pass different functions as arguments
process_greeting(say_hello, "Alice") # Output: Hello, Alice!
process_greeting(say_goodbye, "Bob") # Output: Goodbye, Bob!
The key insight: functions can be treated as data, passed to other functions that then invoke them.
Returning Functions from Other Functions
def create_multiplier(factor):
"""Returns a function that multiplies by a given factor."""
def multiplier(number):
return number * factor
return multiplier
# Create specialized functions by calling a function that returns a function
times_two = create_multiplier(2)
times_five = create_multiplier(5)
print(times_two(10)) # Output: 20
print(times_five(10)) # Output: 50
Each call to create_multiplier returns a new function with a different factor value captured in its closure.
What Is a Decorator and How Does It Work?
A decorator is a function that:
- Takes another function as an argument
- Wraps it with additional behavior
- Returns the modified function
The wrapper function intercepts calls to the original function, performs setup/cleanup, or modifies behavior.
Simple Decorator Example: Logging
Let's build a decorator that logs when a function starts and finishes:
def logger_decorator(func_to_wrap):
"""A decorator that logs function execution."""
def wrapper():
print(f"About to run: {func_to_wrap.__name__}")
func_to_wrap()
print("Finished running.")
return wrapper
def stand_alone_function():
print("I am executing!")
# Manual decoration
wrapped_function = logger_decorator(stand_alone_function)
wrapped_function()
Output:
About to run: stand_alone_function
I am executing!
Finished running.
Step-by-step explanation:
logger_decoratoracceptsstand_alone_functionas an argument- Inside the decorator, we define a new
wrapperfunction that adds behavior before and after calling the original wrappercaptures the original function in its closure (it "remembers"func_to_wrap)- The decorator returns
wrapper - When we call
wrapped_function(), we're actually callingwrapper(), which calls the original function with added logging
How Do You Apply Decorators Using the @ Syntax?
The manual decoration process works, but Python provides elegant syntactic sugar: the @ symbol. Placing @decorator_name above a function definition automatically applies the decorator.
def logger_decorator(func_to_wrap):
"""A decorator that logs function execution."""
def wrapper():
print(f"About to run: {func_to_wrap.__name__}")
func_to_wrap()
print("Finished running.")
return wrapper
@logger_decorator
def stand_alone_function():
print("I am executing!")
# Just call the function normally
stand_alone_function()
Output:
About to run: stand_alone_function
I am executing!
Finished running.
Equivalence: The @logger_decorator syntax is exactly equivalent to manually writing:
stand_alone_function = logger_decorator(stand_alone_function)
Python applies this transformation automatically at function definition time.
Why Use @ Syntax?
- Readability: The decorator is visible right above the function definition, making intent clear
- Elegance: Less boilerplate than manual decoration
- Standard: This is how decorators are used throughout the Python ecosystem
Real-World Decorator Patterns
Timing Decorator
Track how long a function takes to execute:
import time
def timing_decorator(func):
"""Measures and prints function execution time."""
def wrapper():
start = time.time()
func()
end = time.time()
print(f"Execution time: {end - start:.4f} seconds")
return wrapper
@timing_decorator
def slow_function():
time.sleep(1)
print("Done!")
slow_function()
Output:
Done!
Execution time: 1.0015 seconds
Validation Decorator
Check preconditions before running a function:
def validate_positive(func):
"""Ensures the input is a positive number."""
def wrapper(number):
if number <= 0:
print("Error: Input must be positive!")
return None
return func(number)
return wrapper
@validate_positive
def calculate_square_root(number):
return number ** 0.5
print(calculate_square_root(16)) # Output: 4.0
print(calculate_square_root(-4)) # Output: Error: Input must be positive!
Repetition Decorator
Repeat a function call multiple times:
def repeat(times):
"""Decorator factory: run a function N times."""
def decorator(func):
def wrapper():
for _ in range(times):
func()
return wrapper
return decorator
@repeat(3)
def say_hello():
print("Hello!")
say_hello()
Output:
Hello!
Hello!
Hello!
Key Takeaways
- First-Class Functions: Functions are objects that can be assigned to variables, passed as arguments, and returned from functions.
- Decorator Definition: A decorator is a function that takes a function, wraps it with additional behavior, and returns the modified function.
- Wrapper Function: The inner function inside a decorator (often named
wrapper) performs the actual wrapping and calls the original function. - @ Syntax: Use
@decorator_nameabove a function definition to apply a decorator automatically—it's syntactic sugar for reassigning the function. - Closure: The wrapper function remembers the original function through closure, enabling the decorator to invoke it.
- No Source Modification: Decorators add functionality without changing the original function's code, promoting clean separation of concerns.
Frequently Asked Questions
What is the difference between a decorator and a wrapper function?
A wrapper function is the inner function inside a decorator that performs the wrapping. A decorator is the entire function (including the wrapper) that takes another function as input and returns the wrapper. The decorator is the tool; the wrapper is its implementation.
Can you apply multiple decorators to one function?
Yes. You can stack decorators using the @ syntax:
@decorator1
@decorator2
@decorator3
def my_function():
pass
This applies decorators from bottom to top: decorator1(decorator2(decorator3(my_function))). The order matters!
Why not just modify the original function instead of using a decorator?
Decorators allow you to add behavior without modifying source code, enabling code reuse. For example, a single @timing_decorator can be applied to dozens of functions without changing each one. Modifying source code directly violates the open-closed principle and creates maintenance headaches.
How do decorators work with function arguments?
This simple decorator only works with no-argument functions. To handle functions with arguments, you need *args and **kwargs in the wrapper. This is covered in Decorators Part 2.
What is a decorator factory?
A decorator factory is a function that returns a decorator. It allows you to customize the decorator's behavior:
def repeat(times):
"""Decorator factory: customize how many times to repeat."""
def decorator(func):
def wrapper():
for _ in range(times):
func()
return wrapper
return decorator
@repeat(5) # Call the factory with an argument
def say_hello():
print("Hello!")
The factory receives the customization argument (times); the decorator receives the function.