Control Flow
Three concepts: statements and blocks, non-local control flow, and conditions.
The interesting one is the second. break and return have to escape from arbitrarily deep
nesting, and in a tree-walking interpreter — where the "program counter" is the Rust call stack —
there is no obvious way to do that. How you solve it is a real design decision with four real
options, and the one you pick shapes the whole evaluator.
Concept 1: Statements, Blocks, and Sequencing
1. Concept
A statement is executed for its effect and produces no value. A block is a sequence of statements plus a scope.
2. Problem
Expressions compose by nesting; statements compose by sequencing. An evaluator therefore needs two different traversal shapes, and a block needs to do something an expression never does: open a scope, run its contents, and close the scope even if something goes wrong in the middle.
3. Mental model
An expression returns. A statement happens. A block is a statement that owns a scope, and the scope must be closed on every exit path — normal,
break,return, or error. Four exits, one cleanup.
4. Implementation
#![allow(unused)] fn main() { fn eval_block(&mut self, b: &Block) -> Result<Flow> { let _scope = self.env.scope_guard(); // Drop pops the scope on EVERY exit for stmt in &b.stmts { match self.eval_stmt(stmt)? { Flow::Normal => {} other => return Ok(other), // break/return propagate upward } } Ok(Flow::Normal) } }
scope_guard returns an RAII guard whose Drop calls pop_scope. This is the third appearance
of the same pattern in the curriculum — the parser's depth guard, the environment's scope guard, and
(in Section 3) the VM's frame guard. When a function has a push/pop pair and a ? between them, the
pop belongs in a Drop. Learn it once.
5–7. Alternatives, decision, tradeoffs
| Option | Notes |
|---|---|
A. Explicit pop_scope() at the end | Wrong on every ? and every early return. Do not. |
| B. RAII guard (ours) | Correct by construction. Costs a type and a lifetime. |
| C. Push/pop around the call site | Moves the problem to every caller; more places to forget |
Decision: B. The rule to internalize: in Rust, cleanup that must happen on all paths goes in
Drop, not at the end of the function.?makes "the end of the function" a lie.
8. Production concerns
- A leaked scope corrupts everything after it. Variables from a block that returned early stay visible, so an unrelated later statement resolves the wrong name. The symptom appears far from the cause. The RAII guard is not tidiness; it is the fix.
- Empty blocks and empty programs must work.
do end,if x then end, and a zero-byte file are all valid. They are also the inputs a fuzzer finds first.
Concept 2: Non-Local Control Flow
1. Concept
break exits the innermost enclosing loop. return exits the enclosing function. Both may appear
arbitrarily deep inside nested blocks and ifs, and both must unwind past everything between.
2. Problem
while true do
if a then
do
if b then break end -- must exit the WHILE, past two blocks and two ifs
end
end
end
In the tree walker, eval_block is a Rust function calling eval_stmt calling eval_block... The
Rust break keyword breaks the Rust loop it is written in — which is the for stmt in &b.stmts
loop, four frames below the while. It is the wrong loop, and there is no way to name the right one
from inside eval_stmt.
This is the single most instructive bug available in Section 2, because the naive implementation
works for while true do break end and fails the moment there is a nested block.
3. Mental model
Every
eval_*function must be able to answer its caller with more than "done" or "error". It needs a third channel: "done, but you should stop too." That channel is a value returned up the stack, and every statement-evaluating function must forward it.
while ────────────────────────────────┐
│ eval_block(body) │ ← catches Flow::Break, stops looping
▼ │
block ──▶ if ──▶ block ──▶ if ──▶ break
│
Flow::Break ◀───────────────────────┘
...propagated up, unchanged, by every frame in between
4. Implementation
#![allow(unused)] fn main() { /// What a statement tells its caller. `Normal` is the common case; the other /// two mean "stop what you are doing and pass this along." pub enum Flow { Normal, Break, Return(Vec<Value>), // Vec, not Option: Lab 17 adds multiple returns } fn eval_stmt(&mut self, s: &Stmt) -> Result<Flow> { Ok(match s { Stmt::Break { .. } => Flow::Break, Stmt::Return { exprs, .. } => Flow::Return(self.eval_exprs(exprs)?), Stmt::While { cond, body, .. } => { while self.eval_expr(cond)?.is_truthy() { self.budget.tick()?; // §5's instruction budget lives here too match self.eval_block(body)? { Flow::Normal => {} Flow::Break => break, // ← ABSORBED here. Goes no further. r @ Flow::Return(_) => return Ok(r) // ← Return passes THROUGH the loop } } Flow::Normal } Stmt::If { arms, else_, .. } => { for (cond, block) in arms { if self.eval_expr(cond)?.is_truthy() { return self.eval_block(block); } } match else_ { Some(b) => self.eval_block(b)?, None => Flow::Normal } } // ... }) } }
The two lines that carry the whole concept are inside While: Flow::Break is absorbed (the
loop consumes it and continues normally afterwards), and Flow::Return is forwarded (the loop is
not the right handler). The function-call evaluator absorbs Flow::Return and turns it into a value.
Every construct decides which signals it absorbs and which it forwards, and that table is the
complete specification of non-local control flow:
| Construct | Absorbs | Forwards |
|---|---|---|
if / do block | nothing | Break, Return |
while / for | Break | Return |
| function call | Return | — (Break outside a loop is a compile error) |
5. Alternatives
| Option | How | Used by |
|---|---|---|
A. A Flow return value (ours) | Every eval_* returns Result<Flow> | jlox uses a variant of this; the clearest for teaching |
B. Abuse Result's error channel | Err(Control::Break) alongside Err(Runtime), using ? to propagate for free | Crafting Interpreters' jlox uses Java exceptions this way; concise, and it makes "did I forget to forward it?" a non-question |
C. Unwinding via panic! + catch_unwind | Real unwinding | Fast to write, unusable: panic may abort, cannot cross FFI, and is off-limits for a library |
| D. Compile it away | There is no non-local control flow at run time; break is a jump | The VM, in Section 3. Lua, CPython, everything compiled |
6. Decision
A, and note loudly that D is what Section 3 does.
Option B is genuinely tempting and slightly less code, because ? propagates for free and you
cannot forget to forward. It is rejected for one reason: it makes control flow indistinguishable
from errors in the type signature, and Section 5 needs Result to mean exactly "an error the host
may see". A pcall implementation that accidentally catches a Break is a bug you would find in
week fifteen.
The real payoff is the comparison with D. In the VM:
while cond do body end 0000 <cond>
0004 JUMP_IF_FALSE 0016
0006 <body>
0014 JUMP 0000
break → .... JUMP 0016 ← just a jump
0016 ...
break is one instruction, and the entire Flow enum disappears. That is what "the compiler
resolves it at compile time" buys, and it is the same shape of win as names becoming slots. Two
different problems, one lesson.
7. Tradeoffs
| We gain | We lose |
|---|---|
| Control flow is explicit and visible in every signature | Every statement function returns Result<Flow> and must forward correctly |
Result keeps meaning "error" | More typing than option B |
| The VM comparison in §3 is vivid | A forgotten forward is a silent bug — so test nested cases |
8. Production concerns
breakoutside a loop must be a compile error, not a runtime one. The parser or a validation pass tracks loop depth. If it reaches the evaluator, you get aFlow::Breakpropagating out of the program, and whatever you do with it will be arbitrary.- Test nesting, always.
while true do do do break end end endandfor i=1,3 do while true do break end end(thebreakbelongs to thewhile). A test suite with only single-levelbreakpasses on a completely broken implementation. returnfrom the main chunk is legal in Lua and ends the chunk with a value. The top-level evaluator must absorbFlow::Return, not treat it as an error.- The instruction budget lives on the loop back-edge and the call, not on every statement. A
while true do endmust terminate, and that isbudget.tick()in theWhilearm above — put in Lab 6, four labs before Section 5 needs it, because retrofitting a budget into every loop construct later is exactly the kind of thing that gets one of them wrong.
9. References
- Lua's
lparser.c:breakstat, andgotostat/labelstat— in Lua 5.4,breakis implemented as agototo a hidden label, which is a nice piece of unification.patchlistinlcode.cis how the jump target gets filled in later. - Crafting Interpreters chapter 9 (jlox uses exceptions — option B) and chapter 23 (clox uses jumps — option D). Reading both is the fastest way to feel the difference.
- CPython's
Python/ceval.cand thedisoutput for a loop withbreak. Compare with your own disassembly after Lab 10.
Concept 3: Conditions, Truthiness, and Short-Circuit
1–3. Concept, problem, mental model
and and or are not ordinary binary operators, because they do not evaluate both operands, and
they do not return booleans.
nil or 5 --> 5 NOT true
0 and "x" --> "x" because 0 is TRUTHY
false and boom() --> false boom() is never called
a and bmeans: evaluatea; if it is falsy, that is the result; otherwise the result isb.a or bis the mirror image. They are conditional expressions in disguise, which is why they cannot be handled bybinary_opalongside+.
4. Implementation
#![allow(unused)] fn main() { // In eval_expr, BEFORE the generic binary path — these must not evaluate rhs eagerly. Expr::Binary { op: BinOp::And, lhs, rhs, .. } => { let l = self.eval_expr(lhs)?; if l.is_truthy() { self.eval_expr(rhs)? } else { l } // ← returns the OPERAND } Expr::Binary { op: BinOp::Or, lhs, rhs, .. } => { let l = self.eval_expr(lhs)?; if l.is_truthy() { l } else { self.eval_expr(rhs)? } } }
Warning: If
and/orreach the genericBinaryarm — the one that evaluates both operands and then dispatches on the operator — short-circuiting is broken and you will not notice until a script doesx ~= nil and x.field, which will then crash onnil. Put these arms above the generic one and add a test with a side effect on the right-hand side.
5–7. Alternatives, decision, tradeoffs
| Option | Behavior of nil or 5 |
|---|---|
| A. Return the operand (ours, Lua, JS, Python) | 5 |
| B. Return a boolean (C, Java, Rust) | true |
Option A makes local x = opt or default idiomatic, which is most of why dynamic languages do it.
It also means and/or have no single result type, which is fine here and would be impossible in
a statically-typed language — hence C's and Rust's choice.
The idiom cond and a or b is Lua's ternary, and it has a known bug: if a is false or
nil, it yields b regardless of cond. Ember inherits it. Put it in docs/limitations.md and,
if you like, add a real conditional expression as a challenge — that is a language design change,
so it needs an ADR.
8. Production concerns
- Truthiness is a spec decision people assume.
0is truthy in Lua and falsy in Python. A policy written by someone whose instincts come from Python will containif article.boost thenwhereboostis0, and it will behave differently than they expect. Ember cannot fix that; it can document it prominently and it can make--typesavailable so the author can check. - Short-circuit is a correctness feature, not an optimization.
t ~= nil and t.xdepends on it. Never "optimize" it into eager evaluation.
9. References
- Lua 5.4 Reference Manual §3.4.5 ("Logical Operators") — three sentences, worth reading verbatim.
- Section 3's code generation: compiling
and/orwithout a temporary is a classic jump-patching exercise, and Lua does it withluaK_goiftrue/luaK_goiffalseinlcode.c.
The Trace: break through three frames
$ ember run --trace-flow -e '
local n = 0
while true do
do
if n > 2 then break end
end
n = n + 1
end
return n'
eval While → enter
eval Block(1) → enter [while body]
eval Block(2) → enter [do ... end]
eval If → cond false → Flow::Normal
eval Block(2) ← Flow::Normal
eval Block(1) ← Flow::Normal
... (three iterations) ...
eval Block(1) → enter
eval Block(2) → enter
eval If → cond TRUE → eval Block → Flow::Break
eval Block(2) ← Flow::Break ← forwarded, not absorbed
eval Block(1) ← Flow::Break ← forwarded, not absorbed
eval While ← Flow::Break ABSORBED, loop exits, returns Flow::Normal
3
Read the two ← Flow::Break lines: those are the frames that a naive Rust break could never have
escaped. Then compare with the disassembly of the same program after
Lab 10 — where the entire trace above
becomes JUMP 0016.
Things to Notice
- The
Flowenum exists only because the tree walker has no program counter. Give it one — that is what an instruction pointer is — and the whole mechanism collapses into a jump. This is the clearest single argument for bytecode in the curriculum. - "Absorbs or forwards" is the complete specification of non-local control flow, and it is a
three-row table. Write that table in
docs/learning/06-vm.md; it is what the compiler implements as jump targets. - The RAII-guard pattern appears a third time here. Parser depth, evaluator scope, VM frames.
In Rust, any push/pop straddling a
?is aDrop. and/orare conditionals, not operators, and they must be handled before the generic binary path.- The budget check belongs on the loop back-edge, put there four labs before anything needs it. Retrofitting a budget into control flow is how you end up with one construct that can loop forever.
Validation / Self-check
- Why can a Rust
breaknot implement Ember'sbreak? Give the smallest program that exposes it. - Write the absorbs/forwards table for
if,while, and function call. - Give the four ways to implement non-local control flow, and say which the VM uses and why that makes the mechanism disappear.
- Why is option B (using
Result's error channel) rejected, given that it is less code? - Why must the
and/orarms come before the genericBinaryarm? What is the symptom if they do not? - What does
nil or 5return, and what does that imply about the "type" ofor? - What is the known bug in
cond and a or b, and what is Ember's response? - Where does the instruction budget check go, and why is it added in Lab 6 rather than Lab 23?
Next: Functions and Frames.