Iterators, Generators and Lazy Evaluation

1. How iterators work

When Python loops through an object, what happens is:

1. The for statement calls iter() on the object, returning an iterator
2. This iterator has  a __next__() method which accesses the elements in the object
3. When there are no more objects, __next__() raises a StopIteration exception

>>> s = 'abc'
>>> it = iter(s)
>>> it
<iterator object at 0x00A1DB50>
>>> next(it)
'a'
>>> next(it)
'b'
>>> next(it)
'c'
>>> next(it)
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
    next(it)
StopIteration

2. Creating an iterator

Define an __iter__() method which returns an object with a __next__() method. (Here it retuns self since the class itself defines __next__().

class Reverse:
    def __init__(self, data):
        self.data = data
        self.index = len(data)

    def __iter__(self):
        return self

    def __next__(self):
        if self.index == 0:
            raise StopIteration

        self.index = self.index - 1
        return self.data[self.index]

rev = Reverse('lol')
for char in rev:
    print(char)

3. Creating an iterator using generators

A generator is a function that returns stuff before it is finished (using yield), and can be resumed from where they left off (using next), until it hits StopIteration

Some examples:

>>> def myGen(n):
...     yield n + 1
...     yield n + 2
... 
>>> g = myGen(0)
>>> next(g)
1
>>> next(g)
2
>>> next(g)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

Looping through all natural numbers:

>>> def infinite():
...     i = 0
...     while True:
...         yield i
...         i += 1

The same code for reversing a string as before

def reverse(data):
    for index in range(len(data)-1, -1, -1):
        yield data[index]

4. Generator expressions

Generator expressions is much like list comprehensions, but the object is evaluated when it is needed and not when it is created.

gen = (x for x in range(999999))

This saves a ton of memory, since it doesn’t create the whole list upfront.

5. Generator VS generator expressions

They are basically the same thing. Trivia example:

# generator
def uc_gen(text):
    for char in text:
        yield char.upper()

# generator expression
def uc_genexp(text):
    return (char.upper() for char in text)

6. Lazy evaluation, or why do I need generators

Lazy evaluation is an evaluation strategy which delays the evaluation of an expression until its value is needed.
Typical useage: I have to compute a large set of results but I don’t know if I’m going to need them all; or maybe I just don’t want to allocate the memory for all the results at the same time; I have a function that takes long time to execute (think search on a huge domain) and I want to show intermediate results while I get them, instead of waiting for the search to be over.

7. Using generators to loop through a linked list

def looper(self):
    n = self.head
    while n is not None:
        yield n
        n = n.next

# how cool is that?
x = [ n.value for n in l.looper() if n.value > 10 ]

8. Using generators to do a inorder() traversal of a BST

# Python 2
def inorder(t):
    if t:
        for key in inorder(t.left):
            yield key
        yield t.key
        for key in inorder(t.right):
            yield key

Python 3 adds some neat syntax:

# Python 3
def inorder(t):
    if t:
        yield from inorder(t.left)
        yield t.key
        yield from inorder(t.right)