Control Flow: Conditionals & Loops
Writing a Compiler/Interpreter: Fundamentals
Chapter 6 · Control Flow: Conditionals & Loops
Every program run through this interpreter so far has executed every statement exactly once, top to bottom. This chapter gives Wisp the ability to skip statements (if/else) and repeat them (while, for) — and along the way, a genuinely distinct kind of expression node, Logical, whose entire reason to exist is that it must sometimes decline to evaluate its own right-hand side at all. That turns out to matter for more than just and/or — this chapter closes with a real bug, found while writing it, in code this course has been running since Chapter 4.
if/else: A Straightforward New Statement
Nothing surprising here — the condition is evaluated once, and Chapter 4's own is_truthy() decides which branch (if either) actually runs. The interesting part of if isn't the statement itself; it's that the branch that isn't taken is never evaluated at all — stmt.then_branch.accept(self) is a real Python call that either happens or doesn't, not a value that gets computed either way and discarded. That's the same idea this chapter's real subject, Logical, needs to get right at the expression level.
if (5 > 3) { print "yes"; } else { print "no"; } prints yes — the else branch's own print "no"; never executes.
while: The Same Idea, Repeated
The condition is re-evaluated fresh before every single iteration — not cached, not assumed. This is what makes a Wisp while loop able to see its own body's side effects: the body reassigns a variable via Assign (Chapter 5), and the very next condition check reads that new value straight out of the Environment.
var i = 1; var sum = 0; while (i <= 5) { sum = sum + i; i = i + 1; } print sum; prints 15.
for: Desugared Into while, Zero New Node Types
A C-style for (initializer; condition; increment) body doesn't need its own AST node or its own interpreter method at all. The parser can build it entirely out of pieces this chapter and Chapter 5 already have: an optional var declaration (or a bare expression statement) before a WhileStmt, whose own body is the original body followed by the increment, all wrapped in a BlockStmt so the loop variable stays scoped to the loop.
By the time this reaches the interpreter, it isn't a for loop anymore — it's a block containing a declaration and a while loop. Interpreter needs no new method whatsoever to run it; it was already capable of running exactly this shape.
for (var i = 0; i < 5; i = i + 1) { print i; } produces ['0', '1', '2', '3', '4']. A hand-written { var i = 0; while (i < 5) { print i; i = i + 1; } } — written out exactly the way the desugaring above constructs it — produces the exact same list. There is no test that could distinguish the two at runtime, because after parsing, they aren't two different things.
Logical: A New Node, Because Binary Can't Do This
and and or look like they belong in Binary alongside + and < — but Binary's own visit_binary always evaluates both node.left.accept(self) and node.right.accept(self) before deciding anything. That's correct for arithmetic (you need both operands to add them) and wrong for logical operators, which are defined specifically so that the second operand is sometimes never even examined.
Notice what these branches return: not always true or false, but one of the two operand values themselves. That's deliberate — it's what makes a common default-value idiom possible.
print nil or "default"; prints default — the string itself, not true. print "hi" or 2; prints hi — the left side won, because a non-empty string is truthy in Wisp, so the right side was never touched.
print 0 and "reached"; prints reached, not 0 — which can look wrong at first if you're used to a language where 0 is falsy. It isn't a new rule; it's Chapter 4's own truthiness table applied here: 0 is truthy in Wisp, so and's left side doesn't stop evaluation, and the right side runs and wins.
Proving the Short-Circuit, Not Just Asserting It
"Short-circuit" is a claim about what doesn't run — which means the way to actually verify it is to put something that would visibly fail on the side that's supposed to be skipped, and confirm it never fails.
var flag = false; print flag and (1 / 0);, run through this chapter's own short-circuiting visit_logical, prints false with no error — (1 / 0) is never evaluated, because flag already determined the answer. The same program, run through a deliberately naive version that evaluates node.right.accept(self) unconditionally before checking anything (the same shape visit_binary already uses), raises a real ZeroDivisionError: division by zero instead. Swapping flag to true and the operator to or reproduces the identical contrast on the other branch: the correct version prints true cleanly, the naive version crashes the same way.
An Aside: The Same Mistake, Found Living in Chapter 4's Own Code
Writing the naive comparison above meant deliberately building code that evaluates something it doesn't need. That's worth checking for elsewhere in this course — and it turns up in a place with no relation to and/or at all: the arithmetic dispatch shown since Chapter 4.
left / right gets computed even when op is '-'. Running print 5 - 0; through this exact dict-based dispatch raises ZeroDivisionError: division by zero — on a subtraction, because building the dict silently attempted the unrelated division first. Rewriting it as a plain if/elif chain (checking op before computing anything) fixes it: print 5 - 0; then correctly prints 5. This is the same underlying mistake as an eager Logical — evaluating something "just in case" instead of only when it's actually needed — just found in a spot with no logical operator anywhere nearby. The code samples from this chapter onward use the if/elif form.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
IfStmt/WhileStmt execute a branch by calling .accept() conditionally, never unconditionally | Chapter 7's own function calls will use this same "only run what's actually reached" discipline for early return statements |
for desugars into existing nodes with zero new interpreter methods | A general technique worth remembering for Course 2's own bytecode compiler — fewer distinct node types to compile means fewer opcodes to design |
Logical is a genuinely separate node type from Binary, not a special-cased operator inside it | Chapter 3's own Visitor pattern chapter predicted exactly this — a new node type needs a new visit_ method on every existing visitor, which is precisely what happened here |
| The eager-dict bug, found and fixed in this chapter | A genuine, previously-unflagged bug in the arithmetic dispatch code shown in Chapters 4 and 5 — worth knowing about if you built along with those chapters directly |
Hands-On Exercises
C-style for loops allow omitting the initializer, the condition, or the increment (only the two semicolons are mandatory). Run var i = 0; for (; i < 3; i = i + 1) { print i; } (no initializer clause) and for (var j = 0; j < 3;) { print j; j = j + 1; } (no increment clause) through this chapter's own for_statement(). Verify both still produce correct output, and trace through the desugaring code to explain exactly which if branch each omitted clause takes.
Run var i = 10; while (i < 5) { print 1 / 0; } print "after"; — a while loop whose condition is false from the very first check. Verify the program completes without error despite its own body containing a guaranteed division by zero, and explain this in terms of the same "never evaluated" principle this chapter used for if's untaken branch and Logical's skipped operand.
Run print nil or false or 0 or "found it"; — a chain of three ors. Determine exactly which value gets printed, and explain why, tracing through visit_logical and Chapter 4's own is_truthy() rule for each operand in order. Is the result the one you'd expect coming from a language where 0 is falsy?
Chapter 6 Quick Reference
- if/else, while: new
Stmtnodes; a branch/body only runs via a conditional.accept()call, never unconditionally - for: desugared entirely into
BlockStmt+WhileStmtat parse time — verified byte-for-byte identical output to the hand-written equivalent Logical: a genuinely new node type, distinct fromBinary, because it must sometimes skip evaluating its own right side- Verified: short-circuit
and/oravoid a realZeroDivisionErrorthat a naive, eager version reliably triggers and/orreturn an operand value, not a coerced boolean — enables anil or "default"idiom- Real bug found and fixed: the dict-literal arithmetic dispatch shown since Chapter 4 evaluates all three operators before selecting one — verified crashing a plain subtraction; fixed with
if/elif - Next chapter: Functions & Closures — giving Wisp something for
if/whilebodies to actually call