Lab 7: Iterators and Generators

Starter Files

Download lab07.zip.
Inside the archive, you will find starter files for the questions in this lab,
along with a copy of the OK autograder.

Submission

By the end of this lab, you should have submitted the lab with
python3 ok --submit. You may submit more than once before the
deadline; only the final submission will be graded.

To receive credit for this lab, you must complete Questions 1, 2, 3, 4, 5, 6 and
7 in lab07.py and submit through OK.

Questions 8, 9, and 10 are extra practice. They can be found in the
lab07_extra.py file. It is recommended that you
complete these problems on your on time.

Iterables and Iterators

In lecture, we studied several Python object interfaces, or protocols. In this
lab, we will study a new protocol, the iterator protocol. Implementing this
protocol allows us to use our objects in for loops! Remember the for loop?
(We really hope so!)

for elem in something_iterable:
# do something

for loops work on any object that is iterable. We previously described it as
working with any sequence -- all sequences are iterable, but there are other
objects that are also iterable! As it turns out, for loops are actually
translated by the interpreter into the following code:

That is, it first calls the built-in iter function to create an iterator,
saving it in some new, hidden variable (we've called it the_iterator here).
It then repeatedly calls the built-in next function on this iterator to get
values of elem and stops when that function raises StopIteration.

Question 1: Does it work?

Consider the following iterators. Which ones work and which ones don't? Why?

Use OK to test your knowledge with the following conceptual questions:

This also fails to implement the iterator interface. Without the
__iter__ method, the for loop will error. The for loop needs to
call __iter__ first because some objects might not implement the
__next__ method themselves, but calling __iter__ will return an
object that does.

This is technically an iterator, because it implements both __iter__ and
__next__. Notice that it's an infinite sequence! Sequences like these are
the reason iterators are useful. Because iterators delay computation, we can
use a finite amount of memory to represent an infinitely long sequence.

Question 2: WWPD: Odds and Evens

So far, the __iter__ method of our iterators only returns self. What if we
have called next a few times and then want to start at the beginning?
Intuitively, we should create a new iterator that would start at the
beginning. However, our current iterator implementations won't allow that.

Consider the following OddNaturalsIterator and EvenNaturalsIterator. Which
implementation allows us to start a new iterator at the beginning?

Generators

A generator function returns a special type of iterator called a
generator object. Generator functions have yield statements within the
body of the function. Calling a generator function makes it return a generator
object rather than executing the body of the function.

The reason we say a generator object is a special type of iterator is that
it has all the properties of an iterator, meaning that:

Calling the __iter__ method makes a generator object return
itself without modifying its current state.

Calling the __next__ method makes a generator object compute and
return the next object in its sequence. If the sequence is
exhausted, StopIteration is raised.

Typically, a generator should not restart unless it's defined that way. But
calling the generator function returns a brand new generator object (like
calling __iter__ on an iterable object).

However, they do have some fundamental differences:

An iterator is a class with __next__ and __iter__ explicitly defined, but
a generator can be written as a mere function with a yield in it.

__next__ in an iterator uses return, but a generator uses yield.

A generator "remembers" its state for the next __next__ call. Therefore,

the first __next__ call works like this:

Enter the function, run until the line with yield.

Return the value in the yield statement, but remember the state of the
function for future __next__ calls.

And subsequent __next__ calls work like this:

Re-enter the function, start at the line after yield, and run until
the next yield statement.

Return the value in the yield statement, but remember the state of the
function for future __next__ calls.

When a generator runs to the end of the function, it raises StopIteration.

Another useful tool for generators is the yield from statement (introduced in
Python 3.3). yield from will yield all values from an iterator or iterable.

Question 4: WWPD: Generators

Use OK to test your knowledge with the following What would Python Display
questions:

i = n
while i > 1:
yield i
if i % 2 == 0:
i //= 2
else:
i = i * 3 + 1
yield i

Use OK to test your code:

python3 ok -q hailstone

Extra Questions

Question 8: Merge

Implement merge(s0, s1), which takes two iterables s0 and s1 whose
elements are ordered. merge yields elements from s0 and s1 in sorted
order, eliminating repetition. You may assume s0 and s1 themselves do not
contain repeats, and that none of the elements of either are None.
You may not assume that the iterables are finite; either may produce an infinite
stream of results.

You will probably find it helpful to use the two-argument version of the built-in
next function: next(s, v) is the same as next(s), except that instead of
raising StopIteration when s runs out of elements, it returns v.

Question 9: Remainder Generator

Like functions,
generators can also be higher-order. For this
problem, we will be writing remainders_generator, which yields a
series of generator objects.

remainders_generator takes in an integer m, and yields m different
generators. The first generator is a generator of multiples of m, i.e. numbers
where the remainder is 0. The second, a generator of natural numbers with
remainder 1 when divided by m. The last generator yield natural numbers with
remainder m - 1 when divided by m.

Note that if you have implemented this correctly, each of the
generators yielded by remainder_generator will be infinite - you
can keep calling next on them forever without running into a
StopIteration exception.

Hint: Consider defining an inner generator function. What arguments
should it take in? Where should you call it?

Use OK to test your code:

python3 ok -q remainders_generator

Question 10: Zip generator

For this problem, we will be writing zip_generator, which yields a
series of lists, each containing the nth items of each iterable.
It should stop when the smallest iterable runs out of elements.

def zip(*iterables):
"""
Takes in any number of iterables and zips them together.
Returns a generator that outputs a series of lists, each
containing the nth items of each iterable.
>>> z = zip([1, 2, 3], [4, 5, 6], [7, 8])
>>> for i in z:
... print(i)
...
[1, 4, 7]
[2, 5, 8]
"""

"*** YOUR CODE HERE ***"

iterators = [iter(iterable) for iterable in iterables]
while True:
yield [next(iterator) for iterator in iterators]