Generators #
What is a generator? #
Generators can be used to create your own iterators with custom values. This is useful when you want to create an iterator with unpredictable results (for example numbers with irregular intervals, possibly based on different arguments in a given function).
The way you define a generator function is through the yield
keyword (instead of return
in a function). In this sense, a generator is a type of iterator that yields results from a generator function.
def numbers():
num = 0
while num < 3:
yield num
num += 1
Calling the generator function will return a generator (which itself is an iterator, which means the next
function works similarly to how it does for iterators):
num_iter = numbers()
next(num_iter) # 0
next(num_iter) # 1
next(num_iter) # 2
next(num_iter) # StopIterator
How generators work #
When the function called, Python immediately returns an iterator without running any lines of code in the function itself.
When next
is called on the iterator, it starts executing the body of the function until it hits the first yield
statement. Once it hits the yield
statement, the function pauses execution (not to be confused with return
where the function completely stops executing), and when next
is called again, the function will continue executing from where it paused. If it doesn’t find any yield
statements from there, it raises a StopIteration
exception.
def printer(n):
print("we have started")
while n < 3:
yield n
n = n + 1
printing = printer(0)
next(printing)
# we have started
# 0
next(printing)
# 1
next(printing)
# 2
next(printing)
# StopIteration
We can use for loops over generators like we can over iterators:
def printer(n):
print("we have started")
while n < 3:
yield n
n = n + 1
for item in printer(0):
print(item)
# we have started (goes through the function body as normal)
# 0
# 1
# 2
Why use generators? #
For some functions, using generators can save a lot of computing time. This is because they only generate the next item when needed (instead of having to find every solution in a recursive solution for example, you can just compute one element as a time (as you need it) in certain situations).
Example: Fibonacci Numbers #
Instead of an iterative solution
def fib(n):
prev = 0
curr = 1
iterations = 1
while iterations < n:
prev, curr = curr, prev + curr
iterations += 1
return curr
we can use a generator for this:
def gen_fib():
prev = 0
curr = 1
while True:
yield prev
prev, curr = curr, prev + curr
This will allow you to generate Fibonacci numbers as needed.
Yield From #
The yield from
keyword allows you to yield from an iterable/iterator one at a time:
def lst_yielder():
lst = [0, 1, 2, 3]
yield from lst
lst = lst_yielder()
next(lst) # 0
next(lst) # 1
next(lst) # 2
next(lst) # 3
However, one of the very cool things about yield from
is that you can yield the results of another generator function, which makes it act sort of like recursion in a way.
def countdown(n):
if k > 0:
yield n
yield from countdown(n - 1)
When the function hits yield from countdown(n - 1)
, it makes another instance of countdown
, with a lower value of n
, which will then pause when it reaches yield n
in the function body.
Generator Functions with Returns #
Using a return
keyword in a generator function does not work exactly as intended.
Remember how earlier it was mentioned that if a function was unable to find another yield
statement, it would cause a StopIteration
error? Well, what a return
statement does traditionally is exit out of a function, which in this case means that no further yield
statement will be found. As a result, a StopIteration
error will occur.
def f(x):
yield x
yield x + 1
return
yield x + 2
list(f(1)) # [1, 2]
With Return Values #
def f(x):
yield x
yield x + 1
return x + 1
yield x + 2
list(f(1)) # [1, 2]
Notice how nothing changed? Return values can’t be yielded in this way. There is a way to yield these return values, but it is not in the scope of CS61A.
Count Partitions (Yield) #
def partitions(n, m):
"""List partitions.
>>> for p in partitions(6, 4): print(p)
4 + 2
4 + 1 + 1
3 + 3
3 + 2 + 1
3 + 1 + 1 + 1
2 + 2 + 2
2 + 2 + 1 + 1
2 + 1 + 1 + 1 + 1
1 + 1 + 1 + 1 + 1 + 1
"""
if n < 0 or m == 0:
return
else:
if n == m:
yield str(m)
for p in partitions(n - m, m):
yield f"{m} + {p}"
yield from partitions(n, m - 1)
This one is a bit difficult to understand. Let’s break it down.
The base case n < 0 or m == 0
has a return
statement in it. What this means is that if we ever get to the point where either of these cases happen, a StopIteration
error will occur, which in this program, means that specific combination will not be yielded.
In the case where n == m
, it means that there will only be one possible combination to make up that partition, so that specific partition size will be yielded.
Afterwards, we use a for loop to deal with the case where we do use m
(because using yield from
does not allow you to change the values that you want to yield), then just yield from the case where m
isn’t used and it decreases by 1.