Skip to content

StopIteration Mechanics

StopIteration is the protocol-level signal that an iterator has no more values. Understanding StopIteration is fundamental to Python's iteration protocol and generator behavior.


Iterator Protocol

Raising StopIteration

class CountUp:
    def __init__(self, max):
        self.max = max
        self.current = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.current < self.max:
            self.current += 1
            return self.current
        else:
            raise StopIteration

counter = CountUp(3)
for value in counter:
    print(value)

Output:

1
2
3

Manual Iteration

numbers = iter([1, 2, 3])

try:
    print(next(numbers))
    print(next(numbers))
    print(next(numbers))
    print(next(numbers))  # Raises StopIteration
except StopIteration:
    print("Iterator exhausted")

Output:

1
2
3
Iterator exhausted

Generators and StopIteration

Implicit StopIteration

def count_up(max):
    current = 0
    while current < max:
        current += 1
        yield current

gen = count_up(3)
try:
    while True:
        print(next(gen))
except StopIteration:
    print("Generator finished")

Output:

1
2
3
Generator finished

Return Values in StopIteration

PEP 380 Return Mechanism

def search(items, target):
    for i, item in enumerate(items):
        if item == target:
            return i
    return -1

result = search([1, 2, 3, 4], 3)
print(f"Found at index: {result}")

Output:

Found at index: 2

Best Practices

Catching StopIteration Safely

def safe_next(iterator, default=None):
    try:
        return next(iterator)
    except StopIteration:
        return default

gen = (x for x in [1, 2, 3])
next(gen)
next(gen)
next(gen)
result = safe_next(gen, "exhausted")
print(result)

Output:

exhausted


Runnable Example: iterator_protocol_sentence.py

"""
Iterator Protocol Implementation - Building a Sentence Class with __iter__
This tutorial demonstrates how to implement the iterator protocol by
creating a Sentence class that can be iterated over like a native Python object.
Run this file to see the iterator protocol in action!
"""

import re
import reprlib

