Understanding the pause button: frames, state, and bidirectional communication
In Part 1, you learned:
- โ Generators produce values one at a time (lazy)
- โ
The
yieldkeyword pauses the function - โ Generators use constant memory (112 bytes)
- โ
Use
forloops ornext()to get values
If you haven't reviewed Part 1 yet, please refer to it here.
Now let's answer the big question: How does Python remember where it paused?
The Big Question: Where is the State Saved?
When a generator pauses at yield, it needs to remember:
- ๐ Where it was in the code (which line?)
- ๐งฎ All local variables (their current values)
- ๐ The call stack (in case there were nested function calls)
Where does Python store all this?
Answer: In a special object called a Generator Frame.
What Happens When You Call a Generator Function
Let's trace through this step-by-step:
def counter(start, end):
current = start
while current < end:
value = current ** 2
yield value
current += 1
# Step 1: Call the function
gen = counter(0, 3)
What just happened?
โ WRONG: The function ran and calculated squares
โ
CORRECT: Python created a "Generator Object" and did NOTHING else
The Generator Object
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Generator Object (112 bytes) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ gi_frame: <Frame object> โ โ WHERE STATE IS SAVED
โ gi_code: <counter function bytecode> โ โ THE CODE TO RUN
โ gi_running: False โ โ IS IT RUNNING NOW?
โ gi_yieldfrom: None โ โ For yield from
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The most important part: gi_frame - this is where all the magic happens!
Inside the Frame: Where Variables Live
The frame (gi_frame) contains:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Generator Frame โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ f_locals: {} โ โ Local variables
โ f_lasti: 0 โ โ Instruction pointer
โ f_valuestack: [] โ โ Expression stack
โ f_back: None โ โ Caller's frame
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Let's see what happens when we call next():
gen = counter(0, 3)
# FIRST next()
print(next(gen)) # 0
Frame BEFORE first next():
f_locals: {} (empty - hasn't run yet!)
f_lasti: 0 (start of function)
Frame AFTER first next() (paused at yield):
f_locals: {
'start': 0,
'end': 3,
'current': 1, โ Updated!
'value': 0
}
f_lasti: 58 โ Points to instruction AFTER yield
Frame AFTER second next():
f_locals: {
'start': 0,
'end': 3,
'current': 2, โ Updated again!
'value': 1
}
f_lasti: 58 โ Same yield point
See the magic? Python saves everything and resumes exactly where it left off!
The Execution Lifecycle (States)
A generator goes through different states:
โโโโโโโโโโโโโโโโ
โ GEN_CREATED โ โ Just created, hasn't run yet
โโโโโโโโโโโโโโโโ
โ
โ next() or send()
โผ
โโโโโโโโโโโโโโโโ
โ GEN_RUNNING โ โ Currently executing code
โโโโโโโโโโโโโโโโ
โ
โ hit yield
โผ
โโโโโโโโโโโโโโโโ
โGEN_SUSPENDED โ โ โธ๏ธ Paused, state saved
โโโโโโโโโโโโโโโโ
โ
โโโโโ next() โโโโโถ (loop back to GEN_RUNNING)
โ
โโโโโ StopIteration or close() โโโโโถ GEN_CLOSED
The Four Powerful Methods
Generators aren't just for reading values! They support four methods for advanced control:
1. next() - Get Next Value โถ๏ธ
You already know this one!
def simple_gen():
yield 1
yield 2
yield 3
gen = simple_gen()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # 3
print(next(gen)) # StopIteration Error!
2. send(value) - Send Data INTO the Generator ๐จ
This is where it gets interesting! You can send values into a generator while it's running.
def echo():
while True:
# The yield can RECEIVE a value!
received = yield
print(f"You sent: {received}")
gen = echo()
next(gen) # โ ๏ธ MUST prime first! (advance to first yield)
gen.send("Hello") # You sent: Hello
gen.send("World") # You sent: World
gen.send(42) # You sent: 42
How does send() work?
Generator code: received = yield
โ
โ
gen.send("Hello") โโโโโโโโโโโโโ
The yield expression RECEIVES the value "Hello"
Then: received = "Hello"
Real Example: Running Average Calculator
def running_average():
total = 0
count = 0
average = None
while True:
# Yield the current average, receive the next number
number = yield average
if number is None:
break
total += number
count += 1
average = total / count
# Usage
avg = running_average()
next(avg) # Prime (get to first yield)
print(avg.send(10)) # 10.0 (running avg: 10)
print(avg.send(20)) # 15.0 (running avg: 15)
print(avg.send(30)) # 20.0 (running avg: 20)
print(avg.send(40)) # 25.0 (running avg: 25)
Step-by-step flow:
1. next(avg)
โโ Start function
โโ Set total=0, count=0, average=None
โโ Hit: number = yield average
โโ Yield None (first average)
โโ โธ๏ธ PAUSE
2. avg.send(10)
โโ Resume: number = yield average
โโ number receives 10
โโ total = 10, count = 1
โโ average = 10.0
โโ Loop back to: number = yield average
โโ Yield 10.0
โโ โธ๏ธ PAUSE
3. avg.send(20)
โโ Resume: number = yield average
โโ number receives 20
โโ total = 30, count = 2
โโ average = 15.0
โโ Yield 15.0
โโ โธ๏ธ PAUSE
3. close() - Stop the Generator and Cleanup ๐
When you're done with a generator, you should close it to free resources:
def file_reader(filename):
print(f"Opening {filename}")
f = open(filename, 'r')
try:
for line in f:
yield line.strip()
except GeneratorExit:
# This runs when close() is called!
print("Closing file...")
f.close()
raise # Must re-raise or return
# Usage
reader = file_reader('data.txt')
print(next(reader)) # First line
print(next(reader)) # Second line
reader.close() # Triggers cleanup!
# Output: Closing file...
What close() does:
- Raises
GeneratorExitexception inside the generator - Generator's
except GeneratorExitblock runs - Cleanup code executes
- Generator marked as
GEN_CLOSED
4. throw(exception) - Inject Errors ๐ฅ
You can raise an exception inside the generator from outside:
def error_handler():
while True:
try:
value = yield
print(f"Processing: {value}")
except ValueError as e:
print(f"Caught error: {e}")
# Generator continues running!
gen = error_handler()
next(gen) # Prime
gen.send(10) # Processing: 10
gen.send(20) # Processing: 20
# Inject an error from outside!
gen.throw(ValueError, "Bad value!")
# Output: Caught error: Bad value!
gen.send(30) # Processing: 30 (still works!)
Use case: Error handling in data pipelines
def data_ ():
while True:
try:
data = yield
if data < 0:
raise ValueError("Negative values not allowed")
result = process(data)
print(f"Result: {result}")
except ValueError as e:
print(f"Skipping bad data: {e}")
processor = data_processor()
next(processor)
processor.send(10) # Result: ...
processor.send(-5) # Skipping bad data: Negative values not allowed
processor.send(20) # Result: ... (continues!)
Method Comparison Table
| Method | What It Does | Returns | Use When |
|---|---|---|---|
next(gen) |
Get next value | Next yielded value | Basic iteration |
gen.send(val) |
Send value into generator | Next yielded value | Two-way communication |
gen.throw(exc) |
Raise exception inside | Next yield (if handled) | Error injection |
gen.close() |
Stop generator | None | Cleanup resources |
Advanced: yield from - Generator Delegation
When you have generators calling other generators, yield from makes it seamless:
def generator1():
yield 1
yield 2
def generator2():
yield 3
yield 4
# Without yield from
def combined_old():
for value in generator1():
yield value
for value in generator2():
yield value
# With yield from (cleaner!)
def combined_new():
yield from generator1()
yield from generator2()
print(list(combined_new())) # [1, 2, 3, 4]
Why is yield from better?
It transparently forwards all operations:
- โ
next()calls - โ
send()values - โ
throw()exceptions - โ
close()signals
Practical Example: Multi-File Log Reader
def read_file(filename):
"""Read one file"""
print(f"Opening {filename}")
with open(filename) as f:
for line in f:
yield line.strip()
print(f"Closed {filename}")
def read_multiple_files(filenames):
"""Read multiple files sequentially"""
for filename in filenames:
yield from read_file(filename)
# Usage
files = ['log1.txt', 'log2.txt', 'log3.txt']
for line in read_multiple_files(files):
if 'ERROR' in line:
print(line)
Output:
Opening log1.txt
... errors from log1 ...
Closed log1.txt
Opening log2.txt
... errors from log2 ...
Closed log2.txt
Opening log3.txt
... errors from log3 ...
Closed log3.txt
Understanding the Pause and Resume
Let's visualize exactly what happens:
def demo():
print("A")
x = yield 1
print(f"B, x={x}")
y = yield 2
print(f"C, y={y}")
gen = demo()
Execution trace:
Call: gen = demo()
โ Creates generator object
โ NO CODE RUNS
โ State: GEN_CREATED
Call: next(gen)
โ Start execution
โ Execute: print("A") [Output: "A"]
โ Execute: yield 1
โ Return: 1
โ Save state (x not assigned yet!)
โ State: GEN_SUSPENDED
โ Returns: 1
Call: gen.send(100)
โ Resume execution
โ x = 100 (yield receives sent value)
โ Execute: print(f"B, x={x}") [Output: "B, x=100"]
โ Execute: yield 2
โ Return: 2
โ Save state (y not assigned yet!)
โ State: GEN_SUSPENDED
โ Returns: 2
Call: gen.send(200)
โ Resume execution
โ y = 200
โ Execute: print(f"C, y={y}") [Output: "C, y=200"]
โ End of function
โ Raise: StopIteration
โ State: GEN_CLOSED
Summary: What You Learned
๐ฏ Key Concepts:
-
Generator state is saved in a "frame" (
gi_frame) - Frames store local variables and instruction pointer
send()allows bidirectional communicationclose()triggers cleanup withGeneratorExitthrow()injects exceptions for error handlingyield fromdelegates to sub-generators
๐ Remember:
- Generators go through states: CREATED โ RUNNING โ SUSPENDED โ CLOSED
- The frame remembers everything between yields
- You can send data into generators, not just get data out
Happy coding! ๐
Top comments (0)