Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Conditionals and Loops

The previous chapter gave a program a cell whose contents change: a value can be written where an earlier one stood, and a later read finds the new one. Writing one assignment changes the cell once. A program that sums the numbers in a list, or counts up to a bound, or halves a number until it drops below one, changes a cell a number of times it does not know when it is written, and decides each time whether to change it again. Two forms are missing. One chooses whether to act according to a value; the other repeats an action. This chapter adds the conditional if and the loops while and for.

Their category is already settled. The opening chapter of this part placed if, while, and for among the expressions: an if takes the value of the branch it runs, and a loop takes the value (), since a loop runs for the effect its body has on the store rather than for a result. What that chapter left open, and this one fixes, is what each evaluates to — the rules that pick a branch, and the rules that repeat one. The store threads through them as it threads through every form now, one store in and one store out, env ⊢ ⟨e, σ⟩ ⇓ ⟨v, σ'⟩. (The reference’s rules thread the relation database of Part VII and carry the outcome a return produces; this part uses the env-and-store slice mutation.md introduced.) The rules below are written with the keyword forms if e then e else e and while e e; the concrete syntax braces them, if c { … } and while c { … }, with no then.

Choosing a branch: if

The conditional is among the oldest control forms, and its shape changed as languages moved the choice into the program’s expressions. Fortran’s conditional was the arithmetic IF: IF (e) n1, n2, n3 jumped to one of three statement labels according to whether e was negative, zero, or positive — a branch to a label, decided by a sign. Algol 60 gave the if c then S else S that chooses a course of action in place, named by the condition being true or false, and set beside it a conditional expression that chooses between two values — the two-form split the first chapter of this part traced. Bridger keeps the single expression form: one if, valued by the branch it takes.

if evaluates its condition to a boolean, then evaluates the chosen branch against the store the condition produced. The other branch is not evaluated, so only the taken branch’s effects happen:

The condition threads its store into the branch, and the branch hands the store out; a write the condition makes is in place when the branch runs, and a write the branch makes is what the whole if leaves behind. The value the if yields is the branch’s value, which is what lets it stand where a value is wanted:

let n = if x > 0 { x } else { -x };

Because the whole form has one value, its two branches have to agree on a type — the result is the value of one branch or the other, and one form carries one type. That agreement is a typing rule, checked before the program runs; it is the subject of the type checker, and the evaluator here runs whichever branch the condition selects without consulting it.

An if may drop its else:

if x > 0 { print(x) };

runs its branch when the condition holds and does nothing when it does not. The form still has a value on both paths, so the missing branch has to supply one, and the only value the language can supply without a branch to evaluate is (). An else-less if reads as if e1 then e2 else () and is admitted only when its branch is itself ()-valued, so that both paths agree; a one-armed if whose branch produced an Int would have no matching value to give when the condition is false. Chained conditions are the else holding another if, if c1 { … } else if c2 { … } else { … }, which the grammar reads as an if in the else position rather than a new form.

Building it

if is one arm of eval_expr, matching the provided Expr::If node — a condition, a then-branch, and an Option for the else-branch that is absent in the one-armed form:

Expr::If(Box<Expr>, Box<Expr>, Option<Box<Expr>>, Span)

The two E-If rules say what the arm does: evaluate the condition, and on a Value::Bool evaluate the branch that boolean selects, leaving the other unevaluated; when the else-branch is absent and the condition is false, the value is Value::Unit. A condition that evaluates to anything but a boolean is a runtime type error, the conditional’s stuck case, ruled out ahead of time once the type checker of Part VI is in place.

Repeating an action: while

Repetition began as an arrangement of jumps. Early machine code and Fortran repeated work by testing a value and jumping back to a label with GO TO; the loop existed in how the jumps were placed rather than as a form the language named, and Fortran’s counting DO was the one repeat given a construct of its own. Structured programming replaced the placed jumps with constructs that control enters and leaves at a single point, so the repeat becomes a form in the grammar: the while that tests a condition and runs its body again as long as the condition holds. Dijkstra (1968) argued for retiring the GO TO in favor of such constructs, and Böhm and Jacopini (1966) had shown that sequence, selection, and iteration alone express any flowchart, so a language with if and while can describe any control a program of jumps could. Bridger’s while is that structured loop.