if __name__ == "__main__":

    print("=" * 70)
    print("ITERATOR PROTOCOL - IMPLEMENTING __iter__")
    print("=" * 70)

    # ============================================================================
    # EXAMPLE 1: Understanding Iterables and Iterators
    # ============================================================================
    print("\n1. UNDERSTANDING ITERABLES AND ITERATORS")
    print("-" * 70)

    print("\nIn Python, there's an important distinction:")
    print("- ITERABLE: Object that implements __iter__() method")
    print("  Returns an iterator")
    print("  Examples: lists, tuples, strings, dicts")
    print("")
    print("- ITERATOR: Object that implements both:")
    print("  __iter__() - returns itself")
    print("  __next__() - returns the next value and raises StopIteration when done")
    print("")
    print("When you write 'for x in iterable:', Python:")
    print("  1. Calls iterable.__iter__() to get an iterator")
    print("  2. Repeatedly calls iterator.__next__() until StopIteration")

    # ============================================================================
    # EXAMPLE 2: The Sentence Class - A Custom Iterable
    # ============================================================================
    print("\n2. THE SENTENCE CLASS - MAKING TEXT ITERABLE")
    print("-" * 70)

    # Regular expression to find words (letters and digits)
    RE_WORD = re.compile(r'\w+')

    class Sentence:
        """
        A sequence of words extracted from a string.

        This class implements the iterable protocol, allowing you to:
        - Access words by index: sentence[0], sentence[1]
        - Get the number of words: len(sentence)
        - Iterate over words: for word in sentence
        """

        def __init__(self, text):
            """
            Initialize with a text string.

            We extract all words (sequences of alphanumeric chars) from the text.
            """
            self.text = text
            # Use regex to find all words - this does the hard work
            self.words = RE_WORD.findall(text)

        def __getitem__(self, index):
            """
            Allow indexing: sentence[0], sentence[1], etc.

            This makes Sentence a sequence-like object.
            Python uses this for iteration as a fallback if __iter__ isn't defined.
            """
            return self.words[index]

        def __len__(self):
            """Allow len() function: len(sentence)"""
            return len(self.words)

        def __repr__(self):
            """
            Nice string representation that abbreviates long text.

            reprlib.repr() shows a summary of the text.
            """
            return 'Sentence(%s)' % reprlib.repr(self.text)

    print("\nDefined the Sentence class with:")
    print("- __init__(text): Initializes and extracts words using regex")
    print("- __getitem__(index): Allows indexing like a list")
    print("- __len__(): Returns the number of words")
    print("- __repr__(): Nice representation")

    # ============================================================================
    # EXAMPLE 3: Using Sentence - Indexing and Length
    # ============================================================================
    print("\n3. USING SENTENCE - INDEXING AND LENGTH")
    print("-" * 70)

    text = 'To be, or not to be, that is the question'
    s = Sentence(text)

    print(f"\nCreated: s = Sentence('{text}')\n")

    print(f"s[0] = {s[0]!r} (first word)")
    print(f"s[1] = {s[1]!r} (second word)")
    print(f"s[5] = {s[5]!r} (sixth word)")
    print(f"\nlen(s) = {len(s)} (total words)")

    print(f"\nrepr(s) = {repr(s)}")

    print("\nWHY THIS WORKS:")
    print("- __getitem__ allows indexing notation [index]")
    print("- __len__ makes len() work on our custom object")
    print("- __repr__ shows a nice representation in the interpreter")

    # ============================================================================
    # EXAMPLE 4: Adding __iter__ - Making Sentence Iterable in for loops
    # ============================================================================
    print("\n4. IMPLEMENTING __iter__ - FOR LOOP SUPPORT")
    print("-" * 70)

    print("\nThe current Sentence class supports indexing, but what about for loops?")
    print("Python has a fallback: if __iter__ isn't defined, it tries __getitem__")
    print("But it's better to explicitly implement __iter__ for clarity!\n")

    print("Here's what we need to add:\n")

    code_example = '''
    def __iter__(self):
        """
        Make Sentence iterable.

        This method should return an iterator object.
        We could return a custom iterator, or use a generator.
        For this example, we'll use a helper iterator class.
        """
        return SentenceIterator(self.words)
    '''

    print(code_example)

    # ============================================================================
    # EXAMPLE 5: Creating a Custom Iterator Class
    # ============================================================================
    print("\n5. CUSTOM ITERATOR CLASS")
    print("-" * 70)

    class SentenceIterator:
        """
        An iterator for the Sentence class.

        This class implements the iterator protocol:
        - __iter__() returns itself
        - __next__() returns the next word or raises StopIteration
        """

        def __init__(self, words):
            """Store the words and initialize index to 0."""
            self.words = words
            self.index = 0

        def __iter__(self):
            """An iterator returns itself."""
            return self

        def __next__(self):
            """
            Return the next word, or raise StopIteration when done.

            This is the key method that makes iteration work!
            """
            try:
                word = self.words[self.index]
            except IndexError:
                # No more words, signal the end of iteration
                raise StopIteration
            self.index += 1
            return word

    print("Defined SentenceIterator class:")
    print("- __init__(words): Stores words and sets starting index to 0")
    print("- __iter__(): Returns itself (required by iterator protocol)")
    print("- __next__(): Returns next word or raises StopIteration")

    # ============================================================================
    # EXAMPLE 6: Adding __iter__ to Sentence
    # ============================================================================
    print("\n6. ADDING __iter__ TO SENTENCE")
    print("-" * 70)

    # Extend the Sentence class with __iter__
    class Sentence:
        """
        A sequence of words extracted from a string.
        Now with full iterator support!
        """

        def __init__(self, text):
            self.text = text
            self.words = RE_WORD.findall(text)

        def __getitem__(self, index):
            return self.words[index]

        def __len__(self):
            return len(self.words)

        def __repr__(self):
            return 'Sentence(%s)' % reprlib.repr(self.text)

        def __iter__(self):
            """
            Return an iterator for this sentence.

            This is the crucial method that makes 'for word in sentence' work!
            """
            return SentenceIterator(self.words)

    print("\nAdded __iter__ method to Sentence:")
    print("Now Sentence objects work with for loops!")

    # ============================================================================
    # EXAMPLE 7: Using the Iterable Sentence
    # ============================================================================
    print("\n7. USING SENTENCE WITH FOR LOOPS")
    print("-" * 70)

    s = Sentence(text)

    print(f"\nCreated: s = Sentence('{text}')\n")

    print("Iterating with for loop:")
    print("-" * 40)

    for i, word in enumerate(s, 1):
        print(f"  Word {i}: {word}")

    print("\nWHY THIS WORKS:")
    print("- for loop calls s.__iter__() to get an iterator")
    print("- Each iteration calls iterator.__next__() to get next word")
    print("- When StopIteration is raised, the loop ends")
    print("- No explicit iterator management needed!")

    # ============================================================================
    # EXAMPLE 8: Practical Example - Sentence Analysis
    # ============================================================================
    print("\n8. PRACTICAL EXAMPLE - SENTENCE ANALYSIS")
    print("-" * 70)

    def analyze_sentence(text):
        """Analyze a sentence and print various statistics."""
        s = Sentence(text)

        print(f"\nText: {text}")
        print(f"Number of words: {len(s)}")
        print(f"\nWords:")
        for i, word in enumerate(s, 1):
            print(f"  {i:2}. {word}")

        print(f"\nWord lengths:")
        for word in s:
            print(f"  '{word}' -> {len(word)} characters")

    test_texts = [
        "Hello world",
        "Python is awesome",
        "The quick brown fox jumps over the lazy dog"
    ]

    for test_text in test_texts:
        analyze_sentence(test_text)
        print()

    # ============================================================================
    # SUMMARY: The Iterator Protocol
    # ============================================================================
    print("\n" + "=" * 70)
    print("SUMMARY - THE ITERATOR PROTOCOL")
    print("=" * 70)

    print("""
    To make an object iterable (usable in for loops):

    1. Implement __iter__():
       - Should return an iterator object
       - This method is called at the start of a for loop

    2. Create an iterator class that implements:
       - __iter__(): Returns itself
       - __next__(): Returns next value or raises StopIteration

    EXAMPLE FLOW for 'for word in sentence':
       1. Python calls sentence.__iter__() -> gets iterator
       2. Python calls iterator.__next__() -> gets first word
       3. Process word in loop body
       4. Back to step 2, repeat until StopIteration
       5. Loop exits

    KEY BENEFITS:
    - Make custom objects work with Python's for loops
    - Compatible with other iteration tools (list comprehensions, etc.)
    - Elegant, Pythonic interface
    - Lazy evaluation (iterator can be memory efficient)

    REMEMBER:
    - Iterable: Has __iter__() method
    - Iterator: Has __iter__() and __next__() methods
    - You often need both for full iterator support!
    """)

