Decorators and Generators are advanced Python features that offer flexible, concise, and powerful ways to manage functionality. Both provide significant utility in writing cleaner, more efficient, and more modular code.
1. Python Decorators
A decorator is a function that takes another function (or class) and extends or alters its behavior without changing its code. Decorators are commonly used for tasks like logging, enforcing access control, memoization, timing functions, and more.
Decorators are applied to functions using the @decorator_name syntax and are a form of higher-order function since they accept functions as arguments and may return functions.
Basic Structure of a Decorator
A simple decorator wraps a function, modifies or extends its behavior, and returns the modified function.
def my_decorator(func):
def wrapper():
print("Something before the function runs.")
func()
print("Something after the function runs.")
return wrapper
You can apply the decorator to a function with the @ syntax:
@my_decorator
def say_hello():
print("Hello!")
say_hello()
# Output:
# Something before the function runs.
# Hello!
# Something after the function runs.
Explanation
- my_decorator takes a function func as an argument.
- wrapper adds behavior before and after calling func.
- @my_decorator syntax is equivalent to say_hello = my_decorator(say_hello).
Common Uses of Decorators
1. Logging
Decorators are often used to log function calls or return values for debugging or monitoring.
def log_decorator(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with {args} and {kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_decorator
def add(a, b):
return a + b
add(3, 5)
# Output:
# Calling add with (3, 5) and {}
# add returned 8
2. Timing
Timing decorators are useful to measure the execution time of functions.
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start} seconds")
return result
return wrapper
@timer
def slow_function():
time.sleep(2)
return "Done"
slow_function()
# Output: slow_function took ~2.0 seconds
3. Access Control
Decorators can also be used for access control, such as restricting function access based on user roles.
def require_admin(func):
def wrapper(user_role):
if user_role != "admin":
print("Access denied.")
return
return func(user_role)
return wrapper
@require_admin
def view_admin_panel(user_role):
print("Welcome to the admin panel.")
view_admin_panel("guest") # Output: Access denied.
view_admin_panel("admin") # Output: Welcome to the admin panel.
Decorators with Arguments
To create decorators that accept arguments, define a decorator function that returns another decorator function.
def repeat(num_times):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(num_times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# Output:
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!
2. Python Generators
A generator is a function that produces a sequence of values lazily, meaning values are generated only as needed. Generators use the yield keyword instead of return, which allows them to return a value and then pause the function’s state until the next value is requested.
Generators are useful for creating sequences or handling large data streams efficiently since they don’t store all values in memory at once.
Creating a Generator
To create a generator function, use yield to produce a value and suspend the function until the next value is requested.
def count_up_to(max):
count = 1
while count <= max:
yield count
count += 1
# Using the generator
for number in count_up_to(5):
print(number)
# Output:
# 1
# 2
# 3
# 4
# 5
Explanation
- The function count_up_to yields values from 1 up to max.
- Each time yield is called, the function’s state is saved, and execution resumes from the next line when the next value is requested.
Generator Expressions
Generator expressions are similar to list comprehensions but return generators instead of lists. They are created using parentheses instead of square brackets.
squares = (x*x for x in range(1,6))
for square in squares:
print(square)
# Output:
# 1
# 4
# 3
# 16
# 25
Use Cases for Generators
1. Infinite Sequences
Generators are ideal for creating infinite sequences, such as Fibonacci numbers or prime numbers, as they generate values only when requested.
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
fib = fibonacci()
for _ in range(5):
print(next(fib))
# Output:
# 0
# 1
# 1
# 2
# 3
2. Processing Large Data Streams
Generators can process large files or data streams without loading the entire file into memory.
def read_large_file(file_path):
with open(file_path, "r") as file:
for line in file:
yield line
# Process each line one by one
for line in read_large_file("large_file.txt"):
print(line.strip())
3. Pipelining Operations
Generators can be chained to create data pipelines, where each generator processes data and passes it to the next.
def integers():
for i in range(1, 11):
yield i
def squared(numbers):
for n in numbers:
yield n * n
def negated(numbers):
for n in numbers:
yield -n
# Pipeline: generate integers, square them, then negate
pipeline = negated(squared(integers()))
for value in pipeline:
print(value)
# Output:
# -1
# -4
# -9
# -16
# -25
# -36
# -49
# -64
# -81
# -100