while needs the mutation of the previous chapter to do anything. Its condition is evaluated afresh each time around, and a re-evaluation can give a different answer only if something the condition reads has changed; without a store to change, a while whose condition starts true stays true and never stops, and one that starts false never runs. Ordering mutation before the loop is what lets the loop’s body move the condition toward false — a counter the body steps, a total it adds into, a list it shortens.

Two rules define it. When the condition is false the loop stops, yielding () and the store the condition produced:

When it is true the loop runs the body, discards the body’s value, and then runs the same while again against the store the body left:

The third premise is a derivation of the very form the rule concludes. Every rule up to here broke a form into strictly smaller parts, and a derivation bottomed out when the parts were literals; this one names its own conclusion among its premises, and the store is what makes the naming finite. Each turn of the loop derives the next against a store the body has moved, and a run that reaches a false condition is a finite stack of E-While-True closed at the top by E-While-False. A loop whose condition never becomes false has no such finite stack — and so no derivation, and no value — which is the rule’s way of saying that a loop that never stops computes nothing to hand back. The store carries the difference between the two cases.

The counter the previous chapter allocated is what a while drives:

let counter = ref 0;
while deref counter < 3 {
  counter := deref counter + 1
};
deref counter                  // 3

counter holds a cell starting at 0. Each turn reads the cell, and while it is below 3 the body writes back one more; the store the body leaves is the store the next condition reads, so deref counter < 3 is true, true, true, false across four evaluations, and the loop stops with the cell at 3. The loop’s own value is (); the result the program wants is in the cell, read out afterward. A while with no cell to move — while true { } — matches only E-While-True, forever, and has no derivation.

Building it

The while arm matches the provided Expr::While node, a condition and a body:

Expr::While(Box<Expr>, Box<Expr>, Span)

The recursion the rule expresses as a repeated premise, the interpreter can express as a loop of its own: it need not call itself to run the next turn, since a host loop re-evaluates the condition and body in place, threading the one store on the interpreter exactly as the rule threads . The body’s value is evaluated and dropped, matching the rule’s discard of , and the loop yields Value::Unit. A return reached in the body leaves the host loop at once — the interpreter carries it on the same Control channel it uses for an error, so the remaining turns do not run, the reference’s convention that a return abandons the whole while. That returning outcome is the subject of a later section.

Walking a list: for

A while repeats until a condition falls; a for repeats once for each element of a collection. Bridger’s for walks a list — the values written [1, 2, 3] and built with ::, introduced with the other compound values in Part II. A list there had no way to read its elements back out: there is no xs[i] and no xs.0, by design. for is the form that reads them, taking each element in turn and binding a name to it for the body:

let total = ref 0;
for x in [1, 2, 3] {
  total := deref total + x
};
deref total                    // 6

for evaluates the iterable to a list value, then walks it. The walk is two rules over the list’s structure — an empty list, and a cons of a head and a tail — with the evaluated list standing in the in position as the walk proceeds:

An empty list runs the body no times. A cons binds x to the head, runs the body against the incoming store and discards its value, then walks the tail against the store the body left. The binding of x is fresh on each turn and belongs to the body alone; it is not a cell, so writing x := … is not how a for accumulates. To carry a result across the turns, the body writes a cell that outlives the loop — total in the example — the same division of labor as while: the loop supplies the repetition, the store supplies the memory. Like while, the whole for yields (), a return in the body abandons the rest of the walk, and a for over a value that is not a list is stuck.

Because the list is finite and the walk shortens it by one each turn, a for over a list always stops — the property while gives up in exchange for looping on an arbitrary condition. The one other thing a Bridger program walks is the set of solutions to a relational query, written for q { … }; that form belongs with the relations of Part VII, where the queries it draws from are defined.

