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

OptionNotes
A. Explicit pop_scope() at the endWrong 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 siteMoves 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:

ConstructAbsorbsForwards
if / do blocknothingBreak, Return
while / forBreakReturn
function callReturn— (Break outside a loop is a compile error)

5. Alternatives

OptionHowUsed 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 channelErr(Control::Break) alongside Err(Runtime), using ? to propagate for freeCrafting 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_unwindReal unwindingFast to write, unusable: panic may abort, cannot cross FFI, and is off-limits for a library
D. Compile it awayThere is no non-local control flow at run time; break is a jumpThe 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 gainWe lose
Control flow is explicit and visible in every signatureEvery statement function returns Result<Flow> and must forward correctly
Result keeps meaning "error"More typing than option B
The VM comparison in §3 is vividA forgotten forward is a silent bug — so test nested cases

8. Production concerns

  • break outside 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 a Flow::Break propagating out of the program, and whatever you do with it will be arbitrary.
  • Test nesting, always. while true do do do break end end end and for i=1,3 do while true do break end end (the break belongs to the while). A test suite with only single-level break passes on a completely broken implementation.
  • return from the main chunk is legal in Lua and ends the chunk with a value. The top-level evaluator must absorb Flow::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 end must terminate, and that is budget.tick() in the While arm 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, and gotostat/labelstat — in Lua 5.4, break is implemented as a goto to a hidden label, which is a nice piece of unification. patchlist in lcode.c is 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.c and the dis output for a loop with break. 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 b means: evaluate a; if it is falsy, that is the result; otherwise the result is b. a or b is the mirror image. They are conditional expressions in disguise, which is why they cannot be handled by binary_op alongside +.

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/or reach the generic Binary arm — the one that evaluates both operands and then dispatches on the operator — short-circuiting is broken and you will not notice until a script does x ~= nil and x.field, which will then crash on nil. 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

OptionBehavior 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. 0 is truthy in Lua and falsy in Python. A policy written by someone whose instincts come from Python will contain if article.boost then where boost is 0, and it will behave differently than they expect. Ember cannot fix that; it can document it prominently and it can make --types available so the author can check.
  • Short-circuit is a correctness feature, not an optimization. t ~= nil and t.x depends 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/or without a temporary is a classic jump-patching exercise, and Lua does it with luaK_goiftrue/luaK_goiffalse in lcode.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 Flow enum 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 a Drop.
  • and/or are 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

  1. Why can a Rust break not implement Ember's break? Give the smallest program that exposes it.
  2. Write the absorbs/forwards table for if, while, and function call.
  3. Give the four ways to implement non-local control flow, and say which the VM uses and why that makes the mechanism disappear.
  4. Why is option B (using Result's error channel) rejected, given that it is less code?
  5. Why must the and/or arms come before the generic Binary arm? What is the symptom if they do not?
  6. What does nil or 5 return, and what does that imply about the "type" of or?
  7. What is the known bug in cond and a or b, and what is Ember's response?
  8. Where does the instruction budget check go, and why is it added in Lab 6 rather than Lab 23?

Next: Functions and Frames.