A few months ago the Steering Council asked me to be the PEP-delegate for this PEP and I accepted. Since then I’ve been thinking about the proposed semantics & the syntax, talking to more than a few Python core developers including Peter, and to a few Python developers whose opinions I trust.
TL;DR
I propose to accept the PEP with the caveat of changing the syntax to yield from (away from async yield from).
Chain of thoughts
The initial version of the PEP proposed allowing both yield from and async yield from. While it seemed attractive on the surface, I convinced Peter that it’s not something we should pursue. If we were to allow that, it would be possible for an asynchronous call await generator.asend(...) to suspend in a synchronous frame of a synchronous generator, which would make things quite confusing to the user. While implementing this is possible in principle, the costs are going to be quite high and our generators machinery is already one of the gnarliest parts of the interpreter.
With the option of delegating to synchronous generators gone we’re left with just one key decision to make: yield from async_gen() or async yield from async_gen(). Guido loosely compared this to deciding what 0^0 should be: it really depends on the point of view.
People who advocate for async yield from want explicitness:
- It immediately signals to the user what the statement does and what is on the right hand side of it (an async iterator)
yield from has just one meaning and only one base type for rhs
- We have
async with as a counterpart for with, async for for for, and async if for if, it would be quite logical to have async yield from as a counterpart for yield from
I’d like to challenge the above, but bear in mind, that this isn’t precise science. It ultimately boils down to taste.
Regarding 1 – lack of clear signal that this is asynchronous yield from. We already have a loud signal to the user: the code is either nested inside def or async def. One might then argue “but what if the function body is long and you only see part of it” – well, then the reader still has an opportunity to be confused: the visible part of the code might not contain a single await or yield, so there could be no indication that this piece of code is inside a coroutine function or inside a generator or inside an asynchronous generator. Since a single yield can change the meaning of a multi-hundred-long function completely, I argue that yield from does not need to have async prefix to be more explicit in asynchronous generators.
Regarding 2 – one meaning for yield from. We already compile and work with yield very differently depending on when it’s inside a synchronous or an asynchronous generator. I don’t see why yield from is much more special than yield.
As for different accepted type for the operand of yield from – we’ll have different code paths for async vs. sync implementation anyway. And for the user – with the help of IDEs, type checkers, linters, and increasingly more capable LLMs this argument is moot.
Regarding 3 – ‘async yield from’ is a counterpart for ‘yield from’. I get this one (and all of the above, fwiw!). But but the verbosity of async yield from, the inconvenience of typing it on a keyboard, the space it will take in a language where we don’t encourage soft line breaks, all of these arguments make yield from much more compelling.
Bottom line
We do not have async yield, we have just yield. It’s the signature of the function that signals what this body of code is and how it can be called (nobody is surprised that you can’t list(agen()).
I argue that the same exact reasoning can be applied to yield from. For the user it will be just as clear, that inside async def the argument of yield from must be an asynchronous generator (or iterator, more generally).
I strongly recommend we go with yield from. After years of writing async/await code myself and observing others, I’m confident that the extra verbosity of async prefix is not needed.