Building it

The for arm matches the provided Expr::For node — the bound name, the iterable, and the body:

Expr::For(Name, Box<Expr>, Box<Expr>, Span)

The three E-For rules describe the walk: evaluate the iterable, require a list, and for each element extend the environment with a fresh binding of the name before running the body against the current store, the body’s value discarded each turn. An iterable that is not a list is the stuck case — there are no cells to walk. The per-element binding extends the environment without disturbing the outer one, so the name is in scope for the body and gone after, and the whole for yields Value::Unit. The relational Expr::ForQuery node is the second walk, filled in with Part VII.

Leaving early: return

The forms so far hand their value to the expression around them: an if gives its branch’s value to whatever holds the if, and a block gives its trailing expression’s value to its surroundings. return e sends its value the other way. It evaluates e and carries the value out — past the rest of the block, past the turns of a loop still to come — to the boundary of the function that encloses it, which takes that value as its result. Its rule is the one place evaluation in this part produces something other than an ordinary value: a returning outcome, written , that the surrounding forms pass along instead of continuing past.

Every other rule reads as it did, under one added convention: a premise that yields makes its own rule yield that same , so a returning outcome travels outward on its own. This is what the loop rules meant in saying a return in the body abandons the loop — the body’s premise yields , the loop yields it in turn, and the iterations that would have followed never run. The interpreter already carries such an outcome: the Control channel eval returns for an error is the one a return rides, which is why the ? in the loop arms above carries a return straight out of the host loop.

A return leaves the enclosing function, the subject of Part V; this chapter shows the returning outcome travelling out through the loops and blocks in its way, and that part supplies the function boundary that catches it and the calls that make an early exit worth writing. That a return stands inside a function at all is checked before the program runs — a return with none to leave is a static error rather than a value the evaluator computes.

The design space

Two decisions show through the forms of this chapter: what a conditional yields, and which loops a language provides.

Because Bridger’s if is an expression, its branches must agree on a type and a one-armed if is confined to (). A language whose if is a statement asks nothing of its branches, since the form yields nothing on either path; C and Java take that route and then add a second conditional, the operator c ? a : b, for the places a value is wanted — the split the first chapter of this part followed. The choice is whether one form serves both jobs, at the cost of the branch agreement, or two forms divide them, at the cost of carrying the conditional twice.

Loops vary more. C offers while, a do/while that tests after running the body once, and the three-part for (init; cond; step) that gathers a counter’s setup, test, and update into a header. Python and Rust make for a walk over an iterator — any value that yields elements on demand — and lean on it for counting too, over a range. Bridger provides while and a for that walks a list. while is the general loop: it repeats on any condition and can run without end, and the other shapes are while with state arranged around it — a counting loop steps a ref the body updates, a test-at-bottom loop runs its body before the first test. Bridger’s for names the one arrangement of walking a list’s elements, and it is bounded where while is not, stopping when the list runs out. What it does not do is walk an arbitrary producer the way an iterator protocol does; that generality rests on the traits and closures of later parts, while a list is the concrete sequence already in hand.

Beyond running to its end, a loop leaves only by the return of the previous section, which carries a value out past the loop and out of the enclosing function. There is no break to leave a loop early and no continue to skip to the next turn; a loop that must stop before its natural end folds that into its own condition, with a cell to signal it where the test alone will not do. The loops are kept to the two rules apiece that define them, and the state a program already has does the rest.

Structured control is one answer to the jump: name the loop and the branch as forms, and build a program from them. Another tradition answers the jump, the loop, and the branch together, by making none of them a primitive. Its programs compute by applying functions: repetition is a function that calls itself, so iteration becomes recursion; selection is an expression that evaluates one subexpression or another; and nothing jumps, since control travels where a call goes and resumes where the call returns. In the most reduced form of that tradition — the lambda calculus, whose notation Lisp borrowed — the function is the only construct, and the loop and the branch alike are things a program expresses with it rather than forms the language hands it. Bridger offers both answers: the conditionals and loops of this part, and the functions and recursion of Part V, with a program free to keep to either. return sits on the structured side of that line — a function in the recursive style has nowhere to jump out to, since its value is the expression it evaluates.

