Iterators

https://docs.python.org/3/tutorial/classes.html

https://docs.python.org/3/library/functions.html#iter

https://docs.python.org/3/library/stdtypes.html#typeiter

INTRODUCTION

Three notions:
(1) an iterable is anything that you can iterate over [iter() can build an iterator],
(2) an iterator is the thing that does the actual iterating [__iter__() and __next__() methods should be present],
(3) generators are one of the simpler ways to create your own iterators ['lazy evaluation'; generator expressions and generator functions].


for item in iterable:   # iteration
    print(item)
L = list(iterable)
result = sum(iterable)
high = max(iterable)
low = min(iterable)

FINITE ITERATORS


# Builtin function.

reversed(sequence)   # reversed object

# Generator expression.

sequence = "word"
gen = (sequence[i] for i in range(len(sequence)-1,-1,-1))   # generator object

# Generator function.

def Reverse(sequence):   # a generator iterator is returned
    """A reverse iterator based on a sequence."""
    idx = len(sequence)
    while idx > 0:
        idx = idx-1
        yield sequence[idx]

# The other version based on a class.

class Reverse:   # an instance of this class is an iterator
    """A reverse iterator based on a sequence."""

    def __init__(self, sequence):
        self.sequence = sequence
        self.idx = len(sequence)

    def __iter__(self):   # for the iter() function
        return self

    def __next__(self):   # for the next() function
        if self.idx == 0:
            raise StopIteration
        self.idx -= 1
        return self.sequence[self.idx]

    next = __next__   # compatibility
        # Python 2 has X.next().
        # Python 3 has X.__next__().
        # Python 3 i 2.6+ have a builtin function next(X).

# Usage (for both versions).

for char in Reverse("spam"):
    print(char)   # m, a, p, s

for item in Reverse([1, 2, 3, 4]):
    print(item)   # 4, 3, 2, 1

ITER


# iter(iterable) return iterator
# iter(callable, sentinel) return iterator

# an iterator based on a sequence
it1 = iter([1, 2])   # list_iterator object in Py3 (lists have __iter__)
it2 = iter("abcd")   # str_iterator object in Py3 (strings have __iter__)
it3 = iter(it2)   # all iterators are also iterables
assert it3 is it2

# Once you have an iterator, the only thing you can do with it is get
# its next item. And you’ll get a stop iteration exception if you ask
# for the next item but there aren’t anymore items.
print(next(it1))   # 1
print(next(it1))   # 2
print(next(it1))   # StopIteration

class Dumb:

    def __init__(self, sequence):
        self.data = sequence

    def __getitem__(self, n):
        return self.data[n]

x = Dumb("word")
print(x[1])   # o
#print(x[10])   # IndexError: string index out of range
#len(x)   # AttributeError: Dumb instance has no attribute '__len__'

for item in x:   # iteration
    print(item)

# manual iteration
y = iter(x)         # make an iterator, iter() use the sequence protocol (__getitem__)
print(next(y))   # w
print(next(y))   # o
print(next(y))   # r
print(next(y))   # d
print(next(y))   # StopIteration, the iterator is exhausted

# Chunking list by two using an iterator.

it = iter(range(6))
list(zip(it, it))   # [(0, 1), (2, 3), (4, 5)]

FOR LOOP EXPLAINED


# https://www.programiz.com/python-programming/iterator

for element in iterable:
    # do something with element
    process(element)

# It is equivalent to the following code.

# create an iterator object from that iterable
iter_obj = iter(iterable)

# infinite loop
while True:
    try:
        # get the next item
        element = next(iter_obj)
        # do something with element
        process(element)
    except StopIteration:
        # if StopIteration is raised, break from loop
        break

AN ITERATOR BASED ON A CALLABLE OBJECT


class MyInt:

    def __init__(self):
        self.n = -1

    def __call__(self):
        self.n = self.n + 1
        return self.n

x = MyInt()   # x is a MyInt instance, a callable object
print(x())   # returns 0
print(x())   # returns 1
print(x())   # returns 2

# Preparing an iterator 'y'.
# If the callable returns 5 then y stops.
y = iter(MyInt(), 5)   # y is a callable-iterator object
for i in y:         # returns 0, 1, 2, 3, 4 (without 5)
    print(i)
# 5 was used to stop the iterator y.
print(next(y))   # StopIteration is raised, y is exhausted

# Infinite iterators.

import itertools

iter_zeros1 = iter(int, 1)   # int() gives 0
iter_zeros2 = iter((lambda: 0), 1)
iter_zeros3 = itertools.cycle([0])   # itertools.cycle(sequence)
iter_zeros4 = itertools.repeat(0)    # itertools.repeat(x, times=5)