Hello! Welcome to the sixth and final lesson of our module on the foundations of iteration in Python.
Introduction
In our previous lesson, we dissected the execution model of generator functions, focusing on how they suspend their state at a yield statement and resume it upon the next next() call. This "pause and resume" mechanism is what makes generators so memory-efficient.
Today, we will leverage this capability to its logical conclusion. The learning outcome for this lesson is to write a generator for an infinite sequence and demonstrate consuming a finite portion of it. We'll explore how generators can elegantly represent concepts like mathematical sequences that have no end, without requiring infinite memory. This is a powerful application of the lazy evaluation principle we've been discussing.
This lesson marks the culmination of our study of synchronous generators. The concepts of state suspension and on-demand computation are the essential groundwork for understanding the cooperative multitasking at the heart of asyncio, which we will begin exploring in the next module.
Representing the Infinite
Because generators compute values only when requested, the sequence they represent doesn't need to be finite. We can define a generator that is capable of producing values forever. This is particularly useful for representing mathematical sequences, data streams, or any process that doesn't have a natural endpoint.
The core structure for an infinite generator is typically a while True loop that contains a yield statement.
Let's begin by looking at a very simple implementation.
Infinite Sequences in Python - Impressive Fibonacci
The article 'Infinite Sequences in Python - Impressive Fibonacci' provides a straightforward example of an infinite generator for even numbers. This will help us establish the basic pattern.
Read the first section, 'Creating our infinite sequence'. Observe the simple structure: a while True loop, a state variable (value), and the yield statement that produces the next item in the sequence.
The key insight here is that the while True loop isn't a problem. The generator pauses at yield value on every iteration, handing control back to the caller. The loop only advances when the caller explicitly requests the next value.
However, this power comes with a crucial caveat: you must never try to consume the entire generator at once, for instance by calling list(infinite_generator). This would create a true infinite loop as your program fruitlessly attempts to build a list of infinite size, eventually exhausting all available memory.
Example: The Fibonacci Sequence
Let's move to a more classic mathematical example: the Fibonacci sequence. It's a perfect candidate for an infinite generator.
The following video provides a clear walkthrough of creating an infinite Fibonacci generator.
5 Useful Generator Functions In Python
This clip from '5 Useful Generator Functions In Python' by Indently demonstrates how to implement the Fibonacci sequence as a generator.
Watch the segment from 00:12 to 04:21. Pay close attention to the logic within the while True loop, specifically how the two state variables (a and b) are updated after each yield to produce the next number in the sequence.
The code for the Fibonacci generator is concise and elegant:
def fibonacci():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
Each time next() is called on a fibonacci() generator object, it yields the current value of a, updates a and b, and then pauses, perfectly preserving its state (a and b) for the next call.
Other mathematical sequences, such as the prime numbers or the intriguing Collatz sequence, can also be modeled this way. All you need is an algorithm for generating the next term from the current state.
Consuming Finite Portions of an Infinite Sequence
Since we cannot consume the entire sequence, we need a controlled way to retrieve a finite number of items. There are two primary methods for this.
1. Manual Iteration with next()
The most direct approach is to use a for loop with a fixed range to call next() a specific number of times.
fib_gen = fibonacci()
# Get the first 10 Fibonacci numbers
first_10 = [next(fib_gen) for _ in range(10)]
print(first_10)
# Output: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
This pattern is simple and effective for getting the first N items.
2. Using itertools.islice
A more powerful and Pythonic approach is to use the islice function from the itertools module. As its name suggests, islice allows you to take a slice of any iterable—including an infinite generator—without having to load the entire iterable into memory. It returns an iterator that yields only the selected items.
The following resource provides an excellent guide to using islice.
Infinite Sequences in Python - Impressive Fibonacci
Let's return to the 'Infinite Sequences in Python' article to see how itertools.islice provides an elegant solution for consuming parts of an infinite sequence.
Read the sections 'Consuming the sequence in a controlled way' and 'The famous Fibonacci sequence'. Focus on the different ways islice is used: islice(iterable, stop): To get the first stop items. islice(iterable, start, stop): To get a slice from a starting index to an ending index. islice(iterable, start, stop, step): To get a slice with a specific step.
As you read, islice is a lazy operation. It doesn't advance the underlying generator until you start consuming the islice object itself. This makes it incredibly efficient.
Let's see it in action with our fibonacci generator:
import itertools
# Get the first 10 numbers (indices 0-9)
first_10_slice = itertools.islice(fibonacci(), 10)
print(f"First 10: {list(first_10_slice)}")
# Get the numbers from index 5 to 10 (exclusive)
middle_slice = itertools.islice(fibonacci(), 5, 10)
print(f"Indices 5-9: {list(middle_slice)}")
# Get every second number from index 5 to 15
stepped_slice = itertools.islice(fibonacci(), 5, 15, 2)
print(f"Indices 5-14 (step 2): {list(stepped_slice)}")
Output:
First 10: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Indices 5-9: [5, 8, 13, 21, 34]
Indices 5-14 (step 2): [5, 13, 55, 144, 377]
islice provides a declarative and highly readable way to handle a common task when working with generators, especially infinite ones.
Practical Exercise
Now it's your turn to put this into practice.
- Write a generator function
powers_of(base)that takes an integerbaseand yields the infinite sequence of its powers:base**0,base**1,base**2, and so on. - Use
itertools.isliceto create a list containing the 5th through 10th powers of 3 (i.e.,3**5up to, and including,3**10).
Take some time to implement this. I'll provide a solution below for you to compare against.
Solution
import itertools
def powers_of(base: int):
"""Generates the infinite sequence of powers for a given base."""
power = 0
while True:
yield base ** power
power += 1
# Create the generator
powers_of_3 = powers_of(3)
# The 5th power is at index 5, and the 10th is at index 10.
# To include index 10, the stop argument for islice must be 11.
power_slice = itertools.islice(powers_of_3, 5, 11)
# Consume the slice iterator to create the list
result = list(power_slice)
print(f"The powers of 3 from the 5th to the 10th are: {result}")
# Expected output: [243, 729, 2187, 6561, 19683, 59049]
# Verification
# 3**5 = 243
# 3**10 = 59049
Conclusion
This lesson concludes our module on the fundamentals of Python's iteration protocols. We've built from the basic concepts of iterables and iterators to the powerful, stateful, and lazy computation model of generators.
Key Takeaways:
- Generators can represent infinite sequences by using a non-terminating loop (e.g.,
while True) combined withyield. - This is possible because of lazy evaluation: values are only computed when requested, so the program does not get stuck in an infinite loop.
- Attempting to realize an entire infinite sequence (e.g., with
list()) will exhaust system memory and hang the program. - Finite portions of an infinite generator can be consumed using a counted loop with
next(), or more idiomatically withitertools.islice.
Next Up:
We have now mastered the synchronous "pause and resume" cycle. In the next module, we will see how this very same idea is the cornerstone of asynchronous programming in Python. We will begin by defining the core concepts of concurrency and parallelism and identifying the types of problems—specifically I/O-bound tasks—where asyncio provides a significant performance advantage.
Can't find a good explanation? Sign up and we'll make it for you
Sign up