Further reading

Structured control flow: Edsger Dijkstra’s letter “Go To Statement Considered Harmful” (Communications of the ACM, 1968) makes the case for control constructs that are entered and left at one point, the shape Bridger’s if and while take, in place of the jump. Corrado Böhm and Giuseppe Jacopini’s “Flow Diagrams, Turing Machines and Languages with Only Two Formation Rules” (Communications of the ACM, 1966) proves that sequence, selection, and iteration suffice to express any flowchart, so the loss of the general jump costs a language no control it could otherwise have.

Iteration constructs: CLU introduced the iterator, a for that draws successive values from a separate producer rather than from a stored collection, described in Barbara Liskov and colleagues’ “Abstraction Mechanisms in CLU” (Communications of the ACM, 1977); it is the arrangement Python’s and Rust’s for follow. The Rust Reference sets out the loop, while, and iterator-for that Bridger’s two loops are drawn from and pared against.

Concept checks

An if with no else is admitted only when its branch has type (). Why does the missing branch force that?

An if is an expression, so it has a value on every path, including the one where the condition is false and there is no else to run. The language has to supply that value without a branch to evaluate, and the only value it can supply knowing nothing about the branch is (). For both paths to yield the same type — which an expression’s single type requires — the present branch must therefore also be ()-valued. A one-armed if whose branch produced an Int would have no Int to hand back when the condition is false, so it is rejected; the else-less form reads as if e1 then e2 else ().

The E-While-True rule has the same while among its premises that it has in its conclusion. What does a terminating loop's derivation look like, and why does a loop that never stops have no value?

A terminating loop’s derivation is a finite stack: each E-While-True sits above another derivation of the same while, taken against the store its body produced, and the stack is closed at the top by E-While-False once the condition comes out false. The store is what makes the stack finite — without a body that moves what the condition reads, the condition never changes and the only rule that ever applies is E-While-True. A loop whose condition never becomes false has no E-While-False to close the stack, so no finite derivation exists, and a form with no derivation has no value. That is the rules’ way of recording that a loop which never halts computes nothing to return.

In if c { f() } else { g() }, which of f and g runs, and whose effects on the store does the whole if leave behind?

Only the branch the condition selects is evaluated: on true the rule E-If-True evaluates the then-branch and never touches the else-branch, and E-If-False does the reverse. So exactly one of f and g runs, and the store the whole if hands out is the one that branch produced, threaded from the store the condition itself left. An effect written in the branch that is not taken never happens, because that branch is never evaluated — the rule has no premise for it.

A for x in xs binds x fresh on each turn. How does a loop accumulate a result across its turns, and why doesn't assigning to x do it?

The binding of x holds the element value directly, with no cell behind it, and it is remade for each element and scoped to the body, so a value placed in x on one turn is gone by the next; x := … is well-formed only when x itself holds a location. To carry a result across the turns, the body writes a mutable cell that lives outside the loop: let total = ref 0; for x in xs { total := deref total + x } reads and overwrites the one cell each turn, and deref total after the loop holds the accumulated result. The loop supplies the repetition and the store supplies the memory; the per-element binding carries only the current element into the body.

Bridger has while and a for that walks a list, but no C-style three-part for, no iterator protocol, and no break or continue. What does this keep small, and what does it give up?

Each loop is two rules: while repeats on a condition, for walks a list’s cells, and neither needs a third construct, because a counting loop is a while over a ref the body steps and an early exit is a condition the loop tests for itself. What that gives up is the uniform for over any producer of elements that an iterator protocol offers — Bridger’s for walks a concrete list, and a lazily generated or user-defined sequence would need the traits and closures of later parts — and the convenience of break/continue, which would travel the same non-local Control path as return and would add a second way for a loop to end. The trade is a smaller language, whose every loop is covered by the rules of this chapter, against the reach and brevity those extra constructs buy.