Runnable Example: iterator_protocol_prime_fib.py

"""
Iterator Protocol: Prime Number and Fibonacci Iterators

Custom iterators implementing __iter__() and __next__() for
generating mathematical sequences on demand.

Topics covered:
- Iterator protocol (__iter__ / __next__)
- StopIteration for signaling end of iteration
- Lazy evaluation (values computed one at a time)
- itertools for combinatorial generation

Based on concepts from Python-100-Days example15 and ch02/iteration materials.
"""

import itertools
from math import sqrt


# =============================================================================
# Example 1: Prime Number Iterator
# =============================================================================

def is_prime(num: int) -> bool:
    """Check if a number is prime.

    >>> is_prime(7)
    True
    >>> is_prime(10)
    False
    """
    if num < 2:
        return False
    for factor in range(2, int(sqrt(num)) + 1):
        if num % factor == 0:
            return False
    return True


class PrimeIterator:
    """Iterator that yields prime numbers in a given range.

    Implements the iterator protocol:
    - __iter__() returns self (the iterator object)
    - __next__() returns the next prime or raises StopIteration

    >>> primes = list(PrimeIterator(2, 20))
    >>> primes
    [2, 3, 5, 7, 11, 13, 17, 19]
    """

    def __init__(self, start: int, end: int):
        if start < 2:
            start = 2
        self._current = start - 1  # Will be incremented before first check
        self._end = end

    def __iter__(self):
        return self

    def __next__(self) -> int:
        self._current += 1
        while self._current <= self._end:
            if is_prime(self._current):
                return self._current
            self._current += 1
        raise StopIteration()


