Iterables and Iterators
An iterable can produce an iterator; an iterator produces one value at a time and remembers where it is. Every for loop in Python is built on those two ideas.
- Two different things
- What a for loop really does
- An iterator is used up
- next with a default
- Writing an iterator class
- Separating the iterable from the iterator
- A practical iterator
- iter() with two arguments
- Infinite iterators
- Checking whether something is iterable
- Common mistakes
- Best practices
- Practice
- Conclusion
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
Two different things
| Iterable | Iterator | |
|---|---|---|
| Defines | __iter__ | __iter__ and __next__ |
| Answers | "give me something to walk with" | "give me the next value" |
| Remembers position | No | Yes |
| Reusable | Yes | No - exhausted once used |
| Examples | list, str, dict, set, range | The object iter(list) returns, generators, files |
numbers = [1, 2, 3]
print(hasattr(numbers, "__iter__")) # True - it is iterable
print(hasattr(numbers, "__next__")) # False - it is not an iterator
iterator = iter(numbers)
print(hasattr(iterator, "__next__")) # True
print(next(iterator)) # 1
print(next(iterator)) # 2
print(next(iterator)) # 3
# print(next(iterator)) # StopIterationWhat a for loop really does
for value in [10, 20, 30]:
print(value)# Exactly equivalent
iterator = iter([10, 20, 30])
while True:
try:
value = next(iterator)
except StopIteration:
break
print(value)Every for loop calls iter() once, then next() repeatedly, and stops when StopIteration is raised. Nothing else is involved. Understanding this explains a whole family of otherwise puzzling behaviours.
An iterator is used up
numbers = [1, 2, 3]
print(list(numbers)) # [1, 2, 3]
print(list(numbers)) # [1, 2, 3] - a list can be walked again
iterator = iter(numbers)
print(list(iterator)) # [1, 2, 3]
print(list(iterator)) # [] <- already exhaustedsquares = (n * n for n in range(5)) # a generator, so an iterator
print(sum(squares)) # 30
print(sum(squares)) # 0 - nothing left
print(list(squares)) # []This is the most common surprise with iterators. If you need to walk the values twice, either keep the underlying iterable, or materialise the iterator into a list once with values = list(iterator).with open("notes.txt", encoding="utf-8") as handle:
print(sum(1 for _ in handle)) # counts the lines
print(sum(1 for _ in handle)) # 0 - the file is at the end
handle.seek(0)
print(sum(1 for _ in handle)) # counts againnext with a default
iterator = iter([1, 2])
print(next(iterator, "done")) # 1
print(next(iterator, "done")) # 2
print(next(iterator, "done")) # done - no exception
people = [{"name": "Meera"}, {"name": "Arun"}]
match = next((p for p in people if p["name"] == "Zara"), None)
print(match) # Nonenext(generator, default) is the idiomatic "find the first match, or nothing". It stops at the first hit instead of filtering the whole collection.
Writing an iterator class
class Countdown:
"""Counts down from a starting number to one."""
def __init__(self, start):
self.current = start
def __iter__(self):
return self # the object is its own iterator
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
for n in Countdown(4):
print(n, end=" ") # 4 3 2 1
print()
c = Countdown(3)
print(list(c)) # [3, 2, 1]
print(list(c)) # [] - self.current is now 0, so it is exhaustedSeparating the iterable from the iterator
class CountdownIterator:
def __init__(self, start):
self.current = start
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
class Countdown:
"""Reusable: each iteration gets a fresh iterator."""
def __init__(self, start):
self.start = start
def __iter__(self):
return CountdownIterator(self.start)
c = Countdown(3)
print(list(c)) # [3, 2, 1]
print(list(c)) # [3, 2, 1] - reusable
print(list(zip(c, c))) # two independent walks over the same objectThis is how list works: the list is the iterable, and iter(list) returns a separate list_iterator each time. That is why you can nest loops over the same list.
A practical iterator
class Paginated:
"""Walks a sequence in fixed size pages."""
def __init__(self, items, size):
if size < 1:
raise ValueError("page size must be at least 1")
self.items = items
self.size = size
def __iter__(self):
for start in range(0, len(self.items), self.size):
yield self.items[start:start + self.size]
def __len__(self):
return -(-len(self.items) // self.size) # ceiling division
pages = Paginated(list(range(1, 11)), 3)
print(len(pages), "pages")
for number, page in enumerate(pages, start=1):
print(f"page {number}: {page}")Note that __iter__ uses yield. A method containing yield returns a generator, which is already an iterator - so there is no __next__ to write. The next note covers generators fully, and in practice they replace almost every hand written iterator class.
iter() with two arguments
import random
# Call the function repeatedly until it returns the sentinel value
rolls = iter(lambda: random.randint(1, 6), 6)
print(list(rolls)) # every roll before the first 6with open("data.bin", "rb") as handle:
for chunk in iter(lambda: handle.read(4096), b""):
print(len(chunk)) # read until an empty resultThe two argument form calls the first argument repeatedly and stops when it returns the second. It replaces a while True loop with a break.
Infinite iterators
class Cycle:
"""Repeats a sequence forever."""
def __init__(self, items):
self.items = list(items)
self.index = 0
def __iter__(self):
return self
def __next__(self):
if not self.items:
raise StopIteration
value = self.items[self.index]
self.index = (self.index + 1) % len(self.items)
return value
import itertools
colours = Cycle(["red", "green", "blue"])
print(list(itertools.islice(colours, 7)))
# [' red', 'green', 'blue', 'red', 'green', 'blue', 'red']An iterator never has to end. That is impossible for a list and perfectly normal for an iterator, and it is why lazy evaluation matters.
Checking whether something is iterable
from collections.abc import Iterable, Iterator
print(isinstance([1, 2], Iterable)) # True
print(isinstance([1, 2], Iterator)) # False
print(isinstance(iter([1, 2]), Iterator)) # True
print(isinstance("abc", Iterable)) # True
print(isinstance(42, Iterable)) # False
def flatten(value):
"""Flatten nested iterables, leaving strings alone."""
if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
for item in value:
yield from flatten(item)
else:
yield value
print(list(flatten([1, [2, [3, "ab"]], 4]))) # [1, 2, 3, 'ab', 4]The exclusion of str matters: a string is iterable, so without that check it would be split into characters and then recursed on forever.
Common mistakes
- Iterating an iterator twice and getting nothing the second time.
- Calling
len()on an iterator; there is no length to report. - Indexing an iterator; it does not support subscripting.
- Forgetting to raise
StopIteration, producing an infinite loop. - Returning
selffrom__iter__when the object should be reusable. - Treating a string as a container of words when iterating; it yields characters.
Best practices
- Write
__iter__withyieldrather than a separate iterator class. - Return a fresh iterator from
__iter__when the object should be walkable more than once. - Use
next(it, default)instead of catchingStopIteration. - Materialise into a list when you genuinely need several passes.
- Use
collections.abc.Iterablefor checks rather than testing for__iter__by hand.
Practice
- Write the equivalent
whileloop for aforloop, usingiterandnextexplicitly. - Show that a generator is exhausted after one pass, and give two ways to work around it.
- Write an iterator class producing the first
neven numbers. - Write a reusable iterable and prove it can be walked twice, including in nested loops.
- Use the two argument form of
iterto read a file in fixed size chunks.
Conclusion
An iterable hands out iterators; an iterator hands out values and remembers its place. A for loop is iter plus repeated next until StopIteration. Everything lazy in Python - generators, files, range, itertools - is built on that protocol.