6.3. Iterator

  • EN: Iterator

  • PL: Iterator

  • Type: object

The Iterator pattern is a design pattern that provides a way to access the elements of an aggregate object sequentially without exposing its underlying representation. In Python, this is typically implemented using the __iter__ and __next__ methods.

Here's a simple example of an Iterator in Python:

>>> class MyIterator:
...     def __init__(self, data):
...         self.data = data
...
...     def __iter__(self):
...         self._current = 0
...         return self
...
...     def __next__(self):
...         if self._current >= len(self.data):
...             raise StopIteration
...         result = self.data[self._current]
...         self._current += 1
...         return result

This will output:

>>> my_data = [1, 2, 3, 4, 5]
>>> my_iterator = MyIterator(my_data)
>>> for item in my_iterator:
...     print(item)
...
1
2
3
4
5

6.3.1. Pattern

  • History (like browser history)

design-patterns/behavioral/img/designpatterns-iterator-pattern.png

6.3.2. Problem

design-patterns/behavioral/img/designpatterns-iterator-problem.png

from dataclasses import dataclass, field


@dataclass
class BrowseHistory:
    urls: list[str] = field(default_factory=list)

    def push(self, url: str) -> None:
        self.urls.append(url)

    def pop(self) -> str:
        return self.urls.pop()

    def get_urls(self) -> list[str]:
        return self.urls


if __name__ == '__main__':
    history = BrowseHistory()
    history.push(url='https://a.example.com')
    history.push(url='https://b.example.com')
    history.push(url='https://c.example.com')

    for i in range(len(history.get_urls())):
        url = history.get_urls()[i]
        print(i)

6.3.3. Solution

../../_images/designpatterns-iterator-solution.png

from dataclasses import dataclass, field


class Iterator:
    def has_next(self) -> bool:
        raise NotImplementedError

    def current(self) -> str:
        raise NotImplementedError

    def next(self) -> None:
        raise NotImplementedError


@dataclass
class BrowseHistory:
    urls: list[str] = field(default_factory=list)

    def push(self, url: str) -> None:
        self.urls.append(url)

    def pop(self) -> str:
        return self.urls.pop()

    def get_urls(self) -> list[str]:
        return self.urls

    def create_iterator(self) -> Iterator:
        return self.ListIterator(self)

    @dataclass
    class ListIterator(Iterator):
        history: 'BrowseHistory'
        index: int = 0

        def has_next(self) -> bool:
            return self.index < len(history.urls)

        def current(self) -> str:
            return history.urls[self.index]

        def next(self) -> None:
            self.index += 1


if __name__ == '__main__':
    history = BrowseHistory()
    history.push(url='https://a.example.com')
    history.push(url='https://b.example.com')
    history.push(url='https://c.example.com')

    iterator = history.create_iterator()
    while iterator.has_next():
        url = iterator.current()
        print(url)
        iterator.next()

    # https://a.example.com
    # https://b.example.com
    # https://c.example.com

6.3.4. Use Case - 0x01

from urllib.request import urlopen
from dataclasses import dataclass, field
from typing import Self


@dataclass
class Browser:
    history: list[str] = field(default_factory=list)

    def open(self, url: str) -> None:
        self.history.append(url)
        # return urlopen(url).read()

    def __iter__(self) -> Self:
        self._current = 0
        return self

    def __next__(self) -> str:
        if self._current >= len(self.history):
            raise StopIteration
        result = self.history[self._current]
        self._current += 1
        return result


if __name__ == '__main__':
    browser = Browser()
    browser.open('https://python3.info')
    browser.open('https://numpy.astrotech.io')
    browser.open('https://pandas.astrotech.io')
    browser.open('https://design-patterns.astrotech.io')

    for url in browser:
        print(url)

# https://python3.info
# https://numpy.astrotech.io
# https://pandas.astrotech.io
# https://design-patterns.astrotech.io

6.3.5. Use Case - 0x02

from urllib.request import urlopen
from dataclasses import dataclass, field


@dataclass
class Browser:
    history: list[str] = field(default_factory=list)

    def open(self, url: str) -> None:
        self.history.append(url)
        # return urlopen(url).read()


