Back to Posts
Abstract Möbius strip sculpture symbolizing Understanding Python Iterables and Iterators with a soft gradient and shadow on a light background.

Python 101: Iterables vs. Iterators Guide

In Python programming, you may have heard of the terms “iterables” and “iterators” floating around, causing some confusion. This post is here to help clarify these concepts, explaining their definitions, differences, and how they can be practically used.

Understanding iterables

An iterable is any Python object capable of returning its members one at a time, permitting it to be iterated over in a loop. Specifically, an object is considered iterable if it meets one of the following conditions:

  • It implements the __iter__ method, which returns an iterator for the object.
  • It implements the __getitem__ method, enabling index-based access to its elements.

Common examples of iterables include familiar data structures such as lists, tuples, and dictionaries. While dictionaries naturally iterate over their keys, Python provides ways to iterate over values or key-value pairs of the dictionary as well.

Decoding iterators

Iterators take the stage as the agents enabling the process of iteration. They are objects that track the current position during iteration and proceed to the next element when prompted. To be classified as an iterator, an object must implement:

  • The __iter__ method, which returns the iterator instance itself. This design facilitates the use of iterators in contexts expecting an iterable.
  • The __next__ method, which moves to the next item in the sequence. Upon exhausting the elements, it should signal this by raising a StopIteration exception.

Python’s standard library offers a range of built-in iterators, including functions like range, filter, map, and enumerate. These iterators vary in their operation, with some generating values on the fly and others applying a function to each item in an iterable.

Implementing iterators and iterables

To illustrate these concepts, let’s examine a few simple implementations:

Custom iterable with __getitem__

class NumberIterable:
    def __init__(self, *numbers):
        self.numbers = numbers

    def __getitem__(self, idx: int) -> int:
        if idx < len(self.numbers):
            return self.numbers[idx]
        raise IndexError("list index out of range")

numbers = NumberIterable(1, 2, 3, 4, 5)
for number in numbers:
    print(number)

This example showcases a simple iterable object that allows for index-based access to its elements. All iterable objects have inherent iterator capabilities, which allow us to iterate over their contents. When iterating over index-based iterable objects like lists or tuples, the process is based on the object’s length. Dictionaries, on the other hand, use the keys iterable for iteration, functioning similarly to other index-based iterable objects. We can also create iterators from iterables by using the iter function.

iterator =  iter([1, 2, 3, 4, 5])
print(iterator)
for i in iterator:
    print(i)

Building an iterator with __next__

class Range:
    def __init__(self, start: int, end: int, step: int = 1):
        self.start = start
        self.end = end
        self.step = step
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current > self.end:
            raise StopIteration
        current = self.current
        self.current += self.step
        return current


my_range = Range(0, 10)
first = next(my_range)
print(f"first: {first}")
second = next(my_range)
print(f"second: {second}")
for item in my_range:
    print(item)

The Range class mimics Python’s built-in range function, generating numbers within a specified range. As you can see, by implementing the __iter__ and __next__ methods, we can yield the generated numbers. This does not depend on an external iterable, instead generating them from an internal state. The next function can then be used to manually progress through an iterator, retrieving the result of the “next” method of the iterable. Additionally, it allows for the provision of a default value to be returned in case the iterator is depleted.

The power of generators

Generators offer a streamlined way to create iterators. By using the yield keyword, functions are converted into generators. The underlying process is the same; each iteration calls the generator’s __next__ method, returning the result.

def mapper_gen(func: Callable[[int], int], collection: list[int]):
    for item in collection:
        yield func(item)

items = [1, 2, 3, 4, 5]
mapper =  mapper_gen(lambda x: x * 2, items)
first = next(mapper)
print(f"first: {first}")
second = next(mapper)
print(f"second: {second}")
for item in mapper:
    print(item)
empty = next(mapper, None)

This generator-based mapper generator streamlines the process of applying a specified function to each element in a collection.

Generators are valuable for their ability to be lazily evaluated, simplifying the creation of iterators and iterables. They achieve this by relinquishing control back to their caller upon reaching the yield keyword, effectively pausing their own execution until prompted to continue. We can see this in action when making use of the next function, as we did with the Range iterable.

Comprehending Comprehensions

Another approach to generating and utilizing iterators and iterables is through the use of comprehension expressions. These expressions have a syntax that closely resembles the objects they generate, with the exception of generators.

List Comprehension

even_numbers = [i for i in filter(lambda x: x % 2 == 0, range(10))]
print(even_numbers)

In this instance, we have produced an iterable (the list) by linking iterators together. Comprehension expressions leverage the capability of iterators to be chained in a clear and readable manner.

As previously mentioned, generator comprehension expressions differ from other types in that they appear as generators rather than the structure they create. In these cases, the syntax typically used to generate tuples actually results in generators.

generator_expression = (i for i in filter(lambda x: x % 2 == 0, range(10)))
print(generator_expression)

Final thoughts

Understanding iterables and iterators is crucial for effective Python programming. These constructs underpin many of Python’s built-in functions and allow for the creation of efficient, readable code. Whether through classes or generators, Python provides versatile tools for working with these patterns, enhancing our capability to manage and process collections of data.

Improve your code with my 3-part code diagnosis framework

Watch my free 30 minutes code diagnosis workshop on how to quickly detect problems in your code and review your code more effectively.

When you sign up, you'll get an email from me regularly with additional free content. You can unsubscribe at any time.

Recent posts