Table of Contents
-
Generators: Memory-Efficient Iteration
- What Are Generators?
- The
yieldKeyword - Generator Expressions
- Advanced Generator Methods (
send(),throw(),close()) - Practical Use Cases
-
Decorators: Modifying Function Behavior
- What Are Decorators?
- Higher-Order Functions and Closures
- Basic Decorators (with
@Syntax) - Decorators with Arguments
- Chaining Decorators
- Class Decorators
- Preserving Metadata with
functools.wraps
-
Lambdas: Anonymous Inline Functions
- Syntax and Basics
- Use Cases with
map(),filter(), andsorted() - Limitations of Lambdas
- When to Use (and Avoid) Lambdas
Generators: Memory-Efficient Iteration
What Are Generators?
Generators are a special type of iterator that generate values on-the-fly instead of storing them all in memory at once. Unlike lists or tuples, which precompute and store every element, generators produce values lazily, making them ideal for working with large datasets, infinite sequences, or streams of data.
The yield Keyword
A generator is defined using a generator function—a function that contains the yield keyword instead of return. When called, a generator function returns a generator object (an iterator) that can be iterated over. Each call to next() on the generator resumes execution from where it left off, until yield produces the next value.
Example: Simple Generator Function
def count_up_to(n):
current = 1
while current <= n:
yield current # Pauses execution and returns 'current'
current += 1
# Create a generator object
counter = count_up_to(5)
# Iterate over the generator
print(next(counter)) # Output: 1
print(next(counter)) # Output: 2
print(next(counter)) # Output: 3
# ... and so on until StopIteration is raised
When yield is encountered, the function’s state (local variables, instruction pointer) is saved, and the value is returned. The next call to next() resumes execution right after yield.
Generator Expressions
For simple cases, you can create generators using generator expressions—a concise syntax similar to list comprehensions but with parentheses () instead of square brackets [].
Example: Generator Expression vs. List Comprehension
# List comprehension (stores all values in memory)
squares_list = [x**2 for x in range(10)] # [0, 1, 4, ..., 81]
# Generator expression (generates values on-the-fly)
squares_gen = (x**2 for x in range(10))
# Iterate over the generator
for square in squares_gen:
print(square) # Prints 0, 1, 4, ..., 81 (one at a time)
Key Advantage: A list comprehension for range(1_000_000) would consume megabytes of memory, while a generator expression uses almost no memory—values are computed only when needed.
Advanced Generator Methods
Generators support three advanced methods to interact with their execution flow:
send(value): Inject Values into the Generator
Resumes the generator and sends a value to be assigned to the yield expression.
def echo():
while True:
received = yield # Pause and wait for input
print(f"Echo: {received}")
gen = echo()
next(gen) # Prime the generator (run until first yield)
gen.send("Hello") # Output: Echo: Hello
gen.send("Python") # Output: Echo: Python
throw(type, value, traceback): Raise Exceptions in the Generator
Forces the generator to raise an exception at the current yield point.
def safe_divide():
while True:
try:
x = yield
yield 1 / x
except ZeroDivisionError:
yield "Cannot divide by zero!"
gen = safe_divide()
next(gen) # Prime
gen.send(4) # Output: 0.25 (next(gen) returns this)
next(gen) # Pauses at next yield
gen.send(0) # Output: Cannot divide by zero! (next(gen) returns this)
close(): Terminate the Generator
Stops the generator and raises GeneratorExit (cleanup code can run in a finally block).
Practical Use Cases
-
Processing Large Files: Read a 10GB log file line-by-line without loading it all into memory.
def read_large_file(file_path): with open(file_path, "r") as f: for line in f: yield line.strip() for line in read_large_file("huge_log.txt"): process(line) # Process one line at a time -
Infinite Sequences: Generate Fibonacci numbers or real-time sensor data indefinitely.
def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b fib = fibonacci() for _ in range(10): print(next(fib)) # Output: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34
Decorators: Modifying Function Behavior
What Are Decorators?
Decorators are higher-order functions that dynamically modify the behavior of other functions or methods. They wrap a function, enhancing it with additional logic (e.g., logging, timing, authentication) without changing its core code.
Higher-Order Functions and Closures
To understand decorators, we first need two concepts:
- Higher-Order Functions: Functions that take other functions as arguments or return functions.
- Closures: Nested functions that remember variables from their enclosing scope, even if the outer function has finished executing.
Basic Decorators (with @ Syntax)
A basic decorator is a function that takes a function as input and returns a new function (the “wrapped” version).
Step 1: Define the Decorator
def my_decorator(func):
def wrapper():
print("Before function execution")
func() # Call the original function
print("After function execution")
return wrapper # Return the wrapped function
Step 2: Apply the Decorator (Without @ Syntax)
def greet():
print("Hello, World!")
# Wrap greet with my_decorator
decorated_greet = my_decorator(greet)
decorated_greet()
# Output:
# Before function execution
# Hello, World!
# After function execution
Step 3: Use @ Syntax (Syntactic Sugar)
The @ symbol simplifies decorator application:
@my_decorator # Equivalent to: greet = my_decorator(greet)
def greet():
print("Hello, World!")
greet() # Same output as above
Decorators with Arguments
To pass arguments to a decorator, create a decorator factory—a function that returns a decorator.
Example: Timing Function Execution with Custom Messages
import time
def timer(message="Execution time:"):
def decorator(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs) # Pass args/kwargs to the original function
end = time.time()
print(f"{message} {end - start:.4f} seconds")
return result # Return the original function's result
return wrapper
return decorator
@timer(message="Process took:")
def slow_function(seconds):
time.sleep(seconds)
slow_function(2) # Output: Process took: 2.0021 seconds
Chaining Decorators
Multiple decorators can be applied to a single function, executed from bottom to top.
def bold(func):
def wrapper():
return f"<b>{func()}</b>"
return wrapper
def italic(func):
def wrapper():
return f"<i>{func()}</i>"
return wrapper
@bold
@italic # Applied first (inner)
def greet():
return "Hello"
print(greet()) # Output: <b><i>Hello</i></b>
Class Decorators
Decorators can also be defined as classes, using the __call__ method to make instances callable.
class CounterDecorator:
def __init__(self, func):
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
print(f"Function '{self.func.__name__}' called {self.count} times")
return self.func(*args, **kwargs)
@CounterDecorator
def greet():
print("Hi!")
greet() # Output: Function 'greet' called 1 times; Hi!
greet() # Output: Function 'greet' called 2 times; Hi!
Preserving Metadata with functools.wraps
By default, decorated functions lose their original metadata (e.g., __name__, __doc__). Use functools.wraps to copy metadata from the original function to the wrapper.
from functools import wraps
def my_decorator(func):
@wraps(func) # Preserve func's metadata
def wrapper(*args, **kwargs):
"""Wrapper docstring"""
return func(*args, **kwargs)
return wrapper
@my_decorator
def greet():
"""Return a greeting message"""
return "Hello"
print(greet.__name__) # Output: greet (not 'wrapper'!)
print(greet.__doc__) # Output: Return a greeting message (not 'Wrapper docstring'!)
Lambdas: Anonymous Inline Functions
Syntax and Basics
Lambdas are anonymous functions defined with the lambda keyword. They are restricted to a single expression and are often used for short, one-off operations.
Syntax:
lambda arguments: expression
Example: A lambda that adds two numbers:
add = lambda x, y: x + y
print(add(3, 5)) # Output: 8
Use Cases with map(), filter(), and sorted()
Lambdas shine when paired with higher-order functions like map(), filter(), and sorted(), where a short function is needed as an argument.
map(func, iterable): Apply a Function to All Items
numbers = [1, 2, 3, 4]
squared = list(map(lambda x: x**2, numbers))
print(squared) # Output: [1, 4, 9, 16]
filter(func, iterable): Select Items That Meet a Condition
even_numbers = list(filter(lambda x: x % 2 == 0, numbers))
print(even_numbers) # Output: [2, 4]
sorted(iterable, key=func): Custom Sorting
people = [("Alice", 30), ("Bob", 25), ("Charlie", 35)]
# Sort by age (second element of the tuple)
sorted_by_age = sorted(people, key=lambda x: x[1])
print(sorted_by_age) # Output: [('Bob', 25), ('Alice', 30), ('Charlie', 35)]
Limitations of Lambdas
- Single Expression: Can only contain one expression (no loops, conditionals with
else, or multiple statements). - No Docstrings: Harder to document than named functions.
- Readability: Complex logic in lambdas becomes unreadable (use a named function instead).
When to Use (and Avoid) Lambdas
- Use Lambdas for short, simple operations (e.g.,
keyfunctions insorted()). - Avoid Lambdas for:
- Complex logic (use a named function with
def). - Reusable code (name the function for clarity).
- Debugging (stack traces show
<lambda>instead of a function name).
- Complex logic (use a named function with
Conclusion
Generators, decorators, and lambdas are powerful tools in the Python developer’s toolkit:
- Generators enable memory-efficient iteration for large or infinite datasets.
- Decorators dynamically enhance function behavior (logging, timing, etc.) without code duplication.
- Lambdas provide concise, inline functions for simple, one-off operations.
By mastering these features, you’ll write more Pythonic, efficient, and maintainable code. Experiment with the examples above, and explore how they can simplify your own projects!