if __name__ == '__main__':
    browser = Browser()
    browser.open('https://python3.info')
    browser.open('https://numpy.astrotech.io')
    browser.open('https://pandas.astrotech.io')
    browser.open('https://design-patterns.astrotech.io')

    for url in browser.history:
        print(url)

# https://python3.info
# https://numpy.astrotech.io
# https://pandas.astrotech.io
# https://design-patterns.astrotech.io

6.3.6. Assignments

Code 6.54. Solution
"""
* Assignment: DesignPatterns Behavioral Iterator
* Complexity: easy
* Lines of code: 9 lines
* Time: 5 min

English:
    1. Implement Iterator pattern
    2. Run doctests - all must succeed

Polish:
    1. Zaimplementuj wzorzec Iterator
    2. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> crew = Crew()
    >>> crew += 'Mark Watney'
    >>> crew += 'Jose Jimenez'
    >>> crew += 'Melissa Lewis'
    >>>
    >>> for member in crew:
    ...     print(member)
    Mark Watney
    Jose Jimenez
    Melissa Lewis
"""


class Crew:
    def __init__(self):
        self.members = list()

    def __iadd__(self, other):
        self.members.append(other)
        return self


Code 6.55. Solution
"""
* Assignment: Protocol Iterator Implementation
* Complexity: easy
* Lines of code: 9 lines
* Time: 5 min

English:
    1. Modify classes to implement iterator protocol
    2. Iterator should return instances of `Mission`
    3. Run doctests - all must succeed

Polish:
    1. Zmodyfikuj klasy aby zaimplementować protokół iterator
    2. Iterator powinien zwracać instancje `Mission`
    3. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isclass, ismethod

    >>> assert isclass(Astronaut)

    >>> mark = Astronaut('Mark', 'Watney')
    >>> assert hasattr(mark, 'firstname')
    >>> assert hasattr(mark, 'lastname')
    >>> assert hasattr(mark, 'missions')
    >>> assert hasattr(mark, '__iter__')
    >>> assert hasattr(mark, '__next__')
    >>> assert ismethod(mark.__iter__)
    >>> assert ismethod(mark.__next__)

    >>> mark = Astronaut('Pan', 'Twardowski', missions=(
    ...     Mission(1969, 'Apollo 11'),
    ...     Mission(2024, 'Artemis 3'),
    ...     Mission(2035, 'Ares 3'),
    ... ))

    >>> for mission in mark:
    ...     print(mission)
    Mission(year=1969, name='Apollo 11')
    Mission(year=2024, name='Artemis 3')
    Mission(year=2035, name='Ares 3')
"""

from dataclasses import dataclass


@dataclass
class Astronaut:
    firstname: str
    lastname: str
    missions: tuple = ()


@dataclass
class Mission:
    year: int
    name: str


Code 6.56. Solution
"""
* Assignment: Protocol Iterator Range
* Complexity: medium
* Lines of code: 9 lines
* Time: 8 min

English:
    1. Modify class `Range` to write own implementation
       of a built-in `range(start, stop, step)` function
    2. Assume, that user will never give only one argument;
       it will always be either two or three arguments
    3. Use Iterator protocol
    4. Run doctests - all must succeed

Polish:
    1. Zmodyfikuj klasę `Range` aby napisać własną implementację
       wbudowanej funkcji `range(start, stop, step)`
    2. Przyjmij, że użytkownik nigdy nie poda tylko jednego argumentu;
       zawsze będą to dwa lub trzy argumenty
    3. Użyj protokołu Iterator
    4. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isclass, ismethod

    >>> assert isclass(Range)

    >>> r = Range(0, 0, 0)
    >>> assert hasattr(r, '__iter__')
    >>> assert hasattr(r, '__next__')
    >>> assert ismethod(r.__iter__)
    >>> assert ismethod(r.__next__)

    >>> list(Range(0, 10, 2))
    [0, 2, 4, 6, 8]

    >>> list(Range(0, 5))
    [0, 1, 2, 3, 4]
"""
from dataclasses import dataclass


@dataclass
class Range:
    start: int = 0
    stop: int = None
    step: int = 1