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

@dataclass class IfStmt(Stmt): condition: Expr then_branch: Stmt else_branch: Stmt # or None def accept(self, visitor): return visitor.visit_if_stmt(self) def visit_if_stmt(self, stmt): if is_truthy(stmt.condition.accept(self)): stmt.then_branch.accept(self) elif stmt.else_branch is not None: stmt.else_branch.accept(self)

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 allstmt.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.

Verified directly
if (5 > 3) { print "yes"; } else { print "no"; } prints yes — the else branch's own print "no"; never executes.

while: The Same Idea, Repeated

def visit_while_stmt(self, stmt): while is_truthy(stmt.condition.accept(self)): stmt.body.accept(self)

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.

Verified directly — summing 1 through 5 with a while loop
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.

def for_statement(self): self.expect('(') initializer = None if self.match(';') else ( self.var_declaration() if self.match('VAR') else self.expression_statement()) condition = None if self.peek()[0] == ';' else self.parse_expression() self.expect(';') increment = None if self.peek()[0] == ')' else self.parse_expression() self.expect(')') body = self.statement() if increment is not None: body = BlockStmt([body, ExpressionStmt(increment)]) body = WhileStmt(condition if condition is not None else Literal(True), body) if initializer is not None: body = BlockStmt([initializer, body]) return body # the parser hands the interpreter a BlockStmt/WhileStmt -- nothing "for"-shaped at all

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.

Verified directly — the desugared for loop is byte-for-byte identical in output to the equivalent hand-written while loop
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.

def visit_logical(self, node): left = node.left.accept(self) if node.operator == 'or': if is_truthy(left): return left # right side NEVER evaluated return node.right.accept(self) else: # 'and' if not is_truthy(left): return left # right side NEVER evaluated return node.right.accept(self)

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.

Verified directly — and/or return an operand, not a coerced boolean
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.
This is exactly the same evaluation-order question Chapter 4's truthiness rule already answered once
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.

Verified directly — a naive, eager version of Logical reliably crashes; the real one doesn't
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.

# The shape used in Chapters 4-5's own code samples: return {'-': left - right, '*': left * right, '/': left / right}[op]
Verified directly — this line evaluates all three operations, even when only one was requested
Python builds a dict literal by evaluating every value before the dict exists at all — 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 findingWhat it connects to
IfStmt/WhileStmt execute a branch by calling .accept() conditionally, never unconditionallyChapter 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 methodsA 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 itChapter 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 chapterA 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

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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?

📄 View solution

Chapter 6 Quick Reference

  • if/else, while: new Stmt nodes; a branch/body only runs via a conditional .accept() call, never unconditionally
  • for: desugared entirely into BlockStmt + WhileStmt at parse time — verified byte-for-byte identical output to the hand-written equivalent
  • Logical: a genuinely new node type, distinct from Binary, because it must sometimes skip evaluating its own right side
  • Verified: short-circuit and/or avoid a real ZeroDivisionError that a naive, eager version reliably triggers
  • and/or return an operand value, not a coerced boolean — enables a nil 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/while bodies to actually call