# =============================================================================
# Example 2: Fibonacci Iterator
# =============================================================================

class FibonacciIterator:
    """Iterator that yields Fibonacci numbers.

    Fibonacci sequence: 1, 1, 2, 3, 5, 8, 13, 21, 34, ...
    Each number is the sum of the two preceding ones.

    >>> list(FibonacciIterator(8))
    [1, 1, 2, 3, 5, 8, 13, 21]
    """

    def __init__(self, count: int):
        self._count = count
        self._a = 0
        self._b = 1
        self._index = 0

    def __iter__(self):
        return self

    def __next__(self) -> int:
        if self._index >= self._count:
            raise StopIteration()
        self._a, self._b = self._b, self._a + self._b
        self._index += 1
        return self._a


# =============================================================================
# Example 3: Using Iterators in for Loops
# =============================================================================

def demo_iterator_usage():
    """Show how custom iterators work with Python's iteration machinery."""
    print("=== Prime Iterator (primes from 2 to 50) ===")
    for prime in PrimeIterator(2, 50):
        print(prime, end=' ')
    print('\n')

    print("=== Fibonacci Iterator (first 15 numbers) ===")
    for fib in FibonacciIterator(15):
        print(fib, end=' ')
    print('\n')

    # Manual iteration with next()
    print("=== Manual next() calls ===")
    fib_iter = FibonacciIterator(5)
    print(f"next() -> {next(fib_iter)}")  # 1
    print(f"next() -> {next(fib_iter)}")  # 1
    print(f"next() -> {next(fib_iter)}")  # 2
    # Remaining values consumed by for loop
    print("for loop consumes rest:", list(fib_iter))
    print()


# =============================================================================
# Example 4: Iterator vs Generator Comparison
# =============================================================================

def fib_generator(count: int):
    """Generator function equivalent of FibonacciIterator.

    Generators are more concise than iterator classes but
    classes offer more control (reset, state inspection, etc.).
    """
    a, b = 0, 1
    for _ in range(count):
        a, b = b, a + b
        yield a


def demo_iterator_vs_generator():
    """Compare iterator class vs generator function."""
    print("=== Iterator Class vs Generator Function ===")

    # Both produce the same sequence
    from_class = list(FibonacciIterator(10))
    from_generator = list(fib_generator(10))

    print(f"Iterator class: {from_class}")
    print(f"Generator func: {from_generator}")
    print(f"Same result:    {from_class == from_generator}")
    print()


# =============================================================================
# Example 5: itertools with Custom Iterators
# =============================================================================

def demo_itertools():
    """Demonstrate itertools functions with our iterators."""
    print("=== itertools with Custom Iterators ===")

    # Take first 5 primes using islice
    first_5_primes = list(itertools.islice(PrimeIterator(2, 1000), 5))
    print(f"First 5 primes: {first_5_primes}")

    # Chain two iterators
    small_primes = PrimeIterator(2, 10)
    small_fibs = FibonacciIterator(5)
    combined = list(itertools.chain(small_primes, small_fibs))
    print(f"Primes(2-10) + Fib(5): {combined}")

    # Permutations and combinations
    print(f"Permutations of ABC: {list(itertools.permutations('ABC'))}")
    print(f"Combinations C(4,2): {list(itertools.combinations('ABCD', 2))}")
    print(f"Product of AB x 12:  {list(itertools.product('AB', '12'))}")
    print()


# =============================================================================
# Main
# =============================================================================

if __name__ == '__main__':
    demo_iterator_usage()
    demo_iterator_vs_generator()
    demo_itertools()