12.1. Iterators#

Learning goals — By the end of this section you will be able to:

  • Describe Python’s iterator protocol and distinguish iterables from iterators

  • Use iter() and next() manually to step through any iterable

  • Use lazy built-in iterators: enumerate(), zip(), map(), filter(), reversed()

  • Implement a custom iterator class with __iter__ and __next__

  • Raise StopIteration correctly to signal the end of a sequence

12.1.1. The Iterator Protocol#

Any object you can loop over is an iterable. Under the hood, for loops call two methods:

Method

Called on

Returns

__iter__()

the iterable

an iterator object

__next__()

the iterator

the next value, or raises StopIteration

An object that implements both methods is its own iterator (e.g., file objects, generators). A list is iterable but not its own iterator — iter(my_list) creates a separate iterator object.

# iter() creates an iterator from any iterable
my_list = [10, 20, 30]
it = iter(my_list)
print(type(it))           # <class 'list_iterator'>

# next() fetches values one at a time
print(next(it))           # 10
print(next(it))           # 20
print(next(it))           # 30

# The for loop does this for you automatically:
# for item in my_list:  →  it = iter(my_list); next(it); next(it); ...

# Calling next() past the end raises StopIteration
try:
    print(next(it))
except StopIteration:
    print("StopIteration raised — sequence exhausted")
<class 'list_iterator'>
10
20
30
StopIteration raised — sequence exhausted

12.1.2. Built-in Lazy Iterators#

Python’s standard library is full of iterators that produce values on demand — they don’t build a list in memory first. This makes them memory-efficient for large or infinite sequences.

Iterator

What it produces

enumerate(seq)

(index, value) pairs

zip(a, b)

(a_item, b_item) pairs, stops at the shorter

map(f, seq)

f(item) for each item

filter(f, seq)

items where f(item) is True

reversed(seq)

items in reverse order

fruits = ['apple', 'banana', 'cherry']

# enumerate — index + value pairs
for i, fruit in enumerate(fruits, start=1):
    print(f"{i}. {fruit}")

# zip — pair two sequences together
prices = [1.20, 0.50, 2.00]
for fruit, price in zip(fruits, prices):
    print(f"{fruit}: ${price:.2f}")

# map and filter return iterators (wrap in list() to inspect)
lengths = list(map(len, fruits))           # [5, 6, 6]
long_ones = list(filter(lambda f: len(f) > 5, fruits))  # ['banana', 'cherry']
print(lengths)
print(long_ones)

# reversed
for fruit in reversed(fruits):
    print(fruit, end=' ')
print()
1. apple
2. banana
3. cherry
apple: $1.20
banana: $0.50
cherry: $2.00
[5, 6, 6]
['banana', 'cherry']
cherry banana apple 

12.1.3. Custom Iterator Classes#

When you need iteration logic that doesn’t map cleanly to a built-in or generator, implement the iterator protocol directly. Your class needs two methods:

  • __iter__(self) — returns self (the iterator is its own iterable)

  • __next__(self) — returns the next value, or raises StopIteration

class Countdown:
    """Counts down from `start` to 1, then raises StopIteration."""

    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self          # the iterator IS the object

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value


# Works in a for loop
for n in Countdown(5):
    print(n, end=' ')
print()   # 5 4 3 2 1

# Also works with list(), sum(), max(), etc.
print(list(Countdown(4)))    # [4, 3, 2, 1]
print(sum(Countdown(10)))    # 55
5 4 3 2 1 
[4, 3, 2, 1]
55
class NumberRange:
    """Iterates from `start` to `stop` (exclusive) by `step`."""

    def __init__(self, start, stop, step=1):
        self.current = start
        self.stop = stop
        self.step = step

    def __iter__(self):
        return self

    def __next__(self):
        if (self.step > 0 and self.current >= self.stop) or \
           (self.step < 0 and self.current <= self.stop):
            raise StopIteration
        value = self.current
        self.current += self.step
        return value


print(list(NumberRange(1, 10, 2)))    # [1, 3, 5, 7, 9]
print(list(NumberRange(10, 0, -3)))   # [10, 7, 4, 1]
[1, 3, 5, 7, 9]
[10, 7, 4, 1]
### Exercise: Cycling Iterator
#   Implement a `Cycle` iterator that repeatedly loops through a sequence forever.
#   Example: Cycle(['R', 'G', 'B']) yields R, G, B, R, G, B, R, ...
#   1. Store the sequence and the current index.
#   2. `__next__` returns the item at the current index and advances; wraps with modulo.
#   3. Test: collect the first 7 values from Cycle([1, 2, 3]) → [1, 2, 3, 1, 2, 3, 1].
#   Hint: use `import itertools; list(itertools.islice(your_cycle, 7))` to cap it.
### Your code starts here.




### Your code ends here.
### Solution
import itertools

class Cycle:
    def __init__(self, sequence):
        self.sequence = list(sequence)
        self.index = 0

    def __iter__(self):
        return self

    def __next__(self):
        if not self.sequence:
            raise StopIteration
        value = self.sequence[self.index]
        self.index = (self.index + 1) % len(self.sequence)
        return value

# Collect the first 7 values
print(list(itertools.islice(Cycle([1, 2, 3]), 7)))    # [1, 2, 3, 1, 2, 3, 1]
print(list(itertools.islice(Cycle(['R', 'G', 'B']), 8)))  # ['R', 'G', 'B', ...]
[1, 2, 3, 1, 2, 3, 1]
['R', 'G', 'B', 'R', 'G', 'B', 'R', 'G']

12.1.4. Summary#

Concept

Key idea

Iterable

Has __iter__() — can be passed to for, list(), sum(), etc.

Iterator

Has __next__() — produces one value per call; raises StopIteration when done

iter(x)

Returns an iterator from any iterable

next(it)

Returns the next value from an iterator

enumerate(seq)

Yields (index, value) pairs

zip(a, b)

Pairs items from two iterables, stops at shortest

Custom iterator

Class with __iter__ returning self + __next__ raising StopIteration