Iterators Generators Coroutines#

ref1

Iterators#

  • An object that follows python iterator protocol.

    • __next__()

    • __iter__()

  • Use Case Scenerio > Let us take a scenario in which we have a list of 100 elements. We continually ask the user to input a number and whenever they enter an even number, we print the next element of the list. Now, this cannot be done using a for loop as we cannot predict when the user will enter an even number. Having no order or sequence in the inputs stops us from using the for loop to iterate the list in our program. Iterators can be a handy tool to access the elements of iterables in such situations.

[ ]:
num_lst = range(0,101)
num_lst_iter = iter(num_lst)
index=0
# ask user maximum 100 times. Just for demonstrating.
while index<=100:
    try:
        num = int(input())
        if num%2==0:
            print(num_lst_iter.__next__())
        if num == -1:
            print('Bye')
            break
        else:
            pass
    except StopIteration as e:
        print("Please enter a number")
        break

Custom Iterators#

[9]:
class Foo:
    def __init__(self, collection):
        self.collection = collection
        self.index = 0

    def __iter__(self):
        """
        we return self so the 'iterator object'
        is the Foo class instance itself,

        but we could have returned a new instance
        of a completely different class, so long as
        that other class had __next__ defined on it.
        """
        return self

    def __next__(self):
        """
        this method is handling state and informing
        the container of the iterator where we are
        currently pointing to within our data collection.
        """
        if self.index > len(self.collection)-1:
            raise StopIteration

        value = self.collection[self.index]
        self.index += 1

        return value

# we are now able to loop over our custom Foo class!
for element in Foo(("a", "b", "c")):
    print(element)
a
b
c

Generators#

[ ]:
def generator(limit):
    for i in range(limit):
        yield "foo"

g = generator(3)
# print(next(g))
# print(next(g))
# print(next(g))
# print(next(g))

for v in generator(3):
    print(v)

Generator Use Case#

  • Implement a custom iteration pattern that’s different than the usual built-in functions (e.g., range(), reversed(), etc.).

[7]:
def floatRange(start,stop,increment):
    while start < stop:
        yield start
        start += increment
for i in floatRange(0.2,2,0.9):
    print(i)
0.2
1.1

Discussion > The mere presence of the yield statement in a function turns it into a generator. Unlike a normal function, a generator only runs in response to iteration. > The key feature is that a generator function only runs in response to “next” operations carried out in iteration. Once a generator function returns, iteration stops. However, the for statement that’s usually used to iterate takes care of these details, so you don’t normally need to worry about them.

[6]:
def fib(n):
    a,b = 0,1
    for i in range(n):
        yield a
        a,b = b,a+b
for i in fib(10):
    print(i)
0
1
1
2
3
5
8
13
21
34
[ ]:
# The key feature is that a generator function only runs in response to “next” operations carried out in iteration. Once a generator function returns, iteration stops. However, the for statement that’s usually used to iterate takes care of these details, so you don’t normally need to worry about them.

Coroutines#

  • Because coroutines can pause and resume execution context, they’re well suited to conconcurrent processing, as they enable the program to determine when to ‘context switch’ from one point of the code to another.

  • Generators use the yield keyword to return a value at some point in time within a function, but with coroutines the yield directive can also be used on the right-hand side of an = operator to signify it will accept a value at that point in time.

[12]:
def foo():
    """
    notice we use yield in both the
    traditional generator sense and
    also in the coroutine sense.
    """
    msg = yield  # coroutine feature
    yield msg    # generator feature

coro = foo()

# because a coroutine is a generator
# we need to advance the returned generator
# to the first yield within the generator function
# If this is commented, it will raise the following error
# TypeError: can't send non-None value to a just-started generator
next(coro)

# the .send() syntax is specific to a coroutine
# this sends "bar" to the first yield
# so the msg variable will be assigned that value
result = coro.send("bar")

# because our coroutine also yields the msg variable
# it means we can print that value
print(result)  # bar
bar

Below is an example of a coroutine using yield to return a value to the caller prior to the value received via a caller using the .send() method:

[15]:
def foo():
    msg = yield "beep"
    yield msg

coro = foo()

print(next(coro))  # beep

result = coro.send("bar")

print(result)  # bar
beep
bar