Search code examples
pythongeneratorcoroutine

Send values to Python coroutine without handling StopIteration


Given a Python coroutine:

def coroutine():
     score = 0
     for _ in range(3):
          score = yield score + 1

I'd like to use it in a simple loop like this:

cs = coroutine()
for c in cs:
     print(c)
     cs.send(c + 1)

... which I would expect to print

1
3
5

But actually, I get an exception on the line yield score + 1:

 TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'

I can get it to work if I call next manually:

c = next(cs)
while True:
    print(c)
    try:
        c = cs.send(c + 1)
    except StopIteration:
        break

But I don't like that I need to use try/except, given that generators are usually so elegant.

So, is there any way to use a finite coroutine like this without explicitly handling StopIteration? I'm happy to change both the generator and the way I'm iterating over it.

Second Attempt

Martijn points out that both the for loop and my call to send advance the iterator. Fair enough. Why, then, can't I get around that with two yield statements in the coroutine's loop?

def coroutine():
    score = 0
    for _ in range(3):
        yield score
        score = yield score + 1

cs = coroutine()
for c in cs:
    print(c)
    cs.send(c + 1)

If I try that, I get the same error but on the send line.

0
None
Traceback (most recent call last):
  File "../coroutine_test.py", line 10, in <module>
    cs.send(c + 1)
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'

Solution

  • I'll take a stab at your second attempt. First, let coroutine be defined as:

    def coroutine():
        score = 0
        for _ in range(3):
            yield
            score = yield score + 1
    

    This function will output your 1, 3, 5 as in the original question.

    Now, let's convert the for loop into a while loop.

    # for loop
    for c in cs:
        print(c)
        cs.send(c + 1)
    
    # while loop
    while True:
        try:
            c = cs.send(None)
            print(c)
            cs.send(c + 1)
        except StopIteration:
            break
    

    Now, we can get this while loop working using the following if we precede it with a next(cs). In total:

    cs = coroutine()
    next(cs)
    while True:
        try:
            c = cs.send(None)
            print(c)
            cs.send(c + 1)
        except StopIteration:
            break
    # Output: 1, 3, 5
    

    When we try to convert this back into a for loop, we have the relatively simple code:

    cs = coroutine()
    next(cs)
    for c in cs:
        print(c)
        cs.send(c + 1)
    

    And this outputs the 1, 3, 5 as you wanted. The issue is that in the last iteration of the for loop, cs is already exhausted, but send is called again. So, how do we get another yield out of the generator? Let's add one to the end...

    def coroutine():
        score = 0
        for _ in range(3):
            yield
            score = yield score + 1
        yield
    
    cs = coroutine()
    next(cs)
    for c in cs:
        print(c)
        cs.send(c + 1)
    # Output: 1, 3, 5
    

    This final example iterates as intended without a StopIteration exception.

    Now, if we take a step back, this can all be better written as:

    def coroutine():
        score = 0
        for _ in range(3):
            score = yield score + 1
            yield # the only difference from your first attempt
    
    cs = coroutine()
    for c in cs:
        print(c)
        cs.send(c + 1)
    # Output: 1, 3, 5
    

    Notice how the yield moved, and the next(cs) was removed.