A generator function is, underneath the yield keyword, a state machine:
CPython's compiler captures the function's local variables and "where it
paused" into an object (a frame) that can be resumed later -- literally what
Objects/genobject.c implements in C. This exercise builds the state-machine
equivalent of one specific generator, by hand, with no yield at all:
def handshake():
name = yield "AWAITING_NAME"
yield f"HELLO {name}"
ack = yield "AWAITING_ACK"
return "DONE" if ack == "ACK" else "RETRY"
Calling .send(value) on a real generator resumes it -- the paused yield
expression evaluates to value -- and runs until the NEXT yield. When the
generator instead hits a return, .send() raises StopIteration whose
.value attribute holds the returned value -- this is genuinely how a
generator communicates its final result to whatever's driving it.
Implement HandshakeStateMachine.send(value) so it reproduces this exact
sequence of behaviors, without a yield anywhere in your code.
The missing link: "state" has to become a real, explicit value
A single accumulator variable isn't a state machine -- it's the same
computation every time you call it. A REAL generator's power is that
different calls to .send() can do completely different things, because the
generator is paused at a different yield each time. Since there's no
yield keyword to pause at here, something has to explicitly track "which of
the four points in handshake() am I at" -- that's self._state, and it's
doing the exact job CPython's real generator frame does with a saved
bytecode instruction pointer.
Example
sm = HandshakeStateMachine()
sm.send(None) # -> "AWAITING_NAME" (must prime with None first, like next(gen))
sm.send("Ada") # -> "HELLO Ada"
sm.send(None) # -> "AWAITING_ACK"
sm.send("ACK") # raises StopIteration("DONE")
Constraints
- The very first call must be
send(None)-- exactly like a fresh generator, which must be primed withnext(gen)(equivalent tosend(None)) before it will accept a real value. - On the 4th call, raise
StopIterationwith.valueset to"DONE"or"RETRY"depending on whethervalue == "ACK"-- don't justreturna plain value, since nothing would ever see it. - Your class's behavior must be indistinguishable, call for call, from driving the real generator above with the same sequence of values -- the test checks this directly against Python's own generator as ground truth.
Real-world grounding
- CPython's generator object implementation, including how
return valueinside a generator becomesStopIteration(value)outside it:Objects/genobject.c
Real generator frames also need to save/restore the full local variable set,
the exact bytecode instruction pointer, and exception state -- self._state
(plus whatever you stash on self between calls, like the captured name) is
a much smaller version of exactly that same idea: state that persists across
calls because it's stored on an object instead of a call stack that unwinds
when a function returns.