Python Decorators
Adding Functionality to Functions: Understanding Decorators
In Python, you might want to add extra features to a function - like logging, timing, or checking permissions - without changing the function's code. Decorators make this possible. They let you "wrap" a function with another function to add new behavior in a clean, reusable way.
This article will show you what decorators are, how to use them, and how to write your own. Before we dive into decorators, let's cover some foundational concepts about functions in Python.
Functions Inside Functions: Local Functions
Python allows you to define functions inside other functions. These are called local functions or nested functions. They can access variables from the outer function's scope.
Example:
def outer_function():
message = "Hello from the outer function!"
def inner_function(): # This is a local function
print(message) # inner_function can access message
inner_function() # Call the local function from inside the outer function
outer_function() # Output: Hello from the outer function!
# inner_function() # ERROR: NameError - inner_function is not defined outside outer_function
inner_function
exists only whileouter_function
is running.- Local functions are useful for tasks that are only needed within another function.
Functions as First-Class Objects
In Python, functions are first-class objects. This means you can treat functions like any other value (like a number or a string). You can:
- Assign a function to a variable.
- Pass a function as an argument to another function.
- Return a function from another function.
Example: Passing a Function as an Argument
def execute_function(func):
# execute_function takes a function 'func' as an argument
func() # Call the function that was passed in
def say_hello():
print("Hello!")
execute_function(say_hello) # Pass say_hello function as an argument
# Output: Hello!
Example: Returning a Function from Another Function
def create_greeter(greeting):
def greeter(name): # Define a local function
print(f"{greeting}, {name}!")
return greeter # Return the local function
say_hi = create_greeter("Hi") # say_hi now holds the greeter function
say_hi("Alice") # Output: Hi, Alice!
say_yo = create_greeter("Yo") # say_yo holds a different greeter
say_yo("Bob") # Output: Yo, Bob!
These abilities (local functions and treating functions as objects) are key to how decorators work.
Type Hinting Functions: Callable
When you use type hints, you can show that a variable or parameter is expected to be a function using the Callable
type from the typing
module.
Callable
is used to specify the signature of the function (the types of its arguments and its return type).
Syntax: Callable[[ArgType1, ArgType2, ...], ReturnType]
[ArgType1, ArgType2, ...]
: A list of the expected types of the function's arguments.ReturnType
: The expected return type of the function.- Use
[]
if the function takes no arguments. - Use
Callable[..., ReturnType]
if the function can take any arguments.
Example:
from typing import Callable
# Type hint for a function that takes a string and returns an integer
def process_text(func: Callable[[str], int], text: str) -> int:
return func(text)
def string_length(s: str) -> int:
return len(s)
# process_text expects a function that takes str and returns int
length = process_text(string_length, "hello")
print(length) # Output: 5
# Type hint for a function that takes no arguments and returns None
def run_task(task: Callable[[], None]) -> None:
task()
def say_done() -> None:
print("Done!")
run_task(say_done) # Output: Done!
Understanding Callable
helps make your code clearer when working with functions that take or return other functions.
What Is a Decorator?
A decorator is a function that takes another function as input and returns a new function that usually adds something before or after the original function runs. Decorators are a powerful way to extend or modify the behavior of functions (and sometimes classes) without changing their code.
Decorators are often used for:
- Logging
- Timing how long a function takes
- Checking user permissions
- Repeating code before/after many functions
Using a Decorator: The @ Syntax
Python makes it easy to apply a decorator using the @decorator_name
syntax, which is called a "pie" syntax because it looks like a pie.
Example: Using a Built-in Decorator
def shout(func: Callable[[], None]) -> Callable[[], None]:
def wrapper() -> None:
print("Before the function runs!")
func()
print("After the function runs!")
return wrapper
@shout
def greet() -> None:
print("Hello!")
greet()
# Output:
# Before the function runs!
# Hello!
# After the function runs!
@shout
applies theshout
decorator togreet
.- When you call
greet()
, you actually call thewrapper
function insideshout
.
How Decorators Work (Step by Step)
- You define a decorator function that takes another function as an argument.
- Inside the decorator, you define a new function (the "wrapper") that does something extra and then calls the original function.
- The decorator returns the wrapper function.
- When you use
@decorator
, Python replaces the original function with the wrapper.
Writing Your Own Decorator
Example: A Simple Logging Decorator
from typing import Callable, Any
def log_call(func: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args: Any, **kwargs: Any) -> Any:
print(f"Calling {func.__name__}()...")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}()!")
return result
return wrapper
@log_call
def say_hi(name: str) -> None:
print(f"Hi, {name}!")
say_hi("Alice")
# Output:
# Calling say_hi()...
# Hi, Alice!
# Finished say_hi()!
@log_call
def add(a: int, b: int) -> int:
return a + b
sum_result = add(5, 3)
print(sum_result)
# Output:
# Calling add()...
# Finished add()!
# 8
Decorators with Arguments
If your function takes arguments, the wrapper should accept them too, using *args
and **kwargs
. (This is already covered and improved in the previous section's example).
def repeat(func: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args: Any, **kwargs: Any) -> Any:
func(*args, **kwargs)
func(*args, **kwargs)
return wrapper
@repeat
def greet(name: str) -> None:
print(f"Hello, {name}!")
greet("Alice")
# Output:
# Hello, Alice!
# Hello, Alice!
Stacking Multiple Decorators
You can apply more than one decorator to a function. They are applied from the closest to the function outward.
@decorator_one
@decorator_two
def my_func():
pass
# This is the same as: my_func = decorator_one(decorator_two(my_func))
Advanced Type Hinting with ParamSpec
When you use type hints with decorators, especially for functions that take various arguments, the simple Callable[..., Any]
can make you lose the specific type information of the original function's arguments. This is where ParamSpec
comes in.
ParamSpec
is used to forward the parameter types of one callable to another callable. This helps type checkers understand that the wrapper function in your decorator has the exact same arguments as the original function it wraps.
Example:
from typing import Callable, Any, ParamSpec
P = ParamSpec('P') # Define a ParamSpec variable
T = Any # Define a type variable for return type (or use TypeVar)
# Now, we can hint the decorator more accurately
def simple_decorator(func: Callable[P, T]) -> Callable[P, T]:
# The wrapper now explicitly takes the same parameters (P) and returns the same type (T)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
print("Something before!")
result = func(*args, **kwargs)
print("Something after!")
return result
return wrapper
@simple_decorator
def add_numbers(a: int, b: int) -> int:
return a + b
@simple_decorator
def say_word(word: str) -> None:
print(word)
# Type checkers now know the signatures are preserved:
sum_result = add_numbers(10, 20) # Type checker knows add_numbers takes two ints and returns int
print(sum_result) # Output: Something before!
# 30
# Something after!
say_word("hello") # Type checker knows say_word takes a string and returns None
# Output: Something before!
# hello
# Something after!
P = ParamSpec('P')
creates a placeholder for parameter specification.Callable[P, T]
indicates a function that takes parameters specified byP
and returns typeT
.- The wrapper function's signature
(*args: P.args, **kwargs: P.kwargs) -> T
ensures the type checker knows it matches the decorated function's signature.
Using ParamSpec
provides more precise type hinting for complex decorators, improving static analysis and code clarity.
Why Use Decorators?
- Code reuse: Add the same feature to many functions without repeating code.
- Separation of concerns: Keep extra logic (like logging) out of your main function code.
- Cleaner code: Make your code easier to read and maintain.
Common Beginner Mistakes
- Forgetting to return the wrapper function from the decorator.
- Not using
*args
and**kwargs
in the wrapper, so the decorator only works for functions with no arguments. - Losing the original function's name and docstring (can be fixed with
functools.wraps
).
Example of a mistake:
from typing import Any
def bad_decorator(func: Callable[..., Any]) -> Callable[..., Any]:
def wrapper(*args: Any, **kwargs: Any) -> Any:
print("Something!")
# Forgot to call func() and forgot to return wrapper
return wrapper # Added return to make it a valid (though simple) decorator
@bad_decorator
def test_func(x: int) -> None:
print(f"Original function called with: {x}")
test_func(10)
# Output:
# Something!
# (Original function is not called)
Summary
- Decorators are functions that add extra behavior to other functions.
- Use
@decorator_name
to apply a decorator. - Decorators help you reuse code and keep your functions clean.
- Always use
*args
and**kwargs
in your wrapper if the function takes arguments.