Have you ever looked at code using mysterious @ symbols and wondered what was going on? Do words like wrappers, factory functions, and closures give you a headache? Well, my friend, let me tell you โ you‘re not alone!
Decorators can sound so fancy and intimidating at first. But the truth is, they are incredibly useful in practice and easier to grasp than you think with the right examples and a friendly teacher to guide you :). In this deep dive tutorial, we‘ll gently unravel the mysteries of Python decorators together step-by-step.
I‘ll share lots of annotated code snippets so you build solid intuition on how decorators work under the hood. We‘ll cover common applications like timing functions, logging, caching โ you‘ll be amazed at the capabilities unlocked with these constructs! We‘ll even peek at some gotchas to avoid.
Sound exciting? Don your thinking ๐งข โ and let‘s get decorating!
Gift Wrapping Functions with Decorators
Let‘s think about gift wrapping presents during the holidays. Wrapping paper and ribbons nicely decorate gifts, almost magically enhancing them without changing what‘s inside.
Python decorators provide similar magic for functions!
These wrapper constructs allow transparently enhancing functions by nesting them inside other callable entities. For example:
import time
# Our gift wrap equivalent
def timer(func):
# Wrapper function
def wrapper():
start = time.time()
# Wrap the given function
func()
end = time.time()
print(f"{func.__name__} took {end-start} secs")
return wrapper
@timer
def long_task():
print("Running long task...")
for x in range(1000):
y = x ** 20
long_task()
# Prints "long_task took 0.22 secs" after task runs
Here timer
acts as a decorator that measures execution times for any function passed into it. We decorate long_task
by prefixing it with @timer
. Now long_task
magically gains timing powers without having to change its implementation!
This enables transparently extending functions in a reusable way. ๐ Pretty nifty right?
But before seeing more decorator magic tricks, let‘s formally decode what they are under the hoodโฆ
Demystifying the Inner Workings
The @decorator
syntax is just syntactic sugar for wrapping functions behind the scenes. Here‘s what really happens under the hood:
Without decorators:
def long_task():
print("Running long task...")
# Code for the actual task
long_task = timer(long_task)
With decorators:
@timer
def long_task():
print("Running long task...")
# Code for actual task
So @timer
automatically applies timer()
as a wrapper over long_task
to augment its behavior.
In general, here is the pattern behind decorators:
# Decorator function
def decorator(func):
# Wrapper extends func
def wrapper(*args, **kwargs):
# Extensions like pre/post processing
func(*args, **kwargs)
return wrapper
# Apply decorator by pre-pending @
@decorator
def func():
pass
When Python sees @decorator
, it basically calls:
func = decorator(func)
So func gets replaced by the decorated function returned by the decorator!
With that demystified, let‘s unleash some real world Python decorator magic with practical examples. Onwards my friend! ๐
Real-World Use Cases
While you can craft custom decorators like shown above, Python ships with helpful ones in the standard library too. Let‘s dive into examples.
Timing Functions
Let‘s revive our trusty @timer
decorator from earlier for benchmarking code:
from timeit import default_timer
def timer(func):
def wrapper(*args, **kwargs):
start = default_timer()
result = func(*args, **kwargs)
end = default_timer()
print(f"{func.__name__} took {end - start}")
return result
return wrapper
@timer
def random_sleep(sleeptime):
import random
import time
time.sleep(random.random()*sleeptime)
random_sleep(10)
# Prints "random_sleep took 7.23 secs"
The builtin default_timer()
quantifies performance overhead.
You can also use cProfiler for low-level insights:
import cProfile
cProfile.run(‘random_sleep(10)‘)
# Prints detailed stats like function calls, time per call
# and lines consuming highest time
Logging Function Arguments + Results
Automatically log params to audit flows or debug issues:
from functools import wraps
import logging
# Set logging level
logging.basicConfig(level=logging.INFO)
def log_func(func):
@wraps(func)
def wrapper(*args, **kwargs):
logging.info(
f"Running {func.__name__} with {args} and {kwargs}")
result = func(*args, **kwargs)
logging.info(f"{func.__name__} returned {result}")
return result
return wrapper
@log_func
def add(x, y):
return x + y
print(add(10, 20))
# Logs the arguments, function name and return value
Note @wraps
preserves metadata like docstrings when decorating.
Type Checking Parameters
Confirm param datatypes at runtime:
from inspect import signature
from functools import wraps
def type_check(func):
sig = signature(func)
@wraps(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
for name, val in bound.arguments.items():
if name == ‘self‘:
continue
ann = sig.parameters[name].annotation
if ann is inspect._empty:
continue
if not isinstance(val, ann):
raise TypeError(
f"{name} should be {ann}, not {type(val)}")
return func(*args, **kwargs)
return wrapper
@type_check
def process(inputs: list):
return inputs
process([10, 20]) # Runs OK
process("foo") # TypeError!
Memoization & Caching
Remember prior expensive calculations via caching:
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n-2) + fibonacci(n-1)
print([fibonacci(n) for n in range(10)])
# Cached values reused instead of recomputing
Policies like LRU manage cache sizes.
Rate Limiting
Throttling high frequency calls:
from functools import wraps
import time
def rate_limited(max_per_second):
min_interval = 1 / float(max_per_second)
def decorator(func):
last_time = [0.0]
@wraps(func)
def wrapper(*args, **kwargs):
elapsed = time.perf_counter() - last_time[0]
left_to_wait = min_interval - elapsed
if left_to_wait > 0:
time.sleep(left_to_wait)
last_time[0] = time.perf_counter()
return func(*args, **kwargs)
return wrapper
return decorator
@rate_limited(5) # 5 calls per second
def print_num(num):
print(num)
This builds in pacing.
Authorization
Enable role based access control:
import functools
USER_ROLES = [‘guest‘, ‘admin‘]
def role_required(role):
def decorator(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
if role not in USER_ROLES:
raise ValueError(f"Invalid role {role}")
if role != ‘admin‘:
raise UnauthorizedError(f"Role {role} cannot access this")
return f(*args, **kwargs)
return wrapper
return decorator
@role_required(role=‘admin‘)
def admin_dashboard():
print("Welcome to admin dashboard!")
admin_dashboard() # OK!
@role_required(role=‘guest‘)
def home_page():
return "<p>Welcome home!</p>"
home_page() # UnauthorizedError!
This separates access policies from core logic.
There are endless opportunities for usefulness like input validation, debugging capabilities, app connectivity etc!
Advanced Tactics
Let‘s level up decorator mastery with some advanced tactics:
Passing Arguments to Decorators
Customize wrappers without editing their code:
from functools import wraps
def ensure_permission(perm):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
if check_permissions(perm):
return func(*args, **kwargs)
return wrapper
return decorator
@ensure_permission("admin")
def delete_user():
print(‘Deleting user...‘)
Now you can reuse ensure_permission
flexibly without hardcoding roles!
Nesting Decorators
Apply multiple custom wrappers:
def header(text):
def wrap(func):
def wrapped():
print(f"\n====== {text} ======")
return func()
return wrapped
return wrap
def timer(func):
# timing wrapper code
@timer
@header("Start Process")
def process_data():
print(‘Processing...‘, flush=True)
process_data()
# Prints heading then timing data
This enables nice capability composability! ๐
Classes Too Please!
Let‘s decorate methods in classes:
def debug(method):
def wrapper(*args, **kwargs):
print(f"Calling {method.__name__}")
return method(*args, **kwargs)
return wrapper
class Calculator:
@debug
def add(self, x, y):
return x + y
calc = Calculator()
calc.add(10, 20) # Prints method name
So decorators work on free functions and class instance methods.
But use caution around __init__
methods during initialization.
![Warning sign](https://pluralsight.imgix.net/guides/e49b2dfaโ emphasizes the importance of something-99a9-4562-aee8-ffd7a6f5b8f4_1638103329820.html) Potential Pitfalls
With great power comes great responsibility! ๐ช Before you go overboard on decorators:
โ Performance Overhead: Too many wrappers slow functions down. Profile code to spot impacts!
โ๏ธ Leaky Wrappings: Decorators can unintentionally mutate mutable arguments passed to functions.
๐ก๏ธ Hidden Metadata: Applying wrappers hides useful attributes like docstrings, names etc of wrapped functions. Rely on functools.wraps
where needed.
These issues can sneak up on you! So tread carefully and debug issues with care.
When to Reach for That Decorator?
Here is a handy decision tree on when to use Python decorators vs alternatives like inheritance and composition:
Do you need...
โ
โโโโโ State and polymorphism?
โ โ
โ โ> Use inheritance
โ
โโโโโ Function composition?
โ โ
โ โ> Use higher order functions
โ
โโโโโ To augment functions transparently?
โ
โ> Use Python decorators!!!
Decorators shine best when wanting to transparently tack on cross-cutting capabilities like logging, timing, validation etc. across disparate functions.
Hope you now have sweet intuition on sprucing up functions with Python decorators!
Let‘s raise a toast with cake ๐ฐ to your future decorator endeavours!