Generators
In this chapter, we'll explore generators - a powerful way to work with sequences lazily. We'll cover:
- What are generators?
- The
yieldkeyword - Generator expressions
- Lazy evaluation benefits
- Iterators vs generators
- Testing generators
What is a generator?
A generator is a function that returns an iterator. Instead of returning all values at once, it yields them one at a time:
def count_up_to(n):
i = 1
while i <= n:
yield i
i += 1
# Usage
for num in count_up_to(5):
print(num)
# Prints: 1, 2, 3, 4, 5
Why use generators?
Memory efficiency
A list stores all elements in memory:
# This creates a list with 1 million integers in memory
numbers = [x for x in range(1_000_000)]
A generator produces values on-demand:
# This creates only one integer at a time
numbers = (x for x in range(1_000_000))
Infinite sequences
Generators can represent infinite sequences:
def infinite_counter():
n = 0
while True:
yield n
n += 1
# Take first 5 values
counter = infinite_counter()
for _ in range(5):
print(next(counter))
The yield keyword
yield is like return, but it pauses the function instead of ending it:
def my_generator():
print("Before first yield")
yield 1
print("Before second yield")
yield 2
print("Before third yield")
yield 3
print("After all yields")
gen = my_generator()
print(next(gen)) # Prints "Before first yield", returns 1
print(next(gen)) # Prints "Before second yield", returns 2
Testing generators
Write the test first
from generators import fibonacci_gen
def test_fibonacci_generator():
gen = fibonacci_gen()
first_10 = [next(gen) for _ in range(10)]
assert first_10 == [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
def test_fibonacci_is_generator():
gen = fibonacci_gen()
# Generators are iterators
assert hasattr(gen, '__iter__')
assert hasattr(gen, '__next__')
Implementation
def fibonacci_gen():
"""Generate Fibonacci numbers infinitely."""
a, b = 0, 1
while True:
yield a
a, b = b, a + b
Generator expressions
Like list comprehensions, but lazy:
# List comprehension - creates list immediately
squares_list = [x ** 2 for x in range(1000)]
# Generator expression - creates generator
squares_gen = (x ** 2 for x in range(1000))
Generator expressions use parentheses instead of brackets.
Testing generator expressions
def test_generator_expression():
gen = (x * 2 for x in [1, 2, 3])
assert list(gen) == [2, 4, 6]
# Generator is exhausted after iteration
assert list(gen) == []
Practical examples
Reading large files
def read_lines(filename):
"""Read a file line by line without loading entire file."""
with open(filename) as f:
for line in f:
yield line.strip()
# Test
def test_read_lines(tmp_path):
file_path = tmp_path / "test.txt"
file_path.write_text("line1\nline2\nline3\n")
lines = list(read_lines(file_path))
assert lines == ["line1", "line2", "line3"]
Processing data in chunks
def chunked(iterable, size):
"""Yield chunks of the iterable."""
chunk = []
for item in iterable:
chunk.append(item)
if len(chunk) == size:
yield chunk
chunk = []
if chunk:
yield chunk
# Test
def test_chunked():
data = [1, 2, 3, 4, 5, 6, 7]
chunks = list(chunked(data, 3))
assert chunks == [[1, 2, 3], [4, 5, 6], [7]]
Filtering data
def filter_evens(numbers):
"""Yield only even numbers."""
for n in numbers:
if n % 2 == 0:
yield n
# Test
def test_filter_evens():
evens = list(filter_evens([1, 2, 3, 4, 5, 6]))
assert evens == [2, 4, 6]
yield from
Use yield from to delegate to another generator:
def flatten(nested_list):
"""Flatten a nested list."""
for item in nested_list:
if isinstance(item, list):
yield from flatten(item) # Recursively yield from nested
else:
yield item
# Test
def test_flatten():
nested = [1, [2, 3], [4, [5, 6]], 7]
flat = list(flatten(nested))
assert flat == [1, 2, 3, 4, 5, 6, 7]
Generators vs iterators
Iterator (class-based)
class CountUp:
def __init__(self, n):
self.n = n
self.current = 0
def __iter__(self):
return self
def __next__(self):
if self.current >= self.n:
raise StopIteration
self.current += 1
return self.current
Generator (function-based)
def count_up(n):
for i in range(1, n + 1):
yield i
Generators are usually simpler and more readable.
Generator state
Generators maintain state between yields:
def running_total():
"""Yield running total of received values."""
total = 0
while True:
value = yield total
if value is not None:
total += value
# Usage
gen = running_total()
next(gen) # Prime the generator
print(gen.send(10)) # 10
print(gen.send(20)) # 30
print(gen.send(5)) # 35
Testing generator with send
def test_running_total():
gen = running_total()
next(gen) # Prime
assert gen.send(10) == 10
assert gen.send(20) == 30
assert gen.send(5) == 35
Generator pipelines
Chain generators for data processing:
def read_numbers(filename):
with open(filename) as f:
for line in f:
yield int(line.strip())
def double(numbers):
for n in numbers:
yield n * 2
def filter_large(numbers, threshold):
for n in numbers:
if n > threshold:
yield n
# Pipeline
numbers = read_numbers("data.txt")
doubled = double(numbers)
large = filter_large(doubled, 100)
for n in large:
print(n)
Testing pipelines
def test_generator_pipeline():
source = iter([1, 2, 3, 4, 5])
doubled = double(source)
large = filter_large(doubled, 5)
result = list(large)
assert result == [6, 8, 10]
Common generator utilities
Python's itertools module provides generator utilities:
from itertools import islice, chain, cycle, count
# Take first n items
first_5 = list(islice(infinite_counter(), 5))
# Chain iterables
combined = chain([1, 2], [3, 4])
# Cycle through items
colors = cycle(["red", "green", "blue"])
# Count infinitely
numbers = count(start=10, step=5) # 10, 15, 20, ...
Testing with itertools
from itertools import islice
def test_infinite_generator_with_islice():
def count_forever():
n = 0
while True:
yield n
n += 1
first_5 = list(islice(count_forever(), 5))
assert first_5 == [0, 1, 2, 3, 4]
A complete example: CSV processor
# test_csv_processor.py
import pytest
from csv_processor import process_csv
def test_process_csv(tmp_path):
csv_file = tmp_path / "data.csv"
csv_file.write_text("name,age\nAlice,30\nBob,25\nCharlie,35\n")
rows = list(process_csv(csv_file))
assert rows == [
{"name": "Alice", "age": "30"},
{"name": "Bob", "age": "25"},
{"name": "Charlie", "age": "35"},
]
def test_process_csv_is_lazy(tmp_path):
csv_file = tmp_path / "data.csv"
csv_file.write_text("name,age\nAlice,30\nBob,25\n")
gen = process_csv(csv_file)
# Generator hasn't read file yet
first_row = next(gen)
assert first_row == {"name": "Alice", "age": "30"}
Implementation:
# csv_processor.py
def process_csv(filename):
"""Process CSV file row by row."""
with open(filename) as f:
headers = None
for line in f:
values = line.strip().split(",")
if headers is None:
headers = values
else:
yield dict(zip(headers, values))
Wrapping up
We've covered:
- Generators - Functions with
yieldthat return iterators - yield keyword - Pause and produce a value
- Generator expressions - Lazy comprehensions with
() - yield from - Delegate to another generator
- Generator pipelines - Chain generators for processing
- itertools - Standard library generator utilities
Key takeaways
- Use generators for large or infinite sequences
- Generators are memory-efficient - one value at a time
- Generators are iterators - use with
for,next(),list() - Use
yield fromfor delegation - Test generators by consuming with
list()ornext()
When to use generators
- Processing large files line by line
- Working with infinite sequences
- Chaining data transformations
- Any case where you don't need all values at once
Generators make your code more memory-efficient and often clearer by expressing computation as a sequence of transformations.