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

About This Book

This is an introduction to the concepts used to design programming languages — paradigms, scope, binding, types, evaluation — taught not by touring a handful of languages but by building one. Over the course of the book you implement an interpreter, in Rust, for a single language that grows from a calculator into a small multi-paradigm language named Bridger, with imperative, functional, and declarative features.

It grew out of an undergraduate programming-languages course, but it is written to stand on its own. If you read the chapters and build the interpreter as you go, you will learn the material and end up with a working language.

Why an interpreter?

There are two ways to give a programming language a precise meaning, and they lead to two different kinds of book.

  • A compiler foregrounds translation: it turns your program into another language (machine code, bytecode, a lower-level IR) and cares deeply about code generation, optimization, and the constraints of the target machine.
  • An interpreter foregrounds meaning: it says directly what a program is and does. You write down a rule like (“expression evaluates to value ”), and the evaluator you build is that rule, made executable.

This book takes the semantics lens. That single choice explains most of what follows — including why we hand you a parser instead of making you write one: parsing is a translation problem that belongs to the compiler’s world, and a course on it already exists in most curricula. Here we start from the abstract syntax tree and spend our time on what programs mean. Code generation, optimization, and register allocation are real and important; they are simply a different book, and we point you to good ones where the boundary comes up.

Who this book is for

Anyone who wants to learn how programming languages work, with a practical flair. This text is designed to be self-contained: the concepts, the specifications, and the build are all here, and the scaffolding you build on top of is publicly available. It assumes you can program in some language and are curious about what is going on underneath — not that you already know Rust, type theory, or logic programming.

How the book is organized

The chapters follow the arc of the language as it grows, and each part from Part II onward closes with a project milestone (M1–M8). Treat the milestones as a learning structure: a checkpoint that says “build this much next, and here is how to know it works.” By the end you will have built:

  • a tree-walking evaluator with clear runtime errors,
  • lexical scope, and the closures that make it observable,
  • first-class functions and closures,
  • a static type checker that rules out whole classes of runtime errors before a program runs,
  • a relational (Datalog-style) sublanguage you can query from ordinary code,
  • algebraic data types with pattern matching, and
  • objects built as structs with methods and traits.

Every chapter follows a similar narrative. Each chapter will broadly introduce the relevant programming-language concepts: what they are meant to capture, and the history of how they emerged and changed over time and across communities. It will then discuss how those concepts shape the design decisions underlying Bridger. Then the lens widens to the design space: how ML, Rust, Prolog, or Python answer the same questions, and the pros and cons of each design. Some of those questions Bridger answers by declining — negation in logic rules, full type inference, classical inheritance — and those forks get the same treatment, with pointers for reading further.

The starter repository

Because the book is meant to be self-contained, the scaffolding you build on top of ships publicly: the Value type, the environment structure, error and source-span types, the AST, and the parser. You implement semantics — the evaluator, the type checker, unification — not the memory plumbing that would otherwise have you fighting the borrow checker instead of learning what a closure is. The starter is the bridger-interpreter repository; its README covers setup, and each milestone says what to build next.

How to read this book

  • Code you implement is shown in Rust. Where a snippet is self-contained you can press the play button to run it.

    fn main() {
        println!("Hello from the metalanguage.");
    }

    You do not need to know Rust first. Each construct is introduced where the interpreter first needs it (enum/match at the AST, Rc<RefCell> at closures, Result/Option at error handling), as a short aside you can skip if you’re already familiar with Rust. For those looking for a slightly more in-depth introduction, Appendix A is a self-contained tutorial with an M0 warm-up milestone.

  • Programs in Bridger are shown in monospace:

    fn fib(n: Int) -> Int =
        if n < 2 { n } else { fib(n - 1) + fib(n - 2) };
    
    fn main() { println(fib(10)); }
    
  • Specifications are written as inference rules. For example, addition evaluates its operands and adds the results:

    The notation reference collects the symbols and rule shapes we use.

A note on tools and AI assistants

Syntax is the part that varies. Every language spells the same idea differently, and the spelling changes with every release — which is exactly the kind of detail a coding assistant handles well. It handles semantics well too: ask one to explain closures or type inference and you will usually get a fluent and largely correct answer. The limit is not that these tools only know syntax.

The limit is accountability. An assistant can make a design decision; it cannot be answerable for one. Whether a problem wants a relation or a fold, what a representation choice will cost three modules later, which trade-off a language made and whether it was the right one for your system — those are judgments you own, and owning them means understanding the concepts well enough to evaluate an answer rather than accept it.

The test arrives when someone asks why a system is built the way it is. “The assistant did that” reports only that nobody decided; the engineer who can explain the reasoning understood the choice, whether or not an assistant helped reach it.

That is what this book is for: the concepts, where they came from, and the pros and cons that make one design fit where another fights. It is what you need in order to design and assess systems — including the ones you build with an assistant’s help.

Why Build an Interpreter?

A program, before anything runs it, is text. A file of characters that means nothing to the machine and — strictly speaking — nothing at all until something decides what it means. The field of programming languages focuses on that gap: between a string somebody typed and a behaviour they intended. Broadly speaking, there are two extremes for how that gap is closed: interpretation and compilation. Interpretation directly evaluates the program as written; compilation rewrites it into a different language (e.g., assembly or machine code) and runs that instead.

The shape of a language implementation

Nearly every language implementation, of every kind, begins the same way. The text is broken into tokens, and the tokens are assembled into a tree that reflects the program’s structure — an abstract syntax tree, or AST:

  source text
      │
      │   lexing — characters into tokens
      ▼
    tokens
      │
      │   parsing — tokens into structure
      ▼
     AST   ◄─── this book starts here
      │
      ├────────────►  evaluate it directly           (interpretation)
      │
      └────────────►  translate it into another      (compilation)
                      language, then run that

Everything above the AST is a translation problem: recovering structure from text. It is a well-understood and genuinely interesting body of work, and it is where a compilers course begins. This book starts at the AST and works down from there — on what the program means, and how to make a machine carry that meaning out.

Interpretation and compilation

A compiler turns your program into another program. The output might be machine code for a particular processor, bytecode for an abstract machine, or another high-level language entirely. The defining move is translation, and the compiler’s work is finished before your program ever runs. Its central concerns follow from that: which instruction sequences are efficient, how to allocate registers, which optimizations are safe.

An interpreter does not translate. It walks the program and carries out what the program says, directly. Ask it to evaluate 1 + 2 and it evaluates 1, evaluates 2, and adds them. Its central concern is not what instructions to emit but what each construct means.

That difference is why this book builds an interpreter. When you write the case that handles addition, you are not describing addition to a machine that will handle it later — you are writing down what addition is, in a form that runs. Specification and implementation become the same artifact. We will make this explicit in Program semantics, where the rule

and the code implementing it turn out to be the same statement written twice.

It is worth being precise about what interpretation means, because it is broader than simply running a program. To interpret a piece of syntax is to map it to a mathematical object — its meaning. Which object depends on what you are interpreting it as. The expression 1 + 2 might mean the number 3. A function definition might mean an actual mathematical function, from arguments to results. A whole program might mean a map from inputs to outputs, or a set of possible behaviours. The evaluator you build in this book is the concrete case: the object is the value the expression produces when it runs.

But the domain is a choice, and coarser choices are useful. Interpret 1 + 2 as positive rather than as 3, and you have a program that reasons about signs without computing anything. Interpret it as Int, and you have a type checker. This is abstract interpretation — the same syntax-to-meaning mapping, aimed at a domain that deliberately forgets some detail so that questions can be answered without running the program. It is how a great deal of static analysis works, and it is exactly what the type checker in Part VI does: interpret every expression as a type rather than as a value, and reject the program if the types do not fit. The evaluator and the type checker are two interpretations of the same syntax, over different domains.

This is not a modern observation. When John McCarthy published the definition of Lisp in 1960, it included a function called eval — a Lisp program describing how to evaluate Lisp programs. He intended it as a theoretical device, written to show that Lisp was tidier than a universal Turing machine; by his own account the notation behind it was devised “with no thought that it would be used to express LISP programs in practice.” Then S. R. Russell noticed that eval could serve as an interpreter, hand-coded it, and Lisp had an interpreter nobody had set out to write. The definition was the implementation. This book leans on that same fact throughout.

A spectrum of implementations

The two branches above are a useful simplification, and like most useful simplifications it is false at the edges. Real implementations mix strategies freely:

  • A tree-walking interpreter evaluates the AST directly, node by node. The simplest design, the easiest to read, the slowest to run, and the subject of this book.
  • A bytecode interpreter first compiles the AST into a compact instruction set for an abstract machine, then interprets those instructions. CPython works this way: your source is compiled to bytecode, and a virtual machine executes it.
  • A just-in-time compiler begins by interpreting, watches which code runs hot, and compiles those parts to native code while the program is still running. The JVM and JavaScript engines such as V8 both do this, with an interpreter and one or more optimizing compilers cooperating at run time.
  • An ahead-of-time compiler translates the whole program to native code before it runs and ships the result. This is what gcc, clang, and rustc do.

So “is Java compiled or interpreted?” has no clean answer. javac compiles source to bytecode, a virtual machine interprets that bytecode, and a JIT compiler translates the frequently executed parts to machine code during execution — all three in one system. The honest description is that implementations sit somewhere on a spectrum from decide everything before running to decide everything while running, and the interesting ones occupy several points at once.

The tree-walking end of that spectrum is where meaning is most visible, because nothing has been translated away yet. Moving along the spectrum gains speed by moving toward the machine: bytecode and native code run fast because they are closer to what the hardware already knows how to do. That translation discards the program’s original structure and intent: a loop becomes a jump, a function becomes an address, a type vanishes once it has been checked. Those high-level structures — and what they were meant to express — are precisely this book’s subject. So we work at the end of the spectrum where they are still intact.

What you will build

Over the following parts you will build a tree-walking interpreter for Bridger, a language that starts out able to add two numbers and ends up with closures, a static type checker, algebraic data types, objects, and a relational sublanguage.

The interpreter will be slow — meaningfully slower than any production implementation, because walking a tree re-examines the program’s structure at every step. It will also be legible: small enough to read straight through and hold in your head, with every language feature and programming language concept corresponding to a piece of code you can point at.

Performance returns in Cost semantics, where we measure programs by counting evaluation steps rather than seconds — a measure that says something about your algorithm that a stopwatch obscures.

Further reading

Lisp, and the interpreter nobody set out to write: John McCarthy’s “Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I” (1960) defines Lisp, eval included. His “History of Lisp”, a 1979 Stanford Artificial Intelligence Laboratory draft, is his own account of the period, including the moment itself: “S.R. Russell noticed that eval could serve as an interpreter for LISP, promptly hand coded it, and we now had a programming language with an interpreter.”

Abstract interpretation: Patrick Cousot and Radhia Cousot’s “Abstract Interpretation: A Unified Lattice Model for Static Analysis of Programs by Construction or Approximation of Fixpoints” (1977) is the paper that made abstract interpretation a theory rather than a collection of tricks. It sets up the lattices, the abstraction and concretization maps, and the fixpoint construction that the sign and type examples above only sketch. The argument is carried almost entirely in the algebra, and it assumes you are comfortable with partial orders and least fixpoints. Xavier Rival and Kwangkeun Yi’s Introduction to Static Analysis: An Abstract Interpretation Perspective (MIT Press, 2020) covers the same ground at book length, starting from example analyses.

The compiling end of the spectrum: Aho, Lam, Sethi, and Ullman’s Compilers: Principles, Techniques, and Tools, 2nd ed. (2006) — the “Dragon Book” — works through the pipeline this book starts in the middle of: lexing, parsing, semantic analysis, intermediate representations, optimization, and code generation. John Aycock’s “A Brief History of Just-In-Time” (2003) traces how interpretation and compilation stopped being alternatives and began cooperating inside a single system, from Smalltalk-80 onward. For the current shape of a production pipeline, V8’s own accounts of Ignition and TurboFan and of the later Sparkplug baseline compiler describe a JIT with several tiers.

Building one yourself: Robert Nystrom’s Crafting Interpreters (2021) builds the same language twice, once as a tree-walker and once as a bytecode virtual machine — the same program at two points on the spectrum described above.

Concept checks

Is Java compiled or interpreted?

Both, and the question presumes a dichotomy that does not survive contact with real systems. Source is compiled ahead of time to bytecode, the bytecode is interpreted by a virtual machine, and a JIT compiler translates frequently executed portions to native code while the program runs. A useful follow-up: which of those three stages could you remove and still have a working Java? (The JIT — you would simply be slower.)

Why does handing you a parser follow from studying semantics rather than translation?

Parsing recovers structure from text — a translation problem, sitting entirely above the AST in the pipeline. Nothing about it determines what a program means: you can change a language’s surface syntax completely and leave its semantics untouched. Since this book’s questions all live below the AST, building a parser would be time spent on a different subject.

You could study semantics by reading a compiler's code generator instead. What would be lost?

The meaning of a construct would be spread across the translation rather than stated in one place — partly in the emitted instructions, partly in assumptions about the target machine, partly in the optimizer’s notion of which transformations preserve behaviour. You would be reading a correct answer to “how do I make this run fast on this processor” and trying to reconstruct “what does this construct mean” from it. An interpreter states the second directly.

What would it mean to interpret 1 + 2 as something other than 3?

It would mean choosing a different semantic domain. Interpreted over signs, 1 + 2 means positive; over types, it means Int; over intervals, it means something like — and x + 2, where x is known only to lie in , means . Each of these is a legitimate interpretation of the same syntax, and each answers a question the concrete one cannot: they can be computed without running the program, and they can describe all possible runs at once. What they give up is precision — positive does not tell you the answer is 3. Choosing how much precision to give up is a central concern of abstract interpretation and static analysis.

Tree-walking is the slowest point on the spectrum. When would it be the wrong choice for a real project?

Whenever execution speed matters more than implementation legibility, which covers most production systems: if the same program runs repeatedly, the up-front cost of compiling to bytecode or native code is amortized almost immediately. Tree-walking survives in practice where startup time dominates total run time, where the implementation must stay small and auditable, or where the language is a configuration or scripting layer that spends most of its time calling code written in something else.

From Machine Code to High-Level Languages

A program today is written in a high-level language and reaches the processor through a stack of abstractions: libraries, a compiler or an interpreter, an instruction set, and a microarchitecture underneath that. Every one of those layers was put there by somebody, for a reason, and often over somebody else’s objection.

These abstractions have not always existed. The earliest stored-program machines were programmed by putting bits into the store directly. On the Manchester Baby, in June 1948, that meant stepping through the store word by word and setting each bit to 0 or 1 on a device of 32 buttons and switches: the first program to run there was seventeen instructions long and written one bit at a time.

Those bits formed a language. They were broken up into words, each corresponding to a single instruction, which the machine read and executed one by one. Every word had a fixed shape, each part of that shape meant something, and the machine combined those meanings by fixed rules — a vocabulary and a grammar.

What makes this language of bits low-level is that its vocabulary is the machine’s own. The only thing a word can say is that the hardware should carry out one of its operations, and a program is a sequence of such words — so there is no shortage of computations you can express in it. What has nowhere to go is everything you understand about the program besides the operations themselves. That these twelve words are a routine for computing a square root; that location 90 holds a running total; that this jump is the one closing a loop; that the words further down are data rather than instructions — none of it is recorded in the store, because the notation has nothing to record it with. It lived in pencil annotations on the paper beside the machine, and in the programmer’s memory. The rest of this chapter explores how languages evolved to move more of that intent into the program itself.

Consider one of the earliest machines to make such a move. The EDSAC ran at Cambridge from May 1949. The EDSAC took about 1.5 milliseconds to execute an order (approximately 650 orders a second). On the first morning the EDSAC was running, it took two minutes and thirty-five seconds to compute and print a table of the first 100 perfect squares (0 to 9801).

A black-and-white photograph of two men dwarfed by the EDSAC, which fills the room as
            open racks of vacuum tubes standing floor to ceiling in five bays. One man wears a white
            lab coat and faces the camera; the other, in a dark jacket, stands with his back to us
            looking at a rack. Wiring runs overhead across the ceiling, industrial lamps hang above,
            and a bench carrying three circular meters sits at the left edge.
Maurice Wilkes and Bill Renwick with the completed EDSAC. Renwick, the project's chief engineer, kept the operating log quoted at the end of this chapter. “EDSAC I, W. Renwick, M.V. Wilkes.” Copyright Computer Laboratory, University of Cambridge; via Wikimedia Commons, licensed under CC BY 2.0.

The EDSAC’s store held words consisting of 17 bits. An instruction — an order, in the vocabulary of the time — occupied exactly one such word, divided into four fields:

      5 bits      1        10 bits       1
   ┌───────────┬───────┬──────────────┬────────┐
   │  function │ spare │   address    │ length │
   └───────────┴───────┴──────────────┴────────┘

Here is an example of one such order as a sequence of 17 bits:

   1 1 1 0 0    0    0 0 0 1 0 1 1 0 1 0    0
   ─────────    ─    ───────────────────    ─
    function  spare         address       length

Function 11100, address 0001011010 — 90 in decimal — and a length bit of 0. The order instructs the machine to add the number held in location 90 to the accumulator. While this is how orders are represented and understood by the machine, it is not how one wrote programs for EDSAC. A programmer would write a sequence of characters representing the orders to be executed onto a paper tape that EDSAC read into its store and then ran. Below is an example program that represents a small routine that divides by repeated subtraction, leaving the remainder in location 92. The left-hand column depicts where the order is stored, the middle column is the sequence of characters that encode the order, and the right column describes what the order does.

 store     order      description
   56      A 90 F     add the number in location 90 into the accumulator
   57      S 91 F     subtract the number in location 91 from the accumulator
   58      E 57 F     if the accumulator is positive, continue at location 57
   59      T 92 F     store the accumulator into location 92 and clear it

While the above structured representation is helpful to understanding the procedure, the actual program written to the paper tape didn’t contain the store location of instructions, the description or any white space. The tape would simply read A90FS91FE57FT92F and then relied on being loaded into the store from location 56 onward.

The description in that third column, and everything else a reader would need, sat outside the tape entirely. What a routine was for lived on its manuscript — a separate sheet, written out by hand, setting out the same orders with commentary beside them. Even the program’s name was kept outside the program: the practice was to write it on the tape in pencil.

The gap between the bits in the store and the characters on the tape is smaller than it appears. A paper tape records characters as five-bit teleprinter codes, and an order’s function field is five bits wide — so the order code was numbered to make the two coincide. 11100 is at once the function code for add and the teleprinter code for the letter A; 01100 is both subtract and the letter S. The letter punched on the tape was the function field, already in binary, with nothing to convert. As Martin Campbell-Kelly writes in his Tutorial Guide to the EDSAC Simulator, this “simplified the translation of the symbolic program considerably.” It is an early instance of hardware being shaped around the convenience of programming: the order code was numbered to suit the notation a person would write on the tape.

The addresses got no such help, and that is where the difficulty lived. 90, 91, 92, and 57 are absolute store locations, fixed at the moment the routine was written. Suppose you now need one more order between the ones at 57 and 58. Everything below the insertion shifts down a word: the two remaining orders move to 59 and 60, and the three numbers this routine works on, held after the program, move to 91, 92, and 93. So A 90 F, S 91 F, and T 92 F must each have their address increased by one, by hand, in the listing. The jump E 57 F points backward, to a location before the insertion, and must be left exactly as it is.

Note which orders those were. The branch is the one you would think to check, and it is the one that turns out to be fine; the arithmetic orders are what broke, because the data moved even though nothing about the arithmetic changed. That is the whole difficulty in one observation, and it is the documented reason the machine’s loading program was rewritten within months of its first run. In Campbell-Kelly’s account, addresses “had to be coded in absolute form: this meant, for example, that if an extra instruction had to be inserted in a program then the addresses in many of the branch instructions would need to be altered. This made program debugging very tedious.”

And the store itself records none of the distinctions you are relying on. An order is a 17-bit word; so is a number. Which words in the store are instructions is settled by which of them the machine is told to execute, and nothing marks a word one way or the other — which was not merely a theoretical point, because the EDSAC had no index registers. To walk along an array in a loop, a program had to reach into its own orders and increment the address field, treating an instruction as arithmetic data because that was the only way to do it.

From here on, each development moves some piece of bookkeeping off the programmer and into the machine, the program, or the notation itself: where an order sits in the store, which routine performs a standard task, which instructions compute an expression, which declaration a name refers to. Each was also resisted by the programmers who had been doing that bookkeeping by hand, for reasons that were generally sound at the time.

Names for places

The EDSAC’s order code had already made the operation symbolic. The next step was to do the same for the address: let a program work out where things ended up, so that a person does not have to.

The machine’s earliest loading program, the initial orders of spring 1949, read the tape and placed orders in the store, converting decimal addresses to binary. It lasted about three months before its limitations — the absolute addresses above, and the difficulty of keeping a library of subroutines when nothing could be moved — became intolerable. Wilkes handed the problem to David Wheeler, then a research student, with the constraint that the replacement fit in 42 orders. Wheeler’s Initial Orders 2, in service by September 1949, is what Campbell-Kelly calls “the forerunner of the modern assembler,” and was celebrated at the time as “the leading example of programming virtuosity.”

Initial Orders 2 introduced named locations. Fifteen words of store, locations 41 to 55, are each named by a letter, and every order on the tape ends with one of those letters. As an order is loaded, the value held in the named location is added to it, and the sum is what goes into the store. This is why programs are loaded from location 56 onward: the fifteen names sit immediately below.

Three of the names have a fixed convention. F names a location holding the constant value 0, so an order ending in F is stored exactly as written. D names a location holding the constant value 1, so an order ending in D is stored with its length bit set, which denotes its operand as long. θ names a location holding the origin of the routine currently being loaded, set by a directive at the head of each routine. When used, θ makes the address bits of an order relative to the routine’s start rather than absolute.

  the tape says          loaded at 56          loaded at 200
     A 5 θ          →       A 61          or       A 205

This allows one to write a program and have it be correct no matter which location the program is written to. This is relocation, and it is what makes writing a subroutine library possible: a routine can be written once, by someone with no idea which program will use it or what else will be in the store, and can be stored in any available region of the store.

In addition to the three above names, there are twelve names that by convention can be set by the programmer before a subroutine is written to the store and used to denote a region of store locations. For example, a programmer might use the name M to hold the location 90 (where they want to store their program’s data) and then use M within their program to access data elements without worrying where exactly that region of data lives within the store. Using these named locations, we can rewrite the division routine from earlier as follows:

 order       description
 A 0 M       add the number at M+0 into the accumulator
 S 1 M       subtract the number at M+1 from the accumulator
 E 1 θ       if the accumulator is positive, continue at θ+1
 T 2 M       store the accumulator at M+2 and clear it

No absolute location appears anywhere in it. The branch says back to the second order of this routine rather than back to 57; the operands say the first three words of my data rather than 90, 91, and 92. The routine can now be loaded anywhere. Its data can be moved by changing the one value held for M, and the arithmetic orders that broke in the earlier version — the ones that broke because the data had moved rather than because the arithmetic had changed — need no editing at all.

Branches within a routine are not covered. Insert an order ahead of the target and E 1 θ must still become E 2 θ by hand: the position is relative to the routine, but it is still a position. Code letters removed the surprising half of the problem and left the half a programmer would think to check. Removing the rest takes names for the positions themselves — labels, as every assembler since has had — which the EDSAC never acquired.

The same idea was arriving elsewhere. Kathleen Britten and Andrew Booth’s 1947 report Coding for A.R.C., written for the relay machine they had built at Birkbeck College, is among the earliest documented descriptions of a symbolic notation for a machine’s orders together with the program that translated it. Wilkes, Wheeler, and Gill’s 1951 account of the Cambridge system is both the first programming textbook and the first published description of a library of reusable subroutines.

What this step leaves alone is as important as what it changes. One order on the tape is still one order in the store. The program says, in sequence, what the machine is to do, and a programmer reading it can still predict every instruction that will execute. That correspondence is why the objections to this step were mild, and why the objections to the next one were not.

Automatic programming, and the first serious objection

The abstraction so far has been about where: a code letter stands for a location and the loader works out the number, though a programmer still writes every order that runs. The next step abstracts what. If a loading program can place a subroutine and fix up its addresses, it can also be asked for one by name — write take a square root, and the routine computing it is supplied and fitted in. From here a program is assembled out of routines, and which orders carry them out is settled by the program doing the loading. In 1952 Grace Hopper described a system doing exactly that — the A-0, running on the UNIVAC — in a paper called “The Education of a Computer”. The argument she makes there is about where the labour goes:

This situation remains static until the novelty of inventing programs wears off and degenerates into the dull labor of writing and checking programs. This duty now looms as an imposition on the human brain. Also, with the computer paid for, the cost of programming and the time consumed, comes to the notice of vice-presidents and project directors.

Her conclusion is that the programmer, handed a catalogue of subroutines, “may return to being a mathematician” and “does not even need to know the particular instruction code used by the computer.” The machine assembles the program; the person states the problem.

The objection to this was immediate, widespread, and for the time correct. Writing about the same period, John Backus is blunt about how the systems actually performed: “All of the early ‘automatic programming’ systems were costly to use, since they slowed the machine down by a factor of five or ten.” A factor of five is not a rounding error on a machine whose time is billed by the hour. And the people objecting were not being territorial. Backus’s own summary claims that these positions were justified:

Before 1954 almost all programming was done in machine language or assembly language. Programmers rightly regarded their work as a complex, creative art that required human inventiveness to produce an efficient program.

From that viewpoint, it was efficiency in particular that looked impossible to automate:

Experience with slow “automatic programming” systems, plus their own experience with the problems of organizing loops and address modification, had convinced programmers that efficient programming was something that could not be automated.

Both halves of the debate were grounded in daily experience. The “address modification” mentioned is the problem from earlier in this chapter — a loop over an array rewriting its own orders — and Backus lists the lack of index registers first among the difficulties the computers of that era created. The objection came from people who had been doing that work by hand for years.

So there were two true statements in circulation. Automatic programming produced programs several times slower than a competent person would write. And, as Backus notes elsewhere in the same paper, the cost of the programmers at a computing centre was “usually at least as great as the cost of the computer itself,” with programming and debugging accounting for as much as three quarters of the cost of operating one. Both facts are correct. The disagreement was over which cost dominated, which is an empirical question about the state of the world. As machines got cheaper, programs got larger, and translators got better, the answer moved towards supporting the abstraction Hopper argued for.

Expressions

Fortran began at IBM in 1954, and its name is a contraction of “FORmula TRANslating System.” Backus had proposed the project in a letter to his manager in late 1953, giving the cost of programmers as one of his prime motivations. What it let a program state was a formula:

A + B * C

In the EDSAC orders earlier in this chapter, the sequence of orders is the order of operations: the programmer decides that the multiplication happens before the addition, finds a location to keep the product in until the addition needs it, and writes the orders that carry that out. The line above sequences nothing. It records which operations combine which values, and the translator derives a sequence from it. For an array element such as A(I,J) it also generates the arithmetic that turns two subscripts into the single address an instruction can name. Each of those had been done by hand, in a notebook, and redone after every edit.

The Fortran team understood the objections they were up against, and organized the entire project around answering them rather than around the language. Backus, looking back in 1978:

It was our belief that if FORTRAN, during its first months, were to translate any reasonable “scientific” source program into an object program only half as fast as its hand coded counterpart, then acceptance of our system would be in serious danger. This belief caused us to regard the design of the translator as the real challenge, not the simple task of designing the language.

While the above quote noted that half speed was the point of expected failure, the target for acceptance was much higher: Backus writes that a system of this kind would be widely used only if it could be shown to produce programs “almost as efficient as hand coded ones” and to do so “on virtually every job.” Fortran shipped in 1957 with an optimizing compiler because that target was far beyond anything an automatic system had reached; the earlier ones ran five to ten times slower than hand coding.

The earlier systems’ slowdown had gone mostly into floating-point subroutines, which let those systems get away with crude housekeeping: simulated indexing and loop control cost far less time than the floating-point arithmetic they surrounded. The IBM 704 incorporated hardware instructions for floating-point operations and indexing, speeding the arithmetic up by a factor of ten and, in Backus’s phrase, “leaving inefficiencies nowhere to hide.” The largest component of the old slowdown was gone, and the bookkeeping left over — loops, tests, subscript arithmetic — was now a visible share of the program’s running time.

Generating good code for the 704 was the hard problem, and designing the language looked like the simple part in 1954. The rest of this book is a record of how much was hiding in language design — scope, binding, types, evaluation order, and what a function even is. Backus, writing in 1978, still held that the priority had been the right one: had they failed to produce efficient programs, “the widespread use of languages like FORTRAN would have been seriously delayed.” Later languages could afford to sit further from the instruction set, and to give up more against hand-optimized code, because a translator had already been shown to close that gap.

Fortran I did what it set out to do, and it did it for one machine: the compiler turned formulas into IBM 704 instructions. However, the formulas that programmers wrote (e.g., A + B * C) were just arithmetic expressions; which instructions computed them was left to the translator. This is what later made it possible to run the same program on a different machine, and it is what this book relies on when it asks what a construct means separately from how it runs.

Structure

Algol 60 arrives at the start of the next decade. The abstractions so far had mechanized the bookkeeping that programmers were already doing. Algol 60 introduced ideas about programs that had no counterpart in machine code, in assembly, or in Fortran I. Hoare, in “Hints on Programming Language Design” thirteen years after the ALGOL 60 report and with no particular reason for flattery, wrote:

The more I ponder the principles of language design, and the techniques which put them into practice, the more is my amazement and admiration of ALGOL 60. Here is a language so far ahead of its time, that it was not only an improvement on its predecessors, but also on nearly all its successors.

Algol’s practical reach was narrower than Fortran’s — scientific and industrial computing ran on Fortran for decades — but the report’s influence on how languages are described and designed is hard to overstate.

Algol 60 introduced block structure: a program has nested regions, and a name declared inside a region means something there and not outside it. On the EDSAC, location 90 was location 90 for the whole run; a code letter held one value at a time, for whoever was loading. There was no notion of a name having a region of validity, because there was nothing for such a region to be made of. Block structure made “where does this name refer to?” a question with a principled answer, determined by the program’s own shape. Part III of this book is spent on that answer and on what it takes to implement it.

The Algol 60 report specified the language’s syntax in a formal notation — what is now called Backus–Naur Form (BNF) — rather than in prose and examples alone. Syntax and semantics picks this up directly; for now the relevant point is that it became possible to ask whether a program belonged to the language, and the answer did not depend on any particular compiler. Algol 60 was defined by an international committee, in a report, before there was a compiler for it. That inverted the previous arrangement, under which a language was whatever the translator happened to accept and the manual described that translator’s behaviour. Once the definition is the document, several implementations can exist and be compared against it, and the question “is this a compiler bug or a program bug?” becomes answerable.

What does it mean to be “high-level”?

The label “high-level” has moved at every step in the history this chapter describes and has not stopped since. Assembly was the automatic programming of 1950 and became the baseline against which Fortran was accused of inefficiency. C was classed among the high-level languages when it took shape in 1972, though Kernighan and Ritchie hedged even then: The C Programming Language (1978) calls it “a relatively ‘low level’ language.” Today the hedge is gone; C is simply a low-level language, and not much about C itself has changed.

The reason is that “high-level” measures a distance from the machine, and both ends of that measurement move: the languages rise, and the machine itself becomes an abstraction. A modern processor’s instruction set is an interface over a microarchitecture that pipelines and reorders instructions, renames registers, predicts branches and speculates past them, and — on a multiprocessor — allows one core to observe another’s writes in an order the program never wrote. Even hand-written assembly no longer says what will physically happen, let alone in what order another core will see it happen. The bottom of the stack is a moving target too.

Which suggests that “how high-level is this language?” is a weaker question than the two it usually stands in for:

  • What does this notation let me stop saying? Every step above answers this concretely: addresses, instruction selection, temporaries and evaluation order, the region in which a name is valid.
  • What does the language now decide on my behalf, and can I predict what it will decide?

The second question is where every objection in this chapter lived. “It will be five times slower” and “efficient programming cannot be automated” are both versions of I can no longer predict what the machine will do, which in 1954 was the same worry: what the translator decided was exactly which instructions ran. That concern never went away, and it should not: it has only changed granularity. Whether a line allocates, whether a loop will vectorize, when the collector will stop the program, what an optimizing compiler is permitted to assume, which language to write a kernel in — each is the same question asked about a taller stack. What changes is the answer, and the answer depends on facts about hardware, program size, and what people’s time is worth.

How language features come to be

Every abstraction built in the rest of this book arrived by the route just described. Scope, static types, first-class functions, pattern matching, automatic memory management: each was once a proposal that someone had to argue for, against people who could point at a real cost and were not wrong to. By the time an idea reaches a textbook it looks as if it were inevitable, and that appearance hides both how the idea came about and what the alternatives to it were.

When this book introduces a concept, it starts where the concept started: with the problem it solves, the objections raised against it, and the different answers other languages settled on, many of them still in use. Appendix F lays the same material out in date order, for readers who want the arc in one place.

Further reading

Before there were languages: Williams and Kilburn’s letter “Electronic Digital Computers” (Nature, 25 September 1948) runs a few hundred words and announces that a machine in Manchester had been running stored programs for some weeks. That machine is the Manchester Baby, which opens this chapter, and the letter is the first public notice that a program held in a machine’s own memory had actually run. For the machine in detail, including the input device and the surviving text of the first program, see Simon Lavington, A History of Manchester Computers, 2nd ed. (British Computer Society, 1998), and the University of Manchester’s Computer 50 archive. Kathleen Britten and Andrew Booth’s Coding for A.R.C. (1947) is an early written account of a symbolic notation for a machine’s orders and the program that translated it; Britten — later Kathleen Booth — wrote it as a research assistant at Birkbeck College. It is frequently called the first assembly language.

Programming the EDSAC: Cambridge’s EDSAC collection holds the machine’s order code and its operating log, kept by chief engineer W. S. Renwick and transcribed by David Wheeler; the entry for 6 May 1949 reads in full: “Machine in operation for first time. Printed table of squares (0-99), time for programme 2 mins. 35 secs. Four tanks of battery 1 in operation.” Wilkes, Wheeler, and Gill’s The Preparation of Programs for an Electronic Digital Computer (1951) is the first programming textbook and the first published description of a subroutine library: its second half catalogues the EDSAC library subroutines by category, Chapter 7 works through five complete programs, and Chapter 8, “Automatic Programming,” covers assembly, floating addresses, and formula recognition — in 1951. Martin Campbell-Kelly’s Tutorial Guide to the EDSAC Simulator (EDSAC Replica Project, The National Museum of Computing) is the source of the order code and instruction format described above, and comes with a simulator that runs the historical programs and any you write yourself.

Automatic programming and its critics: Grace Hopper’s “The Education of a Computer” (1952) makes the case for automatic programming, argued from the economics of programmer time rather than from elegance. Backus’s “The History of Fortran I, II, and III” (1978, delivered at the first of the HOPL conferences below) records what the objectors said and why they were not being foolish; its opening sections on attitudes toward “automatic programming” and on the economics of programming in the early 1950s are the best short account of why this was a real dispute. For the system as its authors presented it at the time, before it had a history, see “The FORTRAN Automatic Coding System” (1957).

Defining a language: Naur’s “Report on the Algorithmic Language ALGOL 60” (1960) is short and still readable — skim §1 to see a language’s syntax being specified formally for the first time. Hoare’s “Hints on Programming Language Design” (1973) is design advice from the era when designing the language had stopped looking like the simple part, and the annotated reading list in its appendix is a short opinionated bibliography of the field as it stood then.

Designers on their own work, and surveys: Dennis Ritchie’s “The Development of the C Language” (1993) traces C to its BCPL and B lineage and to the PDP-11, a designer describing decisions rather than defending them. It appeared at the second of the History of Programming Languages conferences, whose proceedings — HOPL I (1978), HOPL II (1993), HOPL III (2007), and HOPL IV (2021) — are the standard place to find designers writing the history of their own languages, with the partiality that implies and the detail nobody else could supply. For a survey of the whole field rather than a single language, Jean Sammet’s Programming Languages: History and Fundamentals (Prentice-Hall, 1969) covers more than a hundred languages while most of them were still in use, and records what the field thought it was doing before anyone knew which lines would matter. Donald Knuth and Luis Trabb Pardo’s “The Early Development of Programming Languages” — Encyclopedia of Computer Science and Technology, vol. 7 (1977): 419–493, reprinted in Knuth’s Selected Papers on Computer Languages (CSLI, 2003) — is a systematic survey of the pre-Fortran systems this chapter compresses into a paragraph, including several that were ahead of anything described here but left no descendants.

Concept checks

The EDSAC's function codes were symbolic from the start; its addresses were not. Why did that asymmetry exist, and what did fixing it require?

The function code was free. Numbering the order code so that each function’s bit pattern is the teleprinter code of its letter costs nothing at all — it is a choice about which numbers to assign, made once, and after it the letter on the tape is the function field. An address cannot be handled that way, because its correct value is not known when the order is written: it depends on where the routine ends up in the store and on how many orders precede it, both of which change on every edit.

Fixing it therefore required something the function code did not: a program that computes at load time. Initial Orders 2 added a value to each order as it was placed, so the address could be written relative to an origin fixed later. This is the general shape of the thing — an abstraction is usually a way of writing down information that was previously only in someone’s head, and the ones that are hard are the ones where the information is not available until later.

Programmers in 1954 objected that automatic programming produced code five to ten times slower than hand-coding. Was the objection correct?

Yes, as a description of the systems then available — Backus, who was on the other side of the argument, says so plainly. The objection stopped holding for reasons largely outside the argument: compilers improved, machines got cheaper while programmer salaries did not, and programs grew past the size at which hand-optimizing all of one is possible. There is also a wrinkle in the other direction: the IBM 704’s hardware floating point made translated code’s inefficiency more visible rather than less, by removing the slow subroutines it had been hiding behind. The lesson is that an engineering objection is usually a claim about current conditions, and stays true only as long as the conditions do.

Assembly and Fortran both transfer bookkeeping to the machine. What does Fortran give up that assembly keeps?

Assembly preserves a one-to-one correspondence: one line, one instruction, and a reader can predict exactly what executes. Fortran breaks it. The translator chooses which instructions to emit, where to put temporaries, and how to compute a subscripted address, so the same source can produce different instruction sequences on different compilers or machines — and the program no longer describes any particular machine’s behaviour. That break is what made the efficiency objection serious, and it is also the property that later made the same program runnable elsewhere. The two are the same fact seen from opposite sides.

ALGOL 60 was specified in a committee report before any compiler for it existed. What does that arrangement make possible, and how can it fail?

It makes the language independent of any one implementation: several compilers can exist and be judged against a common definition, disagreements become answerable by citing a document, and “compiler bug” becomes a meaningful accusation. It also makes a language possible to study — the report can be read, criticized, and used as a model, which is most of why ALGOL’s influence outran its use.

The failure mode is specifying something nobody knows how to implement well, or that means less than it appears to. ALGOL 60’s call-by-name parameters are the standard example: cleanly defined in the report, surprising in behaviour, and awkward to compile (Evaluating function calls explores this in detail). A committee writing without an implementation has nothing forcing it to discover the cost of an idea.

"High-level" has been applied to assembly, to Fortran, and to C, and withdrawn from all three. What question is worth asking instead?

Two, both concrete. What does this notation let me stop saying? — addresses, instruction selection, evaluation order, the extent of a name’s meaning; the answer is a list, and lists can be compared across languages. And what does the language now decide for me, and can I predict what it decides? — which is where the real disagreements are, historically and currently. Every objection in this chapter is a version of the second question, and so are modern arguments about garbage collection pauses, undefined behaviour, and what an optimizer may assume. “How high-level is it?” only measures distance from a baseline that keeps moving, in both directions.

The Paradigm Branches

The previous chapter followed a single idea of what a program is. Machine code gave way to assembly, assembly to Fortran and Algol, and each step handed more bookkeeping to the translator — addresses, instruction selection, the region in which a name is valid. What none of those steps touched was the idea underneath: a program is a sequence of commands that change the machine’s state, one after another. Fortran raised the level of the commands and Algol organized them into blocks, but a program was still a list of commands to modify the machine’s store.

That idea is an answer to a question the languages in that chapter never stopped to ask, because the machine answered it for them. The question is: what is a program? The store-and-command picture is one answer, and it is the machine’s own answer, lifted through levels of abstraction; however, there are other notions of what a program is. These notions differ from the store-and-command notion, and from each other, not in how their programs are spelled but in what they take a program to be. This chapter lays out the main answers and the communities that gave them, and marks which part of the book describes each one.

What does it mean to be a program?

The word paradigm entered programming through Robert Floyd’s 1978 Turing Award lecture, which borrowed it from Thomas Kuhn’s account of how a scientific field settles on a shared way of framing its problems. A programming paradigm is that: a shared idea of what a program is and how you go about writing one. Four such ideas organize most of the languages in use, and each can be stated as an answer to what is a program?

A sequence of commands. A program is a list of instructions that read and change a shared, mutable state, carried out in order. This is the imperative view of what a program is. This view derives from the machine’s model of a program, often abstracted to higher-level notation: assigning to a variable, looping, iterating through an array. Each of these operations is about doing something to the state of the program that persists between each step of the program.

Consider the following task: given a set of directed edges and a starting node, which nodes can be reached? Below is an imperative program that accomplishes this task. The program works by keeping two pieces of state: a set of nodes already visited, and a frontier of reached nodes whose outgoing edges it has not yet examined.

visited  = { start }
frontier = [ start ]
while frontier is not empty:
    n = frontier.pop()
    for each edge (n, m):
        if m not in visited:
            visited.add(m)
            frontier.push(m)
return visited

Both visited and frontier hold only the start node in the beginning. Each pass through the loop takes a node off the frontier, looks at every edge leaving it, and for each target it has not seen before adds that target to the visited set and pushes it onto the frontier to examine later. When the frontier is empty, every reachable node has been visited, and visited is the set of nodes reachable from start.

The application of functions to values. A program is an expression, and running it means evaluating that expression to a value. There is no store being modified in the background; a name stands for a value and is not modified. This is the functional view of programming. Its origins predate any computer, in Alonzo Church’s λ-calculus of the 1930s, a formalism in which everything is a function and computation is substitution. John McCarthy’s Lisp, whose eval we discussed previously, was the first functional language, borrowing Church’s notation for functions if not the whole calculus. Where the imperative program says do this, and update that, the functional program says the answer is this expression, and leaves the order of evaluation as a detail.

Below is a functional program for the same graph reachability task. It names the reachable set as the result of growing a set of reached nodes one edge-step at a time, until a step adds nothing more:

step(seen) = seen ∪ { m | n ∈ seen and (n, m) is an edge }

grow(seen) = if step(seen) = seen then seen else grow(step(seen))

grow({ start })

The function step takes a set of nodes reached within n steps and returns a new set with every node reached within n+1 steps, without modifying the input set. The function grow repeatedly applies step until the set of seen nodes has reached a fixed point (the same before and after applying the step function). That stable set of nodes is the answer; it is obtained by evaluating an expression rather than accumulated by a loop that updates the set of seen nodes.

A set of facts and rules. A program states what is true — these edges exist; a path exists when an edge does, or when a path reaches an edge — and running it means asking what else must be true and letting the system work it out. Nothing in the program says how to search. This is the declarative, or logic, view of programming. It grew out of automated theorem proving in the early 1970s, when Robert Kowalski argued that a piece of logic could be viewed as a program and Alain Colmerauer’s group in Marseille built Prolog around that view. Bridger’s relational sublanguage takes the same idea in a smaller, always-terminating form.

Below we show how the graph reachability task is encoded as a declarative program. The program itself prescribes no search mechanism; the two clauses state what it means to be reachable from the start node, and finding the nodes that satisfy the clauses is the system’s job:

reach(start).
reach(Y) :- reach(X), edge(X, Y).

The first clause says the start node is reachable. The second says that if X is reachable and there is an edge from X to Y, then Y must also be reachable. Nothing names a node to visit first, and no set is maintained; the program stores no state and describes no procedure. When one asks which nodes are reachable, the system returns the smallest collection of facts that makes both clauses true.

A society of objects exchanging messages. A program is a collection of objects, each holding some private state and knowing how to respond to requests; computation happens as they send messages to one another. This is the object-oriented view of programming. It appeared in Simula 67, built by Ole-Johan Dahl and Kristen Nygaard to describe simulations in which each entity carries its own state, and was made central by Alan Kay and the Smalltalk group, for whom the messages between objects mattered more than the objects themselves.

Recall the graph reachability task. In the object-oriented program below, the graph becomes a collection of node objects, each holding its own list of neighbors, and the search happens as the nodes send one another a reach message:

class Node:
    neighbors                     # the nodes this one points to

    method reach(visited):
        if self ∈ visited: return
        visited.add(self)
        for each n in neighbors:
            n.reach(visited)      # send the same message onward

start.reach({ })

Each node, on receiving reach, records itself in the shared visited set and then sends the same message to each of its neighbors, which do the same. The visited set both collects the answer and keeps a node from responding twice, so a cycle does not loop forever. The traversal is the imperative one from before, redistributed: no central loop drives it, and the work is carried by objects passing a message along the edges.

What differentiates paradigms

While many imperative programs share similar syntactic forms — and similarly for functional, object-oriented, and declarative programs — the form of a program does not dictate which paradigm it belongs to. The declarative reachability rules could be written in a C-like block syntax, or the imperative worklist algorithm above in Lisp-style s-expressions, without either changing paradigm. What decides the paradigm is what a language treats as primitive and free, and therefore what it lets you leave unsaid.

The imperative program leans on a mutable store as something that is simply there: a variable is a box you can overwrite, and overwriting it costs nothing to express. The functional program has no such box — a name binds to a value and the binding does not change — so anything resembling a running change must be expressed some other way, by producing a new value rather than editing an old one. The declarative program treats search as free: it never says how the answer is found, because supplying the how is the system’s job, not the programmer’s. What one paradigm makes implicit, another makes you spell out.

A consequence is that the paradigm is not a property of a language’s grammar, and one language can host more than one. A programmer can write in a functional style inside an imperative language by refusing to mutate and building with recursion and functions that take other functions as arguments; a programmer can write imperative code inside a functional language wherever it provides a way to. The paradigm a program belongs to is the set of defaults it works with — what it treats as primitive and what it leaves implicit — and a language belongs to a paradigm by making one set of those defaults cheap and the others deliberate.

This is why paradigm fit is something a working programmer can feel. Reachability written imperatively carries a visited set and a worklist that are bookkeeping, present only to drive the search; the same problem written as rules has neither. On the other hand, for a task like a running total over a stream of inputs, or an in-place simulation that updates a grid each tick, the imperative version is succinct, while the declarative framing strains to express a process that is inherently about change over time. The awkwardness comes not from the program being wrong, but from the problem not fitting the strengths of the paradigm it is expressed in.

Multi-paradigm languages

While this chapter names four paradigms, most programming languages let programmers express programs across two or more of the paradigms. Lisp, a typical example of a functional language, had an assignment operator early and has allowed programs with mutable state ever since. In Smalltalk, each method of an object has an imperative body. Most languages a reader is likely to know support multiple paradigms: Python has objects, first-class functions, and imperative loops all at once; Rust, the language this book asks you to write the Bridger interpreter in, has all three as well, and a type discipline over them. Peter Van Roy’s survey of the field catalogues on the order of thirty paradigms once combinations are counted. This is a sign that the paradigms are overlapping views of a program rather than distinct ones: a paradigm is really about the decisions a language makes about what it supports and makes freely available to the programmer.

A paradigm, then, is a language’s center of gravity — the framing it makes cheapest and treats as primitive — and not a strict inability to write programs in a different paradigm. Floyd’s lecture already pointed this way: he treated paradigms as techniques a programmer should collect and move between as the problem demands, rather than allegiances to hold. The taxonomy is worth having for exactly the reason it is worth distrusting: it names real differences in what a language makes easy, while most real languages support multiple paradigms with varying ease.

Bridger’s philosophy

Bridger is built to be multi-paradigm, so every part of this book adds to one language rather than touring several. Keeping the comparison inside a single language is what makes it clean: with one syntax, one evaluator, and one notion of a value, a difference you see between two programs is a difference in the idea rather than an artifact of four separate syntaxes, toolchains, and libraries. One language also lets a single program cross paradigms — building a structure imperatively, querying it relationally, folding the results functionally — which is where the trade-offs stop being abstract. And with every framing within reach, you can feel which one a problem wants: you reach for the one that makes the problem short, and notice when a framing fights you.

The core is imperative and functional together: an expression-oriented language, in which even a loop or an assignment produces a value, with first-class functions and closures on one side and mutable references on the other. Onto that core the book adds a static type discipline, a Datalog-style relational sublanguage for the declarative framing, algebraic data types for modeling structured data, and objects assembled from those data types with methods. Each is introduced where the book reaches it, motivated by the problem it answers and set against the alternatives other languages chose.

Looking ahead

The branches named here are built in the order that lets each rest on the last.

  • The imperative core is the subject of Parts II through IV: evaluating expressions to values, binding names in an environment, and then the state and control flow — mutation, conditionals, loops — that make a program a sequence of steps over a store.
  • The functional branch arrives in Part V, with first-class functions, closures that capture the environment they were written in, and the recursion and higher-order functions that let a program be built by combining functions rather than by issuing commands.
  • The type discipline follows in Part VI, as a pass over the core that analyzes programs before they run and rejects any that would use a value in a way its type forbids.
  • The declarative branch is Part VII: relations defined by facts and rules, evaluated by finding the smallest set of facts closed under the rules, and queried from ordinary code.
  • Algebraic data types in Part VIII give a precise way to describe structured data and take it apart by pattern matching, and they are the material the last branch is built from.
  • The object-oriented branch closes the build in Part IX, as structs carrying state together with the methods that act on them.

The final part sets the branches against one another on shared problems, where choosing the framing that fits is the whole exercise. Appendix F places the languages and ideas named above in date order, each linked to the chapter that takes it up in full.

Further reading

A paradigm as a unit of thought: Robert Floyd’s Turing Award lecture “The Paradigms of Programming” (1979) is where the word, borrowed from Thomas Kuhn’s The Structure of Scientific Revolutions, entered the field. Floyd’s argument is that paradigms are techniques to be identified, taught, and expanded, and that a language should support more than one — the framing this chapter and this book take. Peter Van Roy’s “Programming Paradigms for Dummies: What Every Programmer Should Know” (2009) maps the paradigms against one another by the concepts they add, and reaches roughly thirty once combinations are counted — the concrete form of the claim that the pure cases are the exception.

The functional framing against the imperative default: John Backus’s Turing lecture “Can Programming Be Liberated from the von Neumann Style? A Functional Style and Its Algebra of Programs” (1978) argues that the command-and-store model carried from the hardware is a limit on how programs can be built, names the “von Neumann bottleneck,” and proposes a functional alternative — a designer of Fortran arguing against the style Fortran helped establish. Part V takes up the functional lineage in full.

Logic as a programming language: Robert Kowalski’s “Algorithm = Logic + Control” (1979) makes the case that a program has a logical content, saying what is true, separable from the control that says how to search — the idea under the declarative framing. Alain Colmerauer and Philippe Roussel’s “The birth of Prolog” (HOPL II, 1993) is the account of how that idea became a language.

Objects and the messages between them: Alan Kay’s “The Early History of Smalltalk” (HOPL II, 1993) traces the object-oriented framing from Simula through Smalltalk and argues that the messaging between objects, more than the objects themselves, is the idea that matters. Part IX builds objects from the book’s own data types.

Concept checks

Two languages have nearly identical syntax, yet one is called functional and the other imperative. How is that possible?

Because a paradigm is fixed by what a language treats as primitive and leaves implicit. Give one language a mutable store as a free primitive — a variable is a box you overwrite — and its natural programs change state in place. Give the other only value bindings that do not change, and its natural programs produce new values instead. The two can share a grammar down to the punctuation and still make opposite things cheap to express. Syntax is the changeable surface; the paradigm is in the defaults underneath it.

Reachability was short as two rules and longer as a loop. What, specifically, does the declarative version leave out that the imperative one must include?

The search procedure. The imperative version carries a visited set and a worklist and specifies the order in which nodes are taken up and marked — all of it bookkeeping that exists only to drive the computation, none of it part of what reachability means. The two rules state only the meaning: the start is reachable, and following an edge from a reachable node reaches another. The paradigm treats the search as the system’s responsibility, so the program never mentions it. The imperative paradigm treats the store and the stepping as primitive, so the program is written in terms of them.

Bridger puts every branch into one language instead of teaching four separate languages. What would comparing four separate languages confound, and what is given up by not doing it?

With four languages, any difference you observe between two programs could be the paradigm or it could be one of four syntaxes, four sets of values, four toolchains and standard libraries. Holding all of that fixed and varying only the paradigm is what makes an observed difference attributable to the idea rather than the accident — which is why the whole language is one syntax and one evaluator. What is given up is contact with the real languages as they are actually used, with the mature libraries and idioms that a paradigm accretes in practice; the further-reading pointers and the design-space discussions in each part are where those enter.

Name a language usually placed under one paradigm and a place where it clearly leaves that paradigm. What does the existence of such examples say about the taxonomy?

Lisp is typically considered a functional language, but has had assignment since early in its life; Smalltalk is considered and object-oriented language, but its method bodies are ordinary imperative code; Python is taught with objects but has first-class functions and imperative loops in equal standing. The examples are easy to find because the pure cases are the exception. What they show is that the four headings mark a language’s center of gravity — the framing it makes cheapest — rather than a boundary it stays inside. The taxonomy is worth keeping because it names real differences in what a language makes easy, and worth distrusting because almost every real language combines more than one paradigm.

Syntax and Semantics

A programming language is described in two parts. Its syntax fixes what counts as a program: the rules that decide whether a string of characters is one at all. Its semantics gives each program a meaning: what it computes, and how. The two are separable: one meaning can be given more than one syntax, and one syntax more than one meaning. For example x / y in one language divides the values as integers and in another as reals. Similarly, within a single language x + y may add two numbers or concatenate two strings depending on what values x and y store.

When you have a bug in your program, it is either a syntax error — an error caused by misspelling your program: an unmatched parenthesis, a misspelled keyword, an unknown language construct — or a logical error: one that results in a program that behaves in an unexpected or inconsistent way. Syntax errors are typically trivial to detect and relatively easy to fix. Logic errors are harder: the program runs, but does the wrong thing, often in subtle ways. Much of what programmers spend their time on (i.e., testing, debugging, verification) is closing the gap between the program written and the program intended, and all of it rests on being able to say precisely what a program means. That meaning is the focus of this book.

Syntax: what counts as a program

A language’s syntax is the set of rules that decide which strings of characters are programs and what structure each one has: characters grouped into tokens, tokens assembled into a tree. Those rules are given by a grammar — a finite set of productions a mechanical procedure can check a candidate program against, and the same productions a parser follows to recover a program’s structure. Whether a string is a legal program is, once the grammar is fixed, a question with a definite answer.

Two layers hide inside the word syntax. The concrete syntax is the text as written — every parenthesis, the choice of && over and, the whitespace, the fact that multiplication binds tighter than addition. The abstract syntax is the structure that remains once those surface choices have done their one job of disambiguating: a tree commonly referred to as the abstract syntax tree (AST), which records only how the pieces of a program fit together.

The gap between the concrete and abstract syntax is easiest to see by holding the abstract structure fixed, while varying how that structure is concretely written. These three expressions are spelled by different rules:

x + y          (+ x y)          x y +

The first is infix, an operator written between its operands, the arrangement most languages inherit from arithmetic. The second is prefix, the operator first, as in Lisp, where every call takes this parenthesized form. The third is postfix, the operator last, as in the input to a reverse-Polish calculator. Three concrete syntaxes — yet a parser for any of them produces the same abstract structure, an addition with x and y beneath it, and an evaluator handed that structure cannot tell which spelling it came from. The surface can be redrawn without disturbing what is underneath, which is why this book starts at the AST: the structure is where meaning will be assigned, and the AST is the form the rest of the book works on.

In the decade preceding ALGOL 60, a programming language’s syntax was described the same way as a natural language: in prose, with examples, but ultimately determined by the programs its compiler accepted. The ALGOL 60 report gave the language’s syntax in a formal notation instead — now called Backus–Naur Form — precise enough that whether a string is a legal program became a mechanical question the report itself settled, ahead of and independent of any compiler. Backus had introduced the notation a year earlier for ALGOL 58, whose own report still described its syntax in prose; the 1960 report is where a formal grammar became the definition. Syntax has had a clean formal footing ever since.

Semantics: what a program means

Once a string counts as a program, the question becomes what that program means. Previously, we framed meaning as a mapping from a piece of syntax to a mathematical object — the value an expression evaluates to, or a coarser object such as its type. Formally, that mapping is called the semantics of the programming language. A semantics falls into one of two kinds, based on whether the meaning it assigns can be determined without ever running the program.

A semantics is static when the meaning it assigns can be determined by inspecting the program’s text alone, without ever running it. For example, determining whether every identifier a program uses is declared and in scope at each of its uses is a static question (cf. Part III) that can be answered without ever running the program on any inputs. Similarly, type checking is normally a static check (cf. Part VI): whether an operation is applied to a value of the right kind. Consider writing a program where a number is added to a function. A parser would happily parse the expression x + f, because at the time of parsing both x and f are simply names. Type checking, however, would determine that x is a name representing an integer and f is a function, and thus be able to reject the expression x + f as nonsense.

A semantics is dynamic when the meaning it assigns can only be determined by executing the program. The quintessential dynamic semantics is the concrete semantics: it traces how concrete inputs flow through the program to produce its concrete outputs. The interpreter you build throughout this book, beginning in Part II, computes exactly this, one construct at a time. When we ask what a program means, we typically mean its concrete semantics. A program can pass every static check and still not be the program we intended — i.e., the program can be well-formed, well-scoped, well-typed, and still compute the wrong answer. Static semantics is useful for confirming a program is well-behaved (as prescribed by the static check), whereas dynamic semantics describes its actual observed behavior.

A notation for meaning

ALGOL 60 gave a program’s syntax a formal notation, but its meaning was still written informally, in prose. The written description of the program’s semantics was ambiguous and allows multiple interpretations and open questions about how a program should behave. For example, should an operand short-circuit and not evaluate the right-hand operand when the left-hand one already decides the result? Does an assignment produce a value, and if so which one? Since prose can leave such questions open, two reasonable implementors can read the same description and produce two disagreeing implementations.

Over the years and decades since ALGOL 60, several notations were developed to give a program’s meaning unambiguously (cf. Program semantics). For evaluating expressions, this book uses the following notation

which can be read as in the environment , the expression evaluates to the value . The environment supplies the value of every name in scope, so a variable can be looked up (cf. Part III). The meaning of an expression is given by rules that tell us how to derive the meaning of an expression from the evaluation of the expression’s sub-expressions. Below we show three example rules in turn. The first rule says that the literal evaluates to itself — as does every literal — regardless of the environment.

The next rule states that the variable evaluates to the value assuming that in the environment the variable is bound to the value .

The final rule states that the sum of two expressions is simply the sum of the evaluation of each of those expressions.

This style of writing rules has deep roots in logic and computer science: that the fact below the line is true exactly when every assumption above the line is true. This style of rules dates back to Gentzen’s work in the 1930s on logic, and it’s been used by computer scientists to define how a program executes since Plotkin in 1981 (operational semantics) and Kahn in 1987 (big-step semantics). Throughout this book, we develop these rules into a full account of Bridger’s concrete semantics.

Does the spelling matter?

How a program is spelled shapes how easily it can be read, written, and understood. Over the years, concrete forms have converged on shared expectations about meaning: a for loop is read as iterating over a collection, x := e as updating the value a variable holds, infix + as arithmetic. A reader draws on those expectations from the spelling alone, before working through what any rule says. The surface governs what a language makes easy to express and easy to grasp, and a form that cuts against a reader’s expectations misleads about as readily as a poor name.

This book starts past that point, at the abstract syntax — not because spelling is a cosmetic detail, but because this book’s subject is meaning, and meaning is assigned to a program’s structure rather than its surface syntax. How a language chooses its surface, and how that choice shapes the programs people actually write, is a real question with a study of its own; this book simply begins where meaning does.

Further reading

Giving meaning a formal footing: after BNF gave syntax a formal definition, doing the same for meaning took most of the following decade and produced several styles at once. Glynn Winskel’s The Formal Semantics of Programming Languages: An Introduction (MIT Press, 1993) is an accessible book-length review of those semantics, building each from small example languages. C. A. R. Hoare’s “An Axiomatic Basis for Computer Programming” (1969) is the primary source for one of those styles, reasoning about what a program establishes rather than tracing what it does; the operational style the rule above is written in is developed further in Program semantics.

Whether notation shapes thought: Kenneth Iverson’s Turing Award lecture “Notation as a Tool of Thought” (1980) argues the design-space question from the side that says syntax is far from cosmetic — that a well-chosen notation does part of a programmer’s reasoning for them, using the array notation of APL to make the case.

Concept checks

A program is turned away before it runs because it uses a variable that was never declared. Is that a syntax error or a semantics error?

A semantics error — a static-semantics one, specifically. The program is well-formed as text: the grammar has a production for a variable reference, so the parser accepts it and builds an abstract syntax tree. Nothing about the shape of the program is wrong. What fails is a rule about meaning — that a name must refer to something in scope — which is checked by inspecting the program before it runs. It is easy to lump every error the tool catches up front under “syntax,” but scope and type checking are a form of semantic checks, applied to programs the grammar has already accepted.

The expressions x + y, (+ x y), and x y + are spelled by different rules. What do they share, and why can one evaluator serve all three?

They share their abstract syntax: each parses to the same tree, an addition with x and y as its operands. Infix, prefix, and postfix are three concrete syntaxes for that one structure. An evaluator is defined over the tree, not the text, so by the time it runs, the choice of spelling is gone — the information that distinguished the three was consumed by the parser in recovering the structure. A new surface syntax for the same language only requires a new parser and can continue using the same evaluator.

Why do we write the semantics as the rule `E-Add` defined above rather than describing it in English prose?

Because English leaves gaps precisely where meaning is contested. A sentence like “an addition evaluates its operands and adds them” does not say whether the operands are evaluated left to right, whether either is skipped, or what happens if one fails — and different implementers will fill those gaps differently, each thinking their implementation is the correct one. An unambiguous rule like E-Add fixes this issue: it names both sub-evaluations as premises and the combined result as the conclusion, with no room left for a reader to differ. That precision is what lets a definition also serve as a specification an implementation can be checked against.

If a language's surface syntax can be replaced without changing what its programs mean, is syntax merely cosmetic?

It is cosmetic to the meaning — swap infix for prefix and every program computes the same thing — and isolating exactly that is the job of the syntax/semantics distinction. It is not cosmetic to the programmer. How a program is spelled governs how easily it can be read, written, and understood, and concrete forms carry expectations that have converged over time — a for loop reads as iteration, x := e as updating a variable — so spelling shapes what a reader expects a program to mean and what is comfortable to write, and what is comfortable to write is what gets written. Two languages with identical semantics can therefore steer people toward different programs.

The Abstract Syntax Tree

Syntax and semantics separated the abstract structure of a program from the concrete syntax that disambiguates it: three spellings of an addition collapse to one tree. In this chapter, we detail the concrete implementation of the AST structure of Bridger programs that the parser provided with this book will have you use in the implementation Milestones. Specifically, this chapter details three Rust types: Expr for the expressions at the core of Bridger, Program for whole top-level declarations, and Ty for expressing types. For complete details on the concrete syntax of Bridger see Appendix B: concrete syntax.

The shape of expressions

Run down the grammar and an expression is always one of a fixed set of shapes: a literal, a variable, an operator applied to smaller expressions, a conditional, a call, and so on. A type whose values are “one of the following” is a sum type, and in Rust it is written as an enum: one variant per shape, each carrying exactly the pieces that shape is built from. The productions of a grammar and the variants of a sum type line up one for one, which is what makes the enum the natural representation of an AST.

That correspondence is old enough to have shaped which languages interpreters get written in. McCarthy’s LISP (1960) represented programs as the very lists the language processed — code as data — so a function that transformed a program was an ordinary list-processing function. The ML family later made the shapes explicit as algebraic data types and took them apart with pattern matching, a fit close enough that interpreters and compilers have been written in ML-family languages ever since. Rust inherits both halves: the enum names the shapes, and match (below) takes them apart.

A tree records structure and drops the surface detail the parser has already used up — the parentheses, the whitespace, the fact that * binds tighter than +. What survives is how the pieces fit together, and that is what the type below captures.

Bridger’s Expressions in Rust

The core of Bridger’s syntax is made up of recursively constructable expressions. This section details the components that make up such expressions and how the Book’s companion parser represents the expressions it parses. For convenience in error reporting, every parsed expression includes a span which records where in the source text the expression is located.

struct Span { src: SrcId, start: usize, end: usize }

The src says which loaded source the bytes belong to — the prelude is one source and the program another — so a span names a node uniquely even across files.

A Literal is represented by the following Lit enum. A numeric literal is represented as a 64-bit signed integer in Rust (i.e., all numeric values in Bridger are between and ); a Boolean literal is simply a Rust bool type; a string literal is simply a Rust String, and a unit literal is the singleton value () left implicit in the Lit enum.

enum Lit { Int(i64), Bool(bool), Str(String), Unit }

Before detailing how we represent expressions, we detail several auxiliary types. The enum UnOp represents all unary operators in Bridger. Similarly, BinOp all binary operators. Finally, all names (identifiers and variables) are represented as a String.

enum UnOp { Neg, Not, Ref, Deref }

enum BinOp {
    Add, Sub, Mul, Div, Mod,      // arithmetic
    Eq, Ne, Lt, Le, Gt, Ge,       // comparison
    And, Or,                      // boolean
    Concat,                       // ++
    Cons,                         // ::  (prepend to a list)
}

type Name = String;

With each of the auxiliary data types defined, we represent Bridger expressions with the recursive enum Expr.

enum Expr {
    // a literal value: 5, true, "hi", ()
    Lit(Lit, Span),
    // a variable reference, or the receiver `self`
    Var(Name, Span),
    // a prefix operator: -e, not e, ref e, deref e
    Unary(UnOp, Box<Expr>, Span),
    // a binary operator: e + e, e == e, e and e, e ++ e, e :: e
    Binary(BinOp, Box<Expr>, Box<Expr>, Span),
    // a conditional: if e { … } else { … }, the else optional
    If(Box<Expr>, Box<Expr>, Option<Box<Expr>>, Span),
    // a block: statements and an optional trailing value
    Block(Vec<Stmt>, Option<Box<Expr>>, Span),
    // assignment through a reference: e := e
    Assign(Box<Expr>, Box<Expr>, Span),
    // a function call: e(e, …)
    Call(Box<Expr>, Vec<Expr>, Span),
    // a method call: e.m(e, …)
    Method(Box<Expr>, Name, Vec<Expr>, Span),
    // a lambda: |x, …| e, each parameter optionally annotated x: T
    Lambda(Vec<LambdaParam>, Box<Expr>, Span),
    // a pattern match: match e { arm, … }
    Match(Box<Expr>, Vec<Arm>, Span),
    // a while loop: while e { … }
    While(Box<Expr>, Box<Expr>, Span),
    // a for loop over a list: for x in e { … }
    For(Name, Box<Expr>, Box<Expr>, Span),
    // a for loop over a relation's solutions: for R(a, ?x) { … }
    ForQuery(Query, Box<Expr>, Span),
    // an early return from a function: return e
    Return(Box<Expr>, Span),
    // error propagation: e?  (sugar over match and return)
    Try(Box<Expr>, Span),
    // a tuple: (e, e, …), two or more elements
    Tuple(Vec<Expr>, Span),
    // a list: [e, …]
    List(Vec<Expr>, Span),
    // struct field access: e.f
    Field(Box<Expr>, Name, Span),
    // tuple projection by index: e.0
    Proj(Box<Expr>, u32, Span),
    // constructor application: C(e, …)
    Ctor(Name, Vec<Expr>, Span),
    // struct literal: S { f: e, … }
    Struct(Name, Vec<(Name, Expr)>, Span),
    // a relations operation: add q / clear R / solutions q / query
    Relation(Rel, Span),
}

The comment on each variant names the Bridger form it holds, and every variant matches the abstract syntax fixed in the language reference. This chapter settles the shape of each; the chapters that follow give each its meaning. Several variants hold companion types — Stmt, Arm, Pattern, Rel, and Query — which we define after two structural choices in Expr itself.

Children sit behind Box. An Expr can contain an Expr (e.g., the two operands of a Binary operator or the condition and branches of an If) so its size would depend on itself, and no fixed size would satisfy the Rust compiler. We use Box<Expr> (a pointer to an Expr). Since a pointer has a known size, a Box<Expr> can be used to hold sub-expressions recursively. A node owns its children through the Box, and dropping a tree drops everything under it. Where a node has any number of children — a Call’s arguments, a List’s elements — a Vec<Expr> owns all of them.

Four variants hold companion types, which we define now. A block (Block) is a sequence of statements followed by an optional result expression, and each statement (Stmt) is either a local let binding or an expression evaluated for its effect. A ref x = e binding is stored as an ordinary let of a ref expression, so it needs no case of its own.

enum Stmt {
    Let(Name, Option<Ty>, Expr, Span),   // let x = e   (or   let x: T = e)
    Expr(Expr),                          // an expression evaluated for effect
}

A match expression holds a list of arms. An arm (Arm) pairs a pattern with an optional guard and the expression to evaluate when the arm is chosen.

struct Arm {
    pat: Pattern,          // the pattern this arm matches
    guard: Option<Expr>,   // an optional `if` guard
    body: Expr,            // evaluated when the arm is taken
}

A pattern (Pattern) describes the shape a match tests a value against, and mirrors the expression forms it takes apart. Like an expression, every pattern carries its span.

enum Pattern {
    Wild(Span),                                 // _
    Lit(Lit, Span),                             // a literal: 5, true, "hi"
    Var(Name, Span),                            // binds the matched value to a name
    Ctor(Name, Vec<Pattern>, Span),             // a constructor: C(p, …)
    Tuple(Vec<Pattern>, Span),                  // (p, p, …), two or more
    List(Vec<Pattern>, Option<Name>, Span),     // [p, …], with an optional ...rest
    Cons(Box<Pattern>, Box<Pattern>, Span),     // head :: tail
    Struct(Name, Vec<(Name, Pattern)>, Span),   // S { f: p, … }
    Or(Box<Pattern>, Box<Pattern>, Span),       // p | p
}

The relations sublanguage folds its four expression forms into Rel. A query (Query) names a relation and supplies arguments; each argument (QArg) is either an ordinary expression or a hole to solve for, named ?x or anonymous ?.

enum Rel {
    Add(Query),        // add R(a, …)
    Clear(Name),       // clear R
    Solutions(Query),  // solutions R(a, …)
    Query(Query),      // a bare query R(a, …)
}

struct Query { name: Name, args: Vec<QArg> }   // R(a, …)

enum QArg {
    Expr(Expr),          // a ground argument
    Hole(Option<Name>),  // a hole: ?x (named) or ? (anonymous)
}

The program that holds them

An Expr builds the core of Bridger’s structure; however, at the top-level, a Bridger program is a sequence of declarations: functions, globals, types, structs, traits, relations. The order of declarations does not matter, instead every declaration is visible to every other top-level declaration (with a requirement that all global names must be unique). As such, we represent the top level Declarations simply as a map from names to declarations, and separate trait implementations and rules which are unnamed.

struct Program {
    decls: HashMap<Name, Decl>,   // fn, global, type, struct, trait, relation
    spans: HashMap<Name, Span>,   // where each declaration was written
    impls: Vec<Impl>,             // anonymous: keyed by the type they extend
    rules: Vec<Rule>,             // any number define one relation
}

enum Decl {
    Fn(Generics, Vec<Param>, Ty, Box<Expr>),   // fn f<T: C>(x: T, …) -> T = e
    Global(Option<Ty>, Box<Expr>),             // let x = e  (or  let x: T = e)
    Type(Generics, Vec<Variant>),              // type T<U> = C(U, …) | …
    Struct(Generics, Vec<Field>),              // struct S<T> { f: T, … }
    Trait(Generics, Vec<Sig>),                 // trait Tr<T> { fn m(…); … }
    Relation(Vec<Ty>),                         // relation R : (T, …)
}

The generic-bearing forms — Fn, Type, Struct, Trait, and the impl blocks — each carry a Generics: a declaration can be parameterized by types, and a type parameter may carry a trait bound its argument must satisfy:

struct Generics { params: Vec<TyParam> }   // <T, U: Show, …>; empty if absent

struct TyParam {
    name: Name,               // T
    bound: Option<TraitRef>,  // : Show — the trait bound, if any
    span: Span,               // where it is written, for diagnostics
}

The type checker in Part VI reads them — it quantifies a declaration over its parameters, then at each use instantiates them and checks the bound — while the evaluator ignores them, since running the program is type-erased. The reference’s abstract syntax folds them into a note for the same reason; the AST the parser builds keeps them for the checker.

Every Decl bottoms out in Expr — a function’s body, a global’s initializer — so the tree the last section built is what these hold. Two forms carry no single name and stay out of the map: an impl is found by the type it extends rather than a name, and a relation is defined by any number of rules at once, so each keeps its own list. We define the companion types these declarations use — Param, Field, Variant, Sig, Method, Impl, TraitRef, and the rule forms — below.

Looking a name up is then a map lookup, and that lookup is the order-free, mutually visible scope the reference specifies made concrete: where a declaration sits in the file cannot matter, because the file order is gone once the map is built, and a name declared twice collides when the map is.

A parameter (Param) and a struct field (Field) are each a name with a type, and a constructor of a type declaration is a variant (Variant) with a name and the types of its fields. Each carries the span of its own text, so a diagnostic can point at one parameter of a signature. A lambda’s parameter (LambdaParam) differs from a function’s in one respect: its annotation is optional, because the type checker can usually infer it from how the lambda is used.

struct Param       { name: Name, ty: Ty, span: Span }            // x: T
struct LambdaParam { name: Name, ty: Option<Ty>, span: Span }    // x  or  x: T
struct Field       { name: Name, ty: Ty, span: Span }            // f: T
struct Variant     { name: Name, fields: Vec<Ty>, span: Span }   // C(T, …), or C

A trait lists method signatures (Sig); an impl supplies methods (Method), which are signatures with a body. Each records whether the method takes self, its remaining parameters, and its return type.

struct Sig {
    name: Name,
    has_self: bool,       // whether the method takes `self`
    params: Vec<Param>,
    ret: Ty,              // (), when the return type is omitted
    span: Span,
}

struct Method {           // a Sig with a body
    name: Name,
    has_self: bool,
    params: Vec<Param>,
    ret: Ty,
    body: Expr,
    span: Span,
    sig: Span,            // the signature alone, `fn m(self, x: Int) -> T`
}

An impl block (Impl) records its own generics, the trait it implements if any, the type it extends, and its methods. A trait named there is a TraitRef — a trait name with any type arguments.

struct Impl {
    generics: Generics,        // impl<T> …
    trait_: Option<TraitRef>,  // the trait implemented, if any
    ty: Ty,                    // the type being extended
    methods: Vec<Method>,
    span: Span,
    head: Span,                // the header, `impl<T> Tr for T`
}

struct TraitRef { name: Name, args: Vec<Ty>, span: Span }   // Tr, or Tr<T, …>

A rule (Rule) has a head atom and a body. The head (Atom) names a relation and lists its terms (Term), each a logic variable or a literal. The body is the rule’s and-chain split into its conjuncts, and each conjunct is an ordinary expression: edge(x, y) and is_ok(x) are the same syntax, and which of them joins against a relation and which tests already-bound variables is settled by name resolution when the rules are installed, in Part VII.

struct Rule { head: Atom, body: Vec<Expr>, span: Span }   // rule H :- B1 and …

struct Atom { name: Name, terms: Vec<Term>, span: Span }  // R(t, …)

enum Term {
    Var(Name),   // a logic variable (a lowercase name)
    Lit(Lit),    // a literal
}

The type language

A type annotation on a let, the signature of a function, the fields of a struct, the columns of a relation, and the bound on a type parameter are all written in one small language of types, and it is a tree of its own:

struct Ty { kind: TyKind, span: Option<Span> }   // a shape, and where it was written

enum TyKind {
    Int, Bool, Str, Unit,    // Int, Bool, String, ()
    Tuple(Rc<[Ty]>),         // (T, U, …) — two or more
    List(Rc<Ty>),            // [T]
    Fn(Rc<[Ty]>, Rc<Ty>),    // fn(T, …) -> T
    Ref(Rc<Ty>),             // ref<T>
    SelfTy,                  // Self, in a trait or impl
    Named(Name, Rc<[Ty]>),   // N, or N<T, …> when applied
    Meta(u32),               // a type not yet known; the checker solves it (Part VI)
}

The children sit behind Rc, a reference-counted pointer, where the expression tree uses Box. Types are shared far more than expressions are: the checker records a type for every expression, nested expressions have nested types, and with plain boxes each level would hold its own copy of everything below it. With Rc the copies are pointers, and cloning a type costs nothing.

A type is used in two ways, and the split into Ty and TyKind serves both. A type the programmer wrote has a position, and a diagnostic can then say that a function was declared to return Int here and point at the annotation. A type the checker computes has none, so the span is optional. Two types are equal when their shapes are, wherever they came from, so a Ty compares and prints as its TyKind alone; Ty::int(), Ty::list(t) and their kin build the computed ones. TyKind is recursive for the same reason Expr is — a list of a list, a function returning a function. Named covers both a plain type name and a generic one applied to arguments, with an empty argument list for the plain case. This is the language the type checker in Part VI works over; here it is only the shape the parser records from a written type.

Matching over the tree

A function over an AST is a case analysis: one arm per variant, handling the pieces that variant carries. match is Rust’s tool for it, and pulling a node apart binds its fields in the same motion:

fn depth(e: &Expr) -> usize {
    match e {
        Expr::Lit(..) | Expr::Var(..) => 1,
        Expr::Unary(_, inner, _) => 1 + depth(inner),
        Expr::Binary(_, l, r, _) => {
            1 + depth(l).max(depth(r))
        }
        Expr::If(c, t, e, _) => {
            let branches = match e {
                Some(els) => depth(t).max(depth(els)),
                None => depth(t),
            };
            1 + depth(c).max(branches)
        }
        _ => 1, // remaining forms: Part II onward
    }
}

The recursion in the type becomes recursion in the function, and the shape of each arm is dictated by the shape of its variant. match is also exhaustive: drop the _ arm and the compiler names every variant still unhandled, so a new form added to Expr cannot silently fall through a pass that predates it. That guarantee — the type system enforcing that every case is considered — is why tree-walking interpreters read so cleanly in this style, and it is the single most useful piece of Rust for the chapters ahead.

The trees a compiler keeps

The AST here is shaped for a book that walks it directly. A production compiler keeps different trees, and the differences are worth knowing so the choices above read as choices.

A compiler usually holds more than one tree. This one is untyped: it is the parser’s output, and nothing in it records what an expression’s type is. Part VI’s type checker leaves the tree as it is and records each expression’s type in a side table keyed by the expression’s span — which is why a span must name a node uniquely — so a later stage can consult a type without recomputing it. Names, kept here as String, are often interned to small integers so that comparing two of them is one machine instruction rather than a character-by-character scan. And a tree stored with Box scatters its nodes across the heap; a compiler that cares about locality keeps them in an arena — one contiguous block — and replaces every Box<Expr> with an integer NodeId index into it, which also lets the side tables (types, uses) key off a small integer rather than a span. Each of these buys speed or a place to hang more information, at the cost of a tree that is heavier to build and read. A first interpreter wants neither cost, so it takes the Box, the String, and the one untyped tree.

Further reading

Building an interpreter around an AST: Robert Nystrom’s Crafting Interpreters (free online) builds two interpreters for the same language, the first a tree-walker over an AST much like this one, and it is unusually concrete about the shape of the tree and the walk over it. Its “Representing Code” and “Evaluating Expressions” chapters cover the same ground as this one and the next, in Java rather than Rust.

The trees a compiler keeps instead: Andrew Appel’s Modern Compiler Implementation in ML (Cambridge University Press, 1998) develops the typed and lowered intermediate representations named in the last section — separate trees for parsing, type checking, and code generation — and shows what each stage reads off the tree the stage before it produced.

Concept checks

In x + y * z, which variant is at the root of the tree, and why isn't it the + that appears first in the text?

The root is the Binary(Add, …), with x as its left child and the Binary(Mul, …) for y * z as its right. It is not rooted at the first operator read, because the root is the operator applied last when the expression is evaluated, and * binds tighter than +. Precedence is a concrete-syntax rule; the parser has already used it to decide the grouping, and the tree records only the result. The same tree would come back from (x) + (y * z) or a prefix (+ x (* y z)) — the surface that distinguished them is gone by the time there is an Expr.

Why does Binary hold Box<Expr> for its operands rather than Expr?

Because Expr is recursive: a Binary contains two Exprs, each of which might itself be a Binary, with no bound on the nesting. If the operands were stored inline as Expr, the size of an Expr would include the size of an Expr, and no finite size satisfies that. A Box<Expr> is a pointer to an Expr allocated elsewhere; a pointer has a fixed size regardless of how large the subtree behind it is, so the type is well-sized and the recursion lives in the heap rather than in the type’s layout.

Every variant carries a Span. What breaks if only the root of the tree kept one?

Any node can be the one a later pass needs to complain about — a type error is usually at some operator deep in the tree, an unbound variable at a single leaf — and a diagnostic can only underline what it has a span for. With a span only at the root, every error would point at the whole program. The information is available for free while the parser still knows the offsets, and unrecoverable once the text is gone, so it is recorded on every node even though most nodes never end up in a message.

An arena representation replaces every Box<Expr> with a NodeId index into one array of nodes. What does that buy, and what does it cost?

It buys locality and shared bookkeeping. The nodes sit in one contiguous block instead of scattered across the heap, which a walk traverses faster, and a NodeId is a plain integer, so side tables — the type of each node, its uses, its span — can be separate arrays keyed by the same id, letting a pass attach information to a node without changing the node’s type. It costs directness: reaching a child is now an array lookup through the arena rather than following a pointer the node owns, the arena has to be threaded through every function that touches the tree, and Rust’s ownership no longer frees a subtree for you when its parent goes away. A tree-walking interpreter gains nothing from the trade, which is why this one keeps the Box.

The top-level declarations are held in a HashMap<Name, Decl> rather than a Vec<Decl> in source order. What decision in the language forces that, and what would a Vec fail to capture?

The language reference fixes the top level as a set: declarations may appear in any order, each is visible to every other, and a name is declared at most once. A Vec<Decl> would keep the source order — information the language treats as meaningless — and would let the same name appear twice without complaint, which the language forbids. A map keyed by name discards the order the language does not use and turns a duplicate name into a collision when the map is built, so it admits exactly the programs the reference allows. The impls and rules, which are not singly named, are the exception the two side lists handle.

Values and Runtime Errors

In the previous chapter, we covered the AST of Bridger as provided by the accompanying Rust parser. The provided code base also includes a Rust enum type Value representing the possible values a Bridger expression may evaluate to. Throughout this book, you will work through the milestones to create an interpreter that evaluates an Expr (and whole programs) to a Value. This chapter details the Value type you will be working with. It also covers what the interpreter should produce when an expression has no value at all (a runtime error). The mathematical value grammar and the semantic rules that define the meaning of Bridger programs are detailed in Appendix D: language reference.

The space of values

Evaluation produces a value, and every value a Bridger program can compute is one of a fixed set of shapes: an integer, a boolean, a string, unit; a tuple or a list built from smaller values; a constructor value or a struct value; a location into the store; a closure. The reference writes the set as a grammar:

The last two stand for names rather than data — a declared relation, and a struct or type named as the receiver of an associated call — and nothing compares, prints, or stores them.

The above set of values represents the result of running any Bridger program or expression to completion. We describe each in detail in future chapters: integers, booleans, and strings in the tree-walking evaluation as the result of evaluating simple arithmetic, boolean, and string expressions; tuples and lists in that same chapter, as compound expressions built from smaller values; unit, the value of statements and blocks, with control flow; locations with mutable references; closures with functions; and constructor and struct values with algebraic data types and structs later still.

Bridger’s values in Rust

The set of value shapes is a sum type, the same correspondence the AST turned on: one variant per shape, each carrying what that shape is built from. The Value enum, provided with this book alongside the AST, holds them. It reuses type Name = String from the AST chapter for the names in Ctor and Struct.

enum Value {
    Int(i64),                          // 5
    Bool(bool),                        // true
    Str(Rc<str>),                      // "hi"
    Unit,                              // ()
    Tuple(Rc<[Value]>),                // (v, v, …), two or more
    List(List),                        // [] or a cons cell v :: v, see below
    Ctor(Name, Rc<[Value]>),           // a constructor value: C(v, …)
    Struct(Name, Rc<[(Name, Value)]>), // a struct value: S { f = v, … }
    Ref(Loc),                          // a location in the store (Part IV)
    Closure(Rc<Closure>),              // a function + its captured env (Part V)
    Relation(Name),                    // a declared relation, by name (Part VII)
    Type(Name),                        // a type name, receiver of `T.m()` (Part IX)
}

The comment on each variant gives an example of the kind of value it holds, and the variants line up with the grammar above one for one. The string, tuple, constructor, and struct payloads sit behind Rc, as the types in the AST chapter do: a value is copied whenever it is read from a variable or a cell, and a reference-counted pointer makes that copy a pointer bump rather than a walk of the payload. The Value enum is to a running program what Expr is to a parsed one: the type every part of the interpreter agrees on for the thing it passes around. Where Expr is what the evaluator takes apart, Value is what it builds.

One representation choice is visible in List. The reference models a list as either the empty list or a cons , a head joined to a tail, and the List type holds exactly that: the empty list, or a cell with a head, a tail, and the length so far, the tail shared with any other list that ends the same way. Sharing is what makes the rules’ shape a good representation and not only a good notation. Prepending with ::, taking a list apart with the pattern h :: t, and head and tail each touch one cell, whatever the list’s length, so a function that recurses down a list does work proportional to the list. An array would make ::, tail, and the tail of h :: t copy the whole list. A list literal and ++ cost the left operand’s length, since its cells are rebuilt onto the shared right operand, and len reads the count each cell stores. What the array would give back, reading the nth element in one step, Bridger has no operation for.

Expressions that do not evaluate to a value

Evaluating an expression usually produces a value; however, there are two scenarios when an expression does not evaluate to a value. Evaluation can get stuck: adding a boolean to an integer, applying a value that is not a function, projecting a field off a tuple, or dividing by zero. Each such scenario has no corresponding evaluation rule (and the expression has no well-formed meaning as a value). Instead, the interpreter immediately halts on such runtime errors. On the other hand, an evaluation can choose to return early: a return e computes e and then abandons the rest of the enclosing function’s body, carrying that value out to the function’s own result. We represent both non-local exits with one type Control in our Rust interpreter.

enum Control {
    Raise(RuntimeError),  // stuck: no rule applies; the run halts
    Return(Value),        // an early return, sent to the enclosing function
}

This chapter describes the purpose of Raise in detail. Stuckness is the absence of a result rather than a special result, a notion the operational semantics the book writes its rules in makes precise, and the reference collects the full list of cases. In the untyped evaluator of Parts II through V, these stuck states are how a nonsensical program fails; they are Bridger’s runtime errors. Return carries a value that did finish computing, only not here; a return is legal only inside a function body. We will explain how return e and the Control::Return variant are treated within the interpreter we build in the Control flow chapter and functions.

The interpreter we build must handle expressions that may contain runtime errors. Rather than having our interpreter crash or have undefined behavior on runtime errors, we have the interpreter we build make use of Rust’s Result and Error types to represent such runtime errors explicitly. That means, when the interpreter encounters such runtime errors, rather than halting it stops evaluation early and returns a RuntimeError describing why the interpreter stopped evaluation.

The variants below are the ones the arms of this part and the next raise; the driver and the prelude add theirs (no main, a cycle among global initializers, a failed read from input, a range or a call depth past the machine’s bound, printing a function), and each later part its own as its constructs arrive (a missing struct field, a method with no receiver, a query inside a filter).

enum RuntimeError {
    // x not in scope
    UnboundVariable { name: Name, span: Span },
    // 1 + true, a non-bool condition, iterating a non-list, …
    TypeError { expected: Ty, found: Ty, span: Span },
    // 5 / 0 or 5 % 0
    DivByZero { span: Span },
    // .f a value lacks
    NoSuchField { field: String, span: Span },
    // applying a non-function
    NotAFunction { found: Ty, span: Span },
    // wrong number of arguments
    ArityMismatch { expected: usize, found: usize, span: Span },
    // no arm matched the value
    NonExhaustiveMatch { span: Span },
    // == on a function or relation
    NotComparable { found: Ty, span: Span },
}

Evaluation therefore does not return a bare Value. It returns a Value or a Control, and Rust spells “one or the other” with its own sum type, Result. Its companion Option spells “a value or nothing”:

enum Result<T, E> { Ok(T), Err(E) }   // a T, or an error E
enum Option<T>    { None, Some(T) }   // a T, or nothing

Both come from Rust’s standard library, and both are how a Rust program carries the possibility of failure in a value the compiler forces every caller to look at. The evaluator puts a Value on the Ok side and a Control on the Err side — a normal value, or one of the two non-local exits:

fn eval(e: &Expr) -> Result<Value, Control> {
    match e {
        Expr::Lit(Lit::Int(n), _) => Ok(Value::Int(*n)),
        Expr::Binary(BinOp::Add, l, r, span) => {
            let a = eval(l)?;                 // a Raise or Return bubbles out here
            let b = eval(r)?;
            match (a, b) {
                (Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_add(y))),
                (a, b) => {
                    let found =
                        if matches!(a, Value::Int(_)) { type_of(&b) }
                        else { type_of(&a) };
                    Err(Control::Raise(RuntimeError::TypeError {
                        expected: Ty::int(), found, span: *span,
                    }))
                }
            }
        }
        _ => todo!("the remaining forms: the next chapter"),
    }
}

The Ok/Err split names both outcomes at the type level: a caller of eval cannot use the value without first deciding what to do when it is an Err. The ? after eval(l) is Rust’s propagation operator — when the subexpression is an Err, ? returns it from eval at once, so whichever exit it carries — a Raise with its span, or a Return with its value — travels out through every enclosing operation untouched. One mechanism threads both: every operation propagates a Control the same way, and only a function call inspects a Return, catching it at the boundary and turning it back into the call’s value. When both operands are integers the addition proceeds; when they are not, the last arm raises a RuntimeError::TypeError naming the Ty it expected and the Ty type_of read from the offending operand, and carrying the addition’s span. The addition wraps, since the reference fixes integers as 64-bit values with arithmetic modulo .

Two kinds of failure

The Err(Control::Raise(…)) an addition returns and a Bridger value like Err("file not found") read alike on the page and are different in kind. The first is the interpreter stopping: no rule applied, so there is no value, and the host Result carries that fact out to the top level, where it is printed. A Bridger program cannot observe it or recover from it; the program has already stopped. The second is an ordinary Bridger value, a constructor value Err(s) that a successful evaluation produced, which the program inspects with match and carries on from. Evaluating it is a completed derivation that yields a value; nothing is stuck.

Bridger has its own Option and Result for exactly this second kind of failure, defined in the prelude as ordinary data types and handled with match — separate from Rust’s Option and Result, which are how the interpreter is written. The recoverable failures a Bridger program models with those values, the surface that makes them convenient, and the design space of failure mechanisms (values against exceptions against a halting panic) are the subject of Error handling, once algebraic data types give the language the constructors to build them. What this chapter fixes is the boundary: a runtime type error is the host stopping, and it stays outside the values a program computes with.

That boundary is why every stuck case is stated precisely. Each carries a span and names the Ty it expected against the one it found, because for Parts II through V a runtime type error is the only report a mistake gets, and the reader debugging a program has nothing else to go on. When the type checker arrives in Part VI it rules out the type-shaped stuck cases before the program runs, trading the clear failures described here for a guarantee made ahead of time. Division by zero, a failed read from input, and the interpreter’s two bounds — on call depth and on the size of a range — cannot be ruled out statically, nor, until exhaustiveness checking arrives, can a match no arm covers.

How a value is represented

A tagged union is one way to give an interpreter a single value type, and the choice reads as a choice against the alternatives a faster or a more open interpreter reaches for.

The enum stores a tag — which variant this is — beside the payload, and every value costs the tag plus room for the largest variant. match reads the tag to choose an arm, and the compiler checks that the arms cover every variant, the same exhaustiveness the AST walk relied on. A value kind cannot be added without editing the one enum and revisiting the matches the addition now leaves incomplete.

Other interpreters and compilers optimizing for speed instead often make use of smaller, packed value representations. For instance, a production virtual machine for a dynamic language often stores every value in a single 64-bit word, reading a few spare bits as the tag: small integers are represented inline with a tag bit set. This is how OCaml represents its values. It represents its native integers with 63 bits and the low bit determines whether the 64-bit word is an integer or a pointer to a structure. These packed representations achieve faster computations due to the denser representation that leads to fewer memory reads. However, the cost is that the representation is tied to a specific word size, and such packed representations are often hard to read and understand.

An interpreter written in an object-oriented language reaches instead for a class hierarchy: each value kind a subclass of a common value class, and each operation a virtual method the runtime dispatches on the object’s class. That arrangement makes the set of value kinds open — a new kind is a new subclass, added without editing a central type — and gives up the closed, checked case analysis the enum and its match provide, where the compiler can prove every kind is handled. In this book, we make the decision to use an enum type to represent the AST and Value because it makes it clear which cases are handled and which are not and is easier to read and understand for learning purposes.

Further reading

Representing values in an interpreter: Robert Nystrom’s Crafting Interpreters (free online) gives its bytecode interpreter a tagged-union value type in its “Types of Values” chapter, the same shape as the Value here, and its later “Optimization” chapter rewrites that type as a NaN-boxed word and measures what the packing buys.

Tagged data and dispatching on it: Abelson and Sussman’s Structure and Interpretation of Computer Programs (MIT Press, free online) builds data that carries a type tag and operations that read it, and works through both the central-table and the class-style organizations for dispatching on that tag — the two the last section set against the enum.

Concept checks

The reference models a list as either [] or a cons v :: v, and Value::List holds cons cells. What would change if it held a Vec<Value> instead, and which programs would notice?

No program would see a different value: [1, 2, 3] denotes the same list, == and printing agree, and every rule still applies. What changes is cost. With cons cells, x :: xs and the pattern h :: t share the tail and touch one cell; with an array they copy the list, so a function that walks a list by taking its head off each time does work proportional to the square of the length. A program summing a list of fifty thousand elements would notice. In the other direction, an array reads its nth element in one step, which cons cells cannot; since Bridger has no indexing operation, nothing in the language can tell.

Why does eval return Result<Value, Control> rather than Value? What would a plain Value return type force on the expression 1 + true?

Because 1 + true has no value: it is a stuck evaluation, and there is no Value that honestly represents “there was no result.” A Value return type would force the evaluator to invent one — to pick some integer, or add a Value::Error variant that then has to be threaded and checked by hand through every operation, or to panic and lose the span. Result<Value, Control> names the outcomes at the type level: a success carrying a Value, or a Control exit — for 1 + true, a Raise carrying a RuntimeError::TypeError (expected Int, found Bool) and the span to point at. A caller cannot read the value without first handling the Err case, so a stuck subexpression cannot be quietly used as though it had succeeded.

A Bridger program evaluates Err("nope"); separately it evaluates 1 + true. Both look like failures. How do the two differ inside the interpreter?

Err("nope") is an ordinary Bridger value — a constructor value the evaluator builds by a completed, successful derivation, of type Value, which the program goes on to inspect with match. Nothing is stuck; the failure it represents is one the program chose to model as a value. 1 + true is a stuck evaluation: no rule applies, so there is no Value at all, and the interpreter returns Err(Control::Raise(RuntimeError::TypeError { … })) in the host language and halts that run. The program cannot observe or recover from it, because the program has stopped. One failure is a value the program computes with; the other is the interpreter reporting that it could not compute.

Bridger represents values with a tagged enum. An interpreter in an object-oriented language might make each value kind a subclass of a common value class instead. What does each arrangement make easy, and what does each give up?

The enum makes the set of value kinds closed and the case analysis checked: every kind is a variant of one type, and match will not compile until it handles them all, so a new kind forces the compiler to list every operation that now has a gap. It gives up open extension — adding a kind means editing that central type. The subclass arrangement makes the set open: a new value kind is a new subclass with its own methods, added without touching a central definition, and operations dispatch through virtual methods on the object’s class. It gives up the checked exhaustiveness — nothing proves every kind implements every operation, and a missing case surfaces at run time rather than at compile time. An interpreter defined once, in one place, gains from the closed, checked side, which is why Bridger takes the enum.

The reference says == is stuck on two closures. Why can't the interpreter compare two Value::Closure values structurally, the way it compares two tuples?

Structural equality on a tuple or a list bottoms out at integers, booleans, and strings, which have a plain answer. A closure is a function body paired with a captured environment, and neither piece has a useful structural answer: two closures with different bodies can compute the same function, and deciding whether two functions agree on every input is not something the interpreter can compute in general. Comparing the captured environments or the syntax of the bodies would give an answer, but not one that means “these are the same function,” so it would mislead more than it helps. The language leaves closure equality stuck rather than returning an answer that does not correspond to any equality a programmer would want.

A return deep in a function body and a stuck 1 / 0 both leave eval through its Err channel as a Control. Why can one ? propagate both — and what makes a return stop at its enclosing function while a stuck error travels to the top?

Both are non-local exits, so both ride Err(Control::…), and ? propagates any Err identically — no per-operation code has to thread either one outward. They differ only at the boundary: the Call arm inspects the body’s outcome, and on Control::Return(v) it catches it and yields v as the call’s value, so a return unwinds exactly to its enclosing call and no further. Nothing inspects a Control::Raise on the way up, so it reaches the top level and halts the run. This is the reference’s Val v | Ret v short-circuit convention, realized as Rust error propagation.

Tree-Walking Evaluation

The last two chapters introduced Bridger’s AST and its values. This chapter begins the evaluator: the procedure that maps an AST to a value. It covers the arithmetic, Boolean, string, list, and tuple expressions that Milestone M1 asks you to build, whose semantic rules take the form : the expression evaluates to the value . The rest of those rules carry detail that does not bear on this fragment, and this chapter sets it aside.

Tree-walking evaluator

The rules for evaluation are compositional: the value of an expression is defined in terms of the values of its subexpressions. The E-Add rule only fixes the meaning of once the meanings of and are known.

This shape of giving meaning is naturally recursive: to evaluate a node, first evaluate its children, then combine their values as the node’s rule prescribes. A leaf (a literal) is the base case: it carries its own value and does not recurse. The little arithmetic language below is fixed by four rules: E-Add above, together with a literal axiom, negation, and multiplication:

The evaluator you develop following this book will recurse over the structure of an Expr. Below is a tree-walking evaluator for the little language above (similar to the one you will be asked to write):

enum Expr {
    Lit(i64),                       // 5
    Neg(Box<Expr>),                 // -e
    Add(Box<Expr>, Box<Expr>),      // e + e
    Mul(Box<Expr>, Box<Expr>),      // e * e
}

fn eval(e: &Expr) -> i64 {
    match e {
        Expr::Lit(n)    => *n,                       // E-Lit
        Expr::Neg(x)    => -eval(x),                 // E-Neg
        Expr::Add(l, r) => eval(l) + eval(r),        // E-Add
        Expr::Mul(l, r) => eval(l) * eval(r),        // E-Mul
    }
}

fn main() {
    // (1 + 2) * 4
    let e = Expr::Mul(
        Box::new(Expr::Add(Box::new(Expr::Lit(1)), Box::new(Expr::Lit(2)))),
        Box::new(Expr::Lit(4)),
    );
    println!("{}", eval(&e)); // 12
}

Each rule became one arm of the match; each premise became a recursive call; each conclusion became the value the arm returns. The calls eval makes on trace the same tree as the derivation the rules build for it — eval(Mul) waits on eval(Add) and eval(Lit 4), and eval(Add) waits on its two literals — so the call tree and the proof tree have the same shape. Running the program computes the value at the root by the same steps the derivation uses to justify it.

Interpreting a program by a function that recurses over its structure is the oldest way to run one. McCarthy’s LISP defined its own evaluator this way: eval was an ordinary function, written in LISP, that took a program — itself a LISP list — and walked it, dispatching on each form and calling itself on the pieces. An interpreter for a language, written in that same language, is a metacircular evaluator, and the arithmetic walk above is the same idea with the program held as a Rust enum instead of a list.

From rules to match arms

Bridger’s evaluator has the same skeleton, over the real Expr and the real Value. Two things grow past the arithmetic sketch. Evaluation can fail — an operand can have the wrong shape, so the function returns Result<Value, Control> rather than a bare value, and a subexpression’s exit is propagated with ?. And the arithmetic fragment has no variables, so the environment carried by the reference judgment plays no part yet; eval reads only its &Expr, and the rules are read in the store-free, env-free slice — the starter’s eval_expr already carries an env parameter, which nothing reads until Part III. The environment enters with let and scope there.

The correspondence is otherwise unchanged: one rule, one arm. The previous chapter wrote the Add arm in full — evaluate both operands, and on two integers return their sum, otherwise raise a TypeError. The rest of eval is built the same way, arm by arm:

fn eval(e: &Expr) -> Result<Value, Control> {
    match e {
        // E-Lit: a literal evaluates to itself
        Expr::Lit(lit, _) => Ok(match lit {
            Lit::Int(n)  => Value::Int(*n),
            Lit::Bool(b) => Value::Bool(*b),
            Lit::Str(s)  => Value::Str(s.clone()),
            Lit::Unit    => Value::Unit,
        }),

        // E-Arith: evaluate both, combine two integers (values.md)
        Expr::Binary(BinOp::Add, l, r, span) => { /* … */ }

        // the remaining forms are the work of Milestone M1
        _ => todo!("Sub, Mul, comparisons, and, or, not, tuples, lists, …"),
    }
}

Most of the remaining arms repeat Add‘s shape with a different operator, string concatenation among them. ++ joins two strings, and E-Concat names both operands’ values exactly as E-Add does:

Its arm is the Add arm with Value::Str in place of Value::Int: evaluate both children, return their concatenation on two strings, and raise a TypeError otherwise. The same ++ also joins two lists, so the arm matches that shape as well — one operator over two value shapes, which is operator overloading; the general mechanism that lets one operator serve many types arrives with traits. Comparing strings for order arrives the same way, through cmp rather than <.

Filling in each arm is the walk applied to one rule at a time. Most arms follow a single pattern — evaluate both children, then combine their values — but two rules depart from that pattern.

When the walk departs from evaluating both

Division carries an extra premise: E-Div fires only when the divisor is nonzero. When it is zero no rule applies, so the expression is stuck, and the interpreter reports it rather than dividing. The arm reads the premise off as a guard:

// E-Div: the divisor must be nonzero, or evaluation is stuck
Expr::Binary(BinOp::Div, l, r, span) => {
    match (eval(l)?, eval(r)?) {
        (Value::Int(_), Value::Int(0)) => Err(Control::Raise(
            RuntimeError::DivByZero { span: *span },
        )),
        (Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_div(y))),
        (a, b) => Err(type_error2(Ty::int(), &a, &b, *span)),
    }
}

type_error2 builds the RuntimeError::TypeError the previous chapter wrote out by hand, blaming the first operand that is not an Int (type_error is its one-operand form); here the new case is the zero check, the guard in the rule became a match arm. Modulo is the same arm with %.

The boolean connectives depart further. and and or short-circuit: the right operand is evaluated only when the left has not already settled the result. Two rules cover and — a false left operand settles it without touching the right, and a true left operand hands the result to the right — and the walk follows their shape, evaluating the left, then reaching the right on true:

// E-And-False / E-And-True: evaluate the right only when the left is true
Expr::Binary(BinOp::And, l, r, span) => {
    match eval(l)? {
        Value::Bool(false) => Ok(Value::Bool(false)),
        Value::Bool(true) => match eval(r)? {
            b @ Value::Bool(_) => Ok(b),
            other => Err(type_error(Ty::bool(), &other, *span)),
        },
        other => Err(type_error(Ty::bool(), &other, *span)),
    }
}

The arithmetic arm evaluated both children before combining them; the and arm evaluates one and may stop there. What each arm does is dictated by its rule — an axiom is a leaf, a rule with two operand premises recurses twice, a short-circuiting rule recurses once and then decides — so reading the rules off as arms also settles the questions prose semantics left open, such as whether and ever evaluates its right operand. The remaining forms of Expr — the other arithmetic and comparison operators, not, building tuples and lists, prepending to a list with ::, and projecting a tuple component with e.0 — are each one more arm of the same walk, and completing them is Milestone M1. A tuple has positional access because its arity is fixed and the index is a literal.

Beyond tree-walking evaluators

Walking the AST is one way to run a program; however, there are other alternatives when implementing an interpreter. The walk re-inspects the tree every time control reaches a node: each visit dispatches on the node’s variant and follows the Box pointers to its children. For a literal evaluated once is nothing. For the body of a loop that runs a million times — once the language has loops — it is a million repetitions of the same dispatch and the same pointer-chasing over a tree that never changed.

Interpreters that need the speed spend a compile step to remove that repetition.

  • A bytecode virtual machine lowers the tree once into a flat sequence of simple instructions, then runs a tight loop that dispatches on one instruction at a time. The instructions sit in an array rather than scattered across the heap, and each is a single indexed step, so the repeated dispatch and pointer-chasing of the walk are gone. The cost is a second representation — the bytecode — to design, produce, and debug alongside the tree. CPython, the JVM, and Lua run this way, and it is the second interpreter Nystrom’s book builds for the same language as its first.
  • Closure generation walks the tree once and produces, for each node, a closure that already holds its children’s closures and knows what to do; running the program calls closures and never inspects the AST again. The repeated dispatch is gone without leaving the host language or defining a bytecode, at the cost of a closure allocated per node. Feeley and Lapalme set the approach out in 1987.
  • Just-in-time compilation translates the parts of a program that run hot into machine code while the program runs, reaching the speed of compiled code on those parts. It is the most involved and the most machine-specific of the three, and drives the production JavaScript and JVM engines.

A separate choice is the walk’s use of host-language recursion. eval calling itself uses the Rust call stack for the tree’s depth and for every Bridger call in progress, so a runaway recursion would exhaust it. The provided interpreter runs on a large stack and counts the calls in progress, refusing the next one past 100 000 as the stuck state StackOverflow, before the host stack runs out. An interpreter that must bound its memory more tightly keeps an explicit stack of work to do and loops over it instead of recursing, trading the directness of the recursive arms for control over memory.

This book takes the recursive AST-walk. It is the most direct realization of the semantics — one match arm per rule, the call tree the derivation tree — so the evaluator reads as the rules it implements, which is what serves a reader learning what those rules mean.

Further reading

Two interpreters for one language: Robert Nystrom’s Crafting Interpreters (free online) builds a tree-walking interpreter in its first half and a bytecode virtual machine in its second, for the same language, so the two strategies of the last section can be read against each other line for line, with the walk’s “Evaluating Expressions” the direct counterpart of this chapter.

Generating closures instead of walking: Marc Feeley and Guy Lapalme’s “Using closures for code generation” (Computer Languages 12:1, 1987, doi:10.1016/0096-0551(87)90012-9) compiles an expression into a network of closures whose application evaluates it, the middle ground that removes the walk’s repeated dispatch without a bytecode.

A register machine for a scripting language: Roberto Ierusalimschy, Luiz Henrique de Figueiredo, and Waldemar Celes’s “The Implementation of Lua 5.0” describes a production bytecode interpreter in detail, including why its virtual machine passes values through registers rather than a stack.

The evaluator written in its own language: Abelson and Sussman’s Structure and Interpretation of Computer Programs (MIT Press, free online) develops a metacircular evaluator — eval and apply for a Lisp, written in that Lisp — the modern presentation of the walk LISP first ran on.

Concept checks

Evaluating (1 + 2) * 4, in what order does eval reach the nodes, and why must the two operands of the * be evaluated before the * itself?

eval is called on the Mul first, but it cannot return until its children have; it calls eval on the Add, which calls eval on 1 and on 2 and returns 3, then eval on 4, and only then multiplies. The values are produced bottom-up — the leaves first, each parent after its children — so the node visited first is the last to finish. The * must wait because its rule, E-Mul, names the operands’ values ( and ) in its premises and builds the result from them; there is no value to combine until the subexpressions have been evaluated. The call tree has the same shape as the derivation the rules build for the expression.

The + arm evaluates both operands before combining them. The and arm evaluates its left operand and sometimes stops there. Why the difference?

Because the rules differ. Addition has one rule with both operands’ values as premises, so its arm must produce both before it can add. and has two rules: E-And-False, whose only premise is that the left operand is false, settles the whole expression without mentioning the right; E-And-True reaches the right only after the left is true. The arm follows that shape — evaluate the left, and call eval on the right only in the true case. The walk’s structure at each node is dictated by that node’s rule, which is also what makes the short-circuit behaviour a definite part of the language rather than an accident of how the interpreter happened to be written.

On 1 + true the + arm returns Err(Control::Raise(...)). Why is there no arm that returns some Value for it?

Because the expression has no value: no evaluation rule covers adding a boolean to an integer, so no derivation of 1 + true ⇓ v exists, and the expression is stuck. An arm that returned a Value would have to invent one the rules do not license — pick an integer, or a fabricated error value — and a later operation could then use it as though the addition had succeeded. Returning Err(Control::Raise(RuntimeError::TypeError { … })) instead reports that evaluation could not proceed, and because eval returns a Result, ? carries that report out and no caller can read a value that was never produced.

A bytecode VM lowers the tree once and then loops over an instruction array; the recursive walk re-examines each node every time control reaches it. Where does that difference show up, and where does it not?

It shows up wherever the same node is evaluated many times — the body of a loop, a function called repeatedly. The walk re-dispatches on each node’s variant and re-follows its child pointers on every visit, so a body run a million times pays that cost a million times; the VM paid the lowering cost once and each later run is a linear pass over an array with tighter dispatch and no pointer-chasing. It does not show up on code evaluated once — a literal, a top-level expression — where the walk does the same constant work the VM’s single pass would, and the VM’s compile step is pure overhead. The VM buys its speed with a second representation to build and maintain, which is why an interpreter meant to be read, or run over each node about once, takes the walk.

The recursive walk uses the host language's call stack. What can go wrong on a deeply nested expression, and what does an interpreter do instead when it must not?

Each recursive eval call uses a frame of the Rust call stack, and the depth of nesting is the depth of the tree plus the Bridger calls in progress. Without a bound, a runaway recursion would exhaust it and abort the process; the provided interpreter counts Bridger calls in progress and reports the 100 001st as the stuck state StackOverflow, and runs on a stack large enough that an expression a hundred thousand nodes deep still evaluates. An interpreter that must bound its stack keeps its own explicit stack of pending work on the heap and loops, pushing and popping subexpressions instead of calling itself, so the depth it can handle is limited by heap rather than by the fixed host stack. The cost is directness: the arms no longer read as the rules, because the recursion that mirrored the derivation has been turned into manual stack bookkeeping.

Error Handling

The chapter on values and runtime errors split failure in two. A run can get stuck — no rule applies, so there is no value, and the interpreter halts with a RuntimeError the program cannot observe or recover from. Separately, a program can compute a value that stands for a failure and carry on from it. This chapter is about that second kind: the failures a program expects, how a language lets an operation signal one, and how a caller responds. The mechanism a language picks shapes how easy it is to read and understand code. This chapter looks at the history and design choices for how programs handle and recover from errors.

Failures a program expects

Some operations can fail as a normal part of doing their job. Looking up a configuration key that might be absent, parsing an integer from text a user typed, opening a file that might not exist, dividing by a number that might be zero — none of these is a bug in the program. Each is a case the program should anticipate and respond to: try another key, ask the user again, fall back to a default. The failure is part of the operation’s ordinary result, and the caller has a sensible next move.

That makes these failures different from the stuck states the values chapter described. Adding a boolean to an integer has no meaning and no recovery; the interpreter halts. A missing configuration key has a perfectly good meaning — “it is not there” — and a caller who wants to know. The question is how an operation reports such an outcome so that a caller can see it and act on it.

Mechanisms for handling errors

The earliest high-level languages signalled failure the way the hardware did: an operation set a status value, or returned a sentinel — a negative length, a null pointer, a distinguished integer — and the caller was expected to check it before using the result. This costs the language nothing to provide, and it is still how C’s standard library and most operating-system calls report failure. Its weakness is that nothing forces the check: a failed call has the same type as a successful one, so a caller can use its result as though it had succeeded and the compiler will not object, which makes the unchecked return value a classic source of bugs.

The mechanisms modern languages reach for fall into three broad categories. Two of them — exceptions and error values — repair that unchecked return by forcing the failure into the open, in different ways; the third stops the program rather than recovering. The differences show up in where the failure goes, whether the caller is forced to deal with it, and whether the possibility of failure is visible in the operation’s type.

Raising and catching exceptions

An exception moves the failure out of the return value entirely. An operation raises, and control leaves the raising code at once and travels up the call stack until it reaches a handler installed by some enclosing caller. Code between the raise and the handler is skipped. A function that does not care about a particular failure writes nothing about it; the exception passes through on its way to whoever does.

PL/I, in the mid-1960s, is among the first languages to build this into the language itself, with ON-conditions and the ON-units that handle them. The systematic treatment came a decade later: Goodenough’s 1975 paper Exception Handling: Issues and a Proposed Notation named the design questions still argued today. Chief among them is what happens after a handler runs. Under resumption, the handler can repair the situation and hand control back to the point that raised, which continues as if nothing had happened. Under termination, the raising construct is abandoned and control resumes in the handler’s context. CLU (Liskov and colleagues, later in the 1970s) made exceptions part of each procedure’s signature and committed to termination; termination became the model the mainstream languages adopted, and later designs — C++, Java, Python — followed it, while resumption largely fell out of use.

What every exception mechanism shares is that the failure is out of band. A function’s declared return type describes only the success case; whether it can also raise, and what, is either unstated or kept in a separate part of the signature. Java’s checked exceptions are the notable attempt to pull that information back into the type and force callers to account for it — an experiment whose ergonomics have been argued over ever since.

Error values

The functional tradition keeps the failure in the return value, but replaces the unchecked sentinel with a value whose type says failure is possible. ML’s option and Haskell’s Maybe carry “a value or nothing”; Haskell’s Either and Rust’s Result carry “a value or an error.” A function that might fail returns one of these, and a caller cannot reach the success value without first taking apart the wrapper and confronting the failure case.

This is the family the interpreter already uses in Rust: eval returns Result<Value, Control>, and every caller has to decide what an Err means before it can touch a Value. The possibility of failure is written in the type, the compiler checks that the caller handled it, and no separate channel or stack unwinding is involved — the failure is an ordinary value that flows through the program like any other.

Abort: panics

The third answer is to not recover at all. When a failure means the program has hit a state it was built to assume could never happen — a broken invariant, an index the code proved was in range — a language can stop the program outright: Rust’s panic!, C’s abort, a failed assertion. There is no handler and no value; the program immediately ends.

This is exactly the interpreter’s stuck errors from the values chapter, seen from a Bridger program’s side. When eval returns Err(Control::Raise(..)), the run halts and the program cannot intervene — an abort, reported at the top level. The abort family is reserved for failures where continuing would be worse than stopping, which is why it sits apart from the two recoverable mechanisms above.

Error values in Rust

Rust gives a program both Result and Option, defined in the values chapter, and one way to take them apart: match. An operation that can fail returns the wrapper, and the caller matches on it.

fn safe_div(a: i64, b: i64) -> Result<i64, String> {
    if b == 0 {
        Err("divide by zero".to_string())
    } else {
        Ok(a / b)
    }
}

match safe_div(10, 0) {
    Ok(q)  => println!("{q}"),
    Err(m) => println!("error: {m}"),
}

The Result<i64, String> in the signature is the whole point: a caller reads it and knows the call can fail, and the match will not compile unless it covers Err. The failure is in the open, and handling it is not optional.

When failures come in sequence

Handling one failure reads well. The difficulty this family is known for appears when several fallible operations run in sequence, each depending on the one before. Consider building a configuration from three lookups, each of which can fail:

fn read_config() -> Result<Config, String> {
    match lookup("host") {
        Err(e) => Err(e),
        Ok(host) => match lookup("port") {
            Err(e) => Err(e),
            Ok(port) => match lookup("user") {
                Err(e) => Err(e),
                Ok(user) => Ok(Config { host, port, user }),
            },
        },
    }
}

Each step continues inside the Ok arm of the step before, so the real work moves one level to the right for every operation, and the actual result — building the Config — ends up buried at the bottom of the staircase. This rightward drift is the shape critics of error values point to: the same three lookups written with exceptions would sit flat, one after another, because a failure would simply leave on its own. Handled naively, error values put their least flattering foot forward.

The repair is to let a failure leave early instead of nesting. An early return exits the function the moment a step fails, so the success value can continue at the outer level rather than inside an arm:

fn read_config() -> Result<Config, String> {
    let host = match lookup("host") {
        Ok(v) => v,
        Err(e) => return Err(e),
    };
    let port = match lookup("port") {
        Ok(v) => v,
        Err(e) => return Err(e),
    };
    let user = match lookup("user") {
        Ok(v) => v,
        Err(e) => return Err(e),
    };
    Ok(Config { host, port, user })
}

The staircase is gone — the steps are back in a line — but each one is still four lines of the same match-and-return. That repeated shape is exactly what Rust’s ? operator abbreviates. Writing e? on a Result unwraps an Ok to its value and, on an Err, returns it from the enclosing function at once:

fn read_config() -> Result<Config, String> {
    let host = lookup("host")?;
    let port = lookup("port")?;
    let user = lookup("user")?;
    Ok(Config { host, port, user })
}

Each ? stands for the four-line match above it. The code now reads as the three steps it is, the failure path is a single character, and the type still says the function can fail. This is the same ? the eval walk used to thread a Control outward: one operator, propagating the error channel so that ordinary code does not have to. With return and ? in hand, error values read as directly as the exception version, and keep the failure in the type.

How Bridger handles errors

Bridger makes the same choice, one level up: recoverable failure is a value. Its prelude defines Option and Result as ordinary algebraic data types, taken apart with match, and a Bridger program handles a failing operation the way the Rust above does:

fn safe_div(a: Int, b: Int) -> Result<Int, String> =
    if b == 0 { Err("divide by zero") } else { Ok(a / b) };

fn main() {
    match safe_div(10, 0) {
        Ok(q)  => println(q),
        Err(m) => println(m),
    }
}

The two flattening tools carry over as well. return is part of Bridger’s core from the start, introduced with control flow; ? arrives later as surface sugar over match and return, once ADTs give the language the constructors it works on. Bridger’s Option and Result are its own, defined in the prelude, and separate from the Rust Option and Result the interpreter is written in — the values chapter drew that boundary, and it holds here: a Bridger Err("...") is a value the program computes with, while the interpreter getting stuck is an abort the program never sees.

One detail differs from Rust when ? first arrives, and readers who know Rust will trip on it. Rust’s ? quietly inserts a From::from conversion on the error, so an Err of one error type can propagate out of a function whose error type is another. Bridger’s ? begins without that conversion: in a function returning Result<T, E>, every e? requires e to be a Result<_, E> with that same E, and a mismatch is reported as a type error naming two error types that a Rust programmer would expect to be compatible. The conversion needs trait-directed dispatch, which the language gains after ?. Once traits arrive, the prelude’s From<T> trait supplies it, and the desugaring of e? grows one call:

match e { Ok(v) => v, Err(err) => return Err(E.from(err)) }

The from resolves from the enclosing function’s error type — the expected type drives the choice, as it does for an unannotated lambda parameter — and when that type already matches the error, ? needs no conversion and inserts no from call, so code whose error types agree is unchanged. Supplying impl From<IoError> for ConfigError is what makes a Result<String, IoError> propagate out of a function returning Result<Config, ConfigError>. The conversion Rust performs silently is an ordinary trait here, with an impl a program can read and write.

Bridger has no exceptions. They are the contrast in this chapter rather than a feature: the one piece of non-local control in the core is return, and the abort family is the interpreter’s stuck errors. A program signals a recoverable failure by returning a value, and every place that might fail says so in its type.

Choosing a mechanism

No one of the three families is the plain best, and current languages divide over them. Exceptions keep the common path uncluttered — code that does not handle a failure says nothing about it — at the cost of a failure edge that is invisible at the call site and a control flow that leaves normal order. Error values make every failure visible in the type and checked by the compiler, at the cost of the drift that return and ? exist to answer. Aborting trades all recovery for the guarantee that a broken assumption stops the program instead of corrupting it.

Where languages land often reflects what they are protecting. Java, Python, and C++ center on exceptions, with Java’s checked exceptions an attempt to make the failure edge visible that programs frequently route around. Go returns an error value alongside the result and leans on convention to check it. Rust draws the line by kind of failure: Result and Option for the failures a caller should handle, panic! for the bugs it should not — recoverable failures as values, unrecoverable ones as aborts. Bridger follows that division, with the recoverable side modeled as values and the unrecoverable side its stuck errors, and leaves exceptions to the languages that build on them.

Further reading

The design questions, first stated: John Goodenough’s Exception Handling: Issues and a Proposed Notation (CACM, 1975) set out the vocabulary — including the termination-versus-resumption choice — that later designs argued within. Barbara Liskov and Alan Snyder’s Exception Handling in CLU (IEEE Transactions on Software Engineering, 1979) reports the reasoning behind putting exceptions in a procedure’s signature and choosing termination over resumption, from the experience of building the mechanism into CLU.

Error values in practice: the Rust Book’s Error Handling chapter works through panic!, Result, and the ? operator, and draws the recoverable-versus-unrecoverable line this chapter’s closing section describes. Rob Pike’s Errors are values (2015) makes the case for Go’s convention of returning an error alongside the result, and shows patterns that keep the repeated checks from piling up.

Concept checks

A configuration lookup can fail because the key is absent. Why model that with Result or Option rather than the stuck runtime errors of the values chapter?

Because a missing key is a failure the program expects and can act on, and a stuck error is one it cannot. A stuck error — adding a boolean to an integer — has no meaning and no recovery; the interpreter halts and the program never runs again from that point. A missing key has a clear meaning (“not present”) and a caller with a next move (try a default, ask again), so it should be an ordinary value the program inspects and continues from. Reaching for a stuck error here would halt the whole run over a case that was never a bug, and give the caller no way to respond.

An exception and an error value both report a failure. What can a caller tell from a function's type under each?

Under error values, the type states the failure: a function returning Result<Config, String> announces at the call site that it can fail and with what, and the compiler will not let the caller use the result without handling the Err case. Under exceptions, the return type usually describes only success; whether the function can raise, and what, is either unstated or kept in a separate part of the signature, so the caller can compile without accounting for it. The trade is visibility and forced handling against uncluttered code on the path that does not care about the failure — the axis languages divide on. (Java’s checked exceptions are the attempt to move exceptions toward the error-value end of this axis.)

Rewrite this staircase so the failures leave early, then say what ? abbreviates.
match f() { Err(e) => Err(e), Ok(a) => match g(a) { Err(e) => Err(e), Ok(b) => Ok(h(b)) } }

With early returns the two steps come back into a line:

let a = match f()  { Ok(v) => v, Err(e) => return Err(e) };
let b = match g(a) { Ok(v) => v, Err(e) => return Err(e) };
Ok(h(b))

Each ? abbreviates exactly one of those match expressions: f()? evaluates f(), yields the inner value on Ok, and on Err returns it from the enclosing function. So the whole thing becomes let a = f()?; let b = g(a)?; Ok(h(b)). The drift came from continuing inside each Ok arm; return removes the nesting, and ? removes the repetition, leaving the failure path a single character while it stays present in the type.

A Rust programmer writes e? in a Bridger function returning Result<Config, ConfigError>, where e has type Result<String, IoError>. It type-errors. Why, when the same code would compile in Rust?

Rust’s ? inserts a From::from conversion on the error, so an IoError can be turned into a ConfigError on its way out, provided that conversion exists. When ? first arrives, Bridger’s performs no conversion: in a function returning Result<T, E>, every e? requires e to be a Result<_, E> with that same E, so a Result<String, IoError> propagated out of a Result<Config, ConfigError> function is a type error naming the two error types. The conversion needs trait-directed dispatch, which the language gains after ?. Once traits arrive, the prelude’s From<T> trait supplies it: e? desugars to Err(err) => return Err(E.from(err)) for the enclosing function’s error type E, and the program compiles as soon as it provides impl From<IoError> for ConfigError. What was an invisible coercion in Rust is a trait impl the program writes.

Rust offers both Result and panic!. Why would a language keep an abort mechanism at all, when error values can report any failure?

Because some failures mean an assumption the program was built on has broken, and there is no honest value to return. If code has already established that an index is in range or that an invariant holds, a failure of that assumption is a bug in the program, not a case a caller should be asked to handle; threading a Result through it would force every caller to write handling for a situation that should never arise, and would invite them to paper over it. An abort stops the run at the point the assumption failed, which is both the most informative place to report it and safer than continuing from a state the code cannot describe. The division is by kind of failure: values for what a caller should recover from, aborts for what it should not — the line Rust draws with Result against panic!, and Bridger with values against its stuck errors.

Program Semantics

The tree-walking chapter set out the shape of an evaluator: a function that takes an Expr apart and returns its Value, one arm of the walk per rule. Building it is Milestone M1. That evaluator, once written, is one program, and a program cannot be its own definition of the right answer — to say it is correct, we need an account of what evaluating an expression means that stands on its own, ahead of any interpreter. This chapter writes that account for the expressions of Milestone M1. It uses the inference rules the book has been reading since Part I; the notation reference covers how to read a rule and how the rules stack into a derivation. What is new here is the whole rule set at once, and what having it lets us say: which expressions have a value, which have none, and that these rules give no expression more than one.

What the rules specify

Evaluation is the judgment

read expression evaluates to value , defined by the inference rules below. Each rule is a clause of the definition: an expression evaluates to a value exactly when some rule’s conclusion says so and its premises hold. The rules are syntax-directed — the outermost form of selects which rule can apply — and compositional — a compound expression’s value is built from its subexpressions’ values, named in the premises.

The Milestone M1 fragment has no variables, so the rules here are read in the env-free slice . Once let and scope arrive in Part III the judgment carries an environment, , and later features add more; the reference states the full judgment — with the store, the relation database, and the outcome — of which every rule below is the value-case, effect-free projection.

The rules for the M1 fragment

Literals

A literal evaluates to the value it denotes. Written for an integer :

The boolean literals true and false, string literals, and the unit value () each evaluate to themselves the same way; names all four. The rule is an axiom (it has no premise) and it is the base case every derivation ends at.

Arithmetic

Addition evaluates both operands and adds them, provided both are integers:

Subtraction and multiplication have the identical shape; writing for either operator:

The premise is what confines these operators to integers: when an operand evaluates to a value that is not an integer, no arithmetic rule applies. Division carries one more premise — a nonzero divisor:

Modulo % has the same rule with the remainder in place of the quotient, and the same premise, so a zero divisor matches no rule for either operator. Negation flips the sign of an integer:

Comparison and equality

Two families of operator return a boolean, and they differ in the operands they take. The ordering operators (, , , ) take integers, the same restriction the arithmetic rules carry. Writing for one of them and for the matching order relation on integers:

A non-integer operand matches no ordering rule and is stuck.

Equality == and inequality != instead compare structurally: two values are equal when they have the same shape and equal components, so (1, [2]) == (1, [2]) holds. They apply across the M1 value shapes — integers, booleans, strings, unit, tuples, and lists — so their rule needs no premise restricting the operands. Writing for either operator and for the structural test on values:

Later parts extend the structural test to constructor and struct values, and leave it with no result — stuck — on a function or a relation, which have no structural notion of equality. Comparisons do not chain: is not a legal expression in Bridger.

Boolean connectives

The and and or operators short-circuit, and the specification denotes that by giving each two rules. A false left operand settles and without the right operand appearing at all:

and or mirrors it — a true left operand settles the result, a false one hands off to the right:

Because has no premise about , its conclusion holds whatever would do — including when has no value at all. Short-circuiting is a consequence of the rules, so it is a fact of the language rather than a habit of one interpreter.

Strings, tuples, and lists

The ++ operator joins two strings, and equally joins two lists; one rule covers both, requiring the operands to be the same one of those two shapes:

A tuple evaluates its components left to right and collects the values; its arity is at least two:

A list evaluates its elements the same way, and the result is the value the reference writes as a cons sequence:

Neither rule constrains the values its parts produce, so the untyped evaluator builds a tuple or a list from whatever the parts are. Their difference is a typing matter evaluation does not police: a tuple’s positions may each hold a different type, whereas a list’s type [T] fixes one element type for the whole list. The type checker enforces that homogeneity in Part VI, so [1, true] builds a list here and is rejected by the type checker.

A list is also built one element at a time. :: prepends a value to a list, and its rule constrains only the right operand, which must be a list:

The value 1 :: [2, 3] is the same list as [1, 2, 3], and the same list the pattern h :: t takes apart in Part VIII: the expression that builds a list and the pattern that inspects one share their notation.

Projecting a tuple

A tuple’s component is read by position with e.i, where the index is a literal. The projection has a value when the index is in range:

An index outside , or a .i applied to a value that is not a tuple, matches no rule. Lists have no such projection: a list is taken apart by iteration and by patterns in later parts, and the absence of an indexing operator is a deliberate part of the design.

That is the whole M1 fragment. Everything the milestone evaluator computes is one of these rules applied at the root of a derivation whose sub-derivations evaluate the operands. The order in which those operands are evaluated is not observable here, because none of these expressions has an effect; once effects exist, the threaded judgment in the reference fixes the order left to right.

Derivations and stuck expressions

A derivation stacks the rules that justify one another into a tree whose leaves are axioms and whose root is the judgment it establishes. Short-circuiting shows the idea sharply. Take false and (1 / 0 == 0): its whole derivation is one rule over one axiom,

The right operand 1 / 0 == 0 does not appear anywhere in the tree, so the expression evaluates to false even though 1 / 0 on its own has no value. A rule is applied only for what its premises demand, and demands nothing of .

1 / 0 on its own is stuck: no rule concludes a value for it. The only rule for / is , and its premise fails, so there is no derivation of 1 / 0 ⇓ v for any . The same holds for 1 + true: is the only rule that could conclude 1 + true ⇓ v, and its premise fails on true, which evaluates to a boolean. A stuck expression is one for which no derivation exists — the absence of a value, rather than a special value standing for failure. The reference gathers the stuck cases of the whole language.

The interpreter of the previous chapters is what makes that absence observable. Where the semantics simply has no tree, eval returns Err(Control::Raise(..)) carrying a RuntimeError, and ? propagates it to the top level — the treatment error handling describes. The two agree: the expressions the rules leave without a derivation are exactly the ones the evaluator reports rather than assigning a value.

Determinism

Reading the rule set as a whole settles one more question: how many values can an expression have? Nothing about inference rules forces the answer to be one — a rule set can license several values for the same expression, and semantics for concurrent or randomized languages do — so it is a property to check rather than assume. Bridger’s rules give at most one. They are syntax-directed, so the form of picks out the rules that could conclude . For most forms that is a single rule. Where a form has two — and and or — the rules’ premises are mutually exclusive: needs and needs , and cannot do both. Since each subexpression in turn has at most one value, each expression built from them does too.

So is a partial function from expressions to values: every expression has one value or none, and never a choice of two. This is what lets us speak of the value of an expression, and it is why the tree-walker returning a single Value is not making an arbitrary pick among several the rules would allow — the rules allow only one. An expression has no value when it is stuck, and, once the language has recursion, when its evaluation runs forever; a big-step rule set does not distinguish those two cases, a point the next section returns to.

A family of semantic styles

The rules above are one way to give a language meaning: big-step operational semantics, which Kahn named natural semantics, relating an expression directly to its final value. Three other styles answer “what does a program mean” differently, and each is reached for a different purpose.

  • Small-step (structural operational) semantics, due to Plotkin, gives meaning as a single reduction , iterated until no step remains. It exposes the intermediate states a big-step rule passes over, which is what a soundness proof by progress and preservation, and any account of concurrent or interleaved execution, is written against.
  • Denotational semantics, in the Scott–Strachey tradition, maps each phrase to a mathematical object — a function from inputs to results — assembling the whole from its parts. It is the style for reasoning about when two programs mean the same thing.
  • Axiomatic semantics, from Floyd and Hoare, gives meaning through what can be proved about a program, in assertions . It is the style for verifying that code meets a specification, rather than for computing the value it produces.

This book works in the big-step style throughout. Each rule becomes one arm of the tree-walker, a derivation tree is the evaluator’s call tree, and the judgment is exactly the type of eval — an expression in, a value out — so the specification and the interpreter have the same shape and can be read against each other. It is also compact enough to be the specification you write for each new feature as it is added, which is how the rest of the book proceeds.

What the style gives up is the intermediate detail. A big-step rule set relates an expression to its final value and describes nothing between, so it cannot tell a computation that runs forever from one that is stuck: each simply lacks a finite derivation. It also fixes no order of steps beyond what the premises thread. Small-step semantics recovers both. Neither matters here — the book has no concurrent execution to describe and treats soundness informally — so big-step gives up nothing this book needs, and keeps the directness of reading as the interpreter it defines.

Further reading

The semantic styles and their sources: the founding papers for these styles — Plotkin on structural operational semantics, Kahn on natural semantics, and Hoare on the axiomatic style — and Glynn Winskel’s book-length development of all of them are collected in the further reading for Syntax and semantics, where the notation the rules above use was first introduced.

Concept checks

The expression false and (1 / 0 == 0) evaluates to false, but 1 / 0 on its own is stuck. How do the rules produce both facts?

1 / 0 is stuck because its only rule, E-Div, has the premise , which fails, and no other rule concludes a value for a / expression — so there is no derivation of 1 / 0 ⇓ v. false and (1 / 0 == 0) is settled by E-And-False, whose single premise is that the left operand evaluates to false; the rule says nothing about the right operand, so its derivation is just E-And-False over the axiom false ⇓ false, and 1 / 0 never enters the tree. A rule reaches a subexpression only when a premise names it, and short-circuiting is exactly the absence of such a premise in E-And-False.

On 1 + true the rules assign no value at all, rather than a special error value. Why is that the definition, and how does the interpreter make the missing value visible?

The only rule that could conclude 1 + true ⇓ v is E-Add, whose premise fails because true evaluates to a boolean; no other rule applies, so no derivation exists. Being stuck is that absence of a derivation — there is no value, so the semantics invents none. Adding a special “error value” would make it a value like any other, one a later operation could read and carry on from, which is the opposite of “there is no result here.” The interpreter keeps the two apart: it returns Err(Control::Raise(RuntimeError::TypeError { … })) and propagates it with ?, so the absence the rules describe becomes a report at the top level rather than a Value in circulation.

and is defined by two rules, not one. Why does giving a form more than one rule not make evaluation nondeterministic?

Because the two rules cannot both apply to the same expression. E-And-False requires and E-And-True requires , and since has at most one value, at most one of those premises holds. The rules partition the cases rather than offering a choice within one. Determinism needs each expression to have at most one value, and that survives a form having several rules as long as their premises are mutually exclusive — which, across the whole M1 fragment, they are.

A big-step rule set cannot tell a computation that runs forever from one that is stuck; small-step semantics can. Give a setting where that distinction earns its keep, and say why this book can let it go.

The distinction matters most for a soundness proof. Type soundness is often stated as “a well-typed program does not get stuck,” and proved in the small-step style as progress (a well-typed non-value can take a step) and preservation (a step keeps the type). That argument needs the intermediate states — the steps — which big-step discards, and it needs stuckness told apart from looping forever, since a sound language rules out the first while still permitting the second. Reasoning about concurrent or interleaved execution needs the intermediate states for the same reason. This book has no concurrency to describe and treats soundness informally rather than as a step-by-step proof, so it never has to separate the two non-terminating outcomes, and the directness of big-step is worth more to it than the detail it gives up.

The tree-walker already computes values. What does writing the rules down add that reading the interpreter's code does not?

The rules define the right answer independently of the program meant to produce it, so “the evaluator is correct” becomes a claim with content: it agrees with the rules. They also pin down what code leaves implicit. Whether and short-circuits in the language, rather than only in this interpreter, is fixed by E-And-False having no premise about its right operand; whether an expression may have two values is answered by the rules being a partial function; which expressions are errors is answered by which have no derivation. And because each rule maps to one arm, the rule set is the recipe for extending the evaluator: to add a feature, write its rules, then add the arms that mirror them.

Milestone M1

The Expression Evaluator

Part II built the pieces of an evaluator: the values it produces, the walk that produces them, the failures it reports, and the rules that fix what each expression means. This milestone puts them together into a working evaluator for the expression fragment, and gives you a way to know it runs correctly.

The codebase

The starter is one interpreter that you grow across the milestones. Nearly all of it is provided and frozen — the parser, the value and error types, the environment and the store — so your effort goes into meaning rather than the plumbing that would have you fighting the borrow checker. The files that matter here:

src/
  ast.rs            the Expr tree and its companion types    (provided)
  interp/
    eval.rs         eval_expr — YOUR file
    value.rs        the Value type, and type_of              (provided)
    error.rs        RuntimeError and Control                 (provided)
    env.rs          the environment (used from M2)           (provided)
    store.rs        the store (used from M3)                 (provided)
  parser/           source text -> Expr                      (provided)
  types/            the type checker (M5)                    (deferred)
  relations/        the Datalog fragment (M6)                (deferred)

Most work happens in one method, which you grow a little each milestone:

pub fn eval_expr(&mut self, e: &Expr, env: &Env) -> Result<Value, Control>

Every Expr variant already has a match arm. The ones you have not reached yet are milestone-tagged holes — a todo_mN!(…) that compiles as any type but stops the run if reached — so the crate builds from the first clone with nothing written. As you fill an arm, you replace its hole with the rule’s implementation. To read the shape of any provided type — Value, Expr, RuntimeError — while you work, build the API docs and open them in your browser:

cargo doc --no-deps --open

Setting up

Install the Rust toolchain from rustup — it brings cargo, the formatter, and the linter — and confirm it runs:

cargo --version

Clone the starter repository, then build it once. A fresh clone builds green, because every hole compiles:

cd bridger-interpreter
cargo build

From there, the loop you repeat on every milestone:

cargo m1                                    # run the Milestone-1 tests
cargo fmt                                   # format your code
cargo clippy --all-targets -- -D warnings   # lint

cargo m1 is short for cargo test --features m1 — the tests you should pass in Milestone M1. The milestones are cumulative. So cargo m2 will run the tests for Milestone M2 and Milestone M1.

To-dos

Every place you write code is a macro named for its milestone, so a search lists exactly what a milestone asks for:

grep -rn 'todo_m1!' src

turns up the six arms of eval_expr — and in general, grep -rn 'todo_mN!' src is Milestone N’s checklist. The six for M1:

// src/interp/eval.rs — the six M1 arms of eval_expr
Expr::Lit(..)    => todo_m1!("E-Lit"),
Expr::Unary(..)  => todo_m1!("E-Neg / E-Not"),
Expr::Binary(..) => todo_m1!("E-Arith/Ord/Eq, E-And/Or, E-Concat, E-Cons"),
Expr::Tuple(..)  => todo_m1!("E-Tuple"),
Expr::List(..)   => todo_m1!("E-List"),
Expr::Proj(..)   => todo_m1!("E-Proj"),

Every other Expr variant is a hole tagged for a later milestone, which is why the crate compiles with none of them written.

Milestone specification

Each expression form, the rule that gives it meaning, and the case that leaves it stuck:

ExpressionRuleStuck when
n, b, s, ()E-Lit—
-eE-Negthe operand is not an Int
e + e, e - e, e * eE-Arithan operand is not an Int
e / e, e % eE-Div / E-Modan operand is not an Int, or the divisor is 0
e < e, <=, >, >=E-Ordan operand is not an Int
e == e, e != eE-Eq(structural; at M1, never)
not e, e and e, e or eE-Not / E-And / E-Oran operand is not a Bool
e ++ eE-Concatoperands are not both strings or both lists
e :: eE-Consthe right operand is not a list
(e, …), [e, …]E-Tuple / E-List—
e.iE-Proje is not a tuple, or i is past its end

The full rules are in the Program Semantics chapter; this table is the checklist your evaluator is measured against, not a second statement of them.

Three contracts hold across every arm. On success an arm returns Ok(v); when the expression is stuck it returns Err(Control::Raise(…)) carrying the RuntimeError and the span of the node the rule blames — the interpreter reports rather than panics. and and or short-circuit: the right operand is evaluated only when the left has not already settled the result. And integer arithmetic wraps modulo , as the values chapter fixed.

Implementing Milestone M1

Destructure each arm’s .. into the fields you need. The M1 variants and what they carry:

VariantFieldsCovers
Expr::Lit(Lit, Span)a literal: Lit::Int, Bool, Str, Unit
Expr::Unary(UnOp, Box<Expr>, Span)UnOp::Neg, UnOp::Not
Expr::Binary(BinOp, Box<Expr>, Box<Expr>, Span)the operators below
Expr::Tuple(Vec<Expr>, Span)(e, …)
Expr::List(Vec<Expr>, Span)[e, …]
Expr::Proj(Box<Expr>, u32, Span)e.i, the index a literal u32

BinOp names the operator: Add Sub Mul Div Mod for arithmetic, Eq Ne Lt Le Gt Ge for comparison, And Or, Concat for ++, and Cons for ::.

Three tools do the work of every arm:

  • Evaluate a subexpression with self.eval_expr(sub, env)?. The ? sends a stuck operand straight out, so an arm only handles the case where its operands produced values.
  • Build the result — Value::Int, Value::Bool, Value::Str, Value::Tuple, Value::List. Integer arithmetic uses the wrapping operations (i64::wrapping_add, wrapping_mul, …), since the reference fixes 64-bit wraparound.
  • Report a stuck expression — return RuntimeError::TypeError { expected, found, span } or RuntimeError::DivByZero { span }. The provided type_of(&v) gives the found type, Ty::int() / Ty::bool() / … name the expected one, and span is the offending node’s. A From<RuntimeError> for Control is provided, so .into() (or ?) lifts a RuntimeError into the Control::Raise the signature returns.

You have already seen this in Part II: the Add arm is worked in full, and Div and and show the divisor guard and short-circuiting. The rest of M1 is the same shape, one arm at a time.

Worked examples

An expression, and the value it evaluates to:

1 + 2 * 3          ⇓ 7
-(3 - 5)           ⇓ 2
7 / 2              ⇓ 3          (integer division)
7 % 2              ⇓ 1
2 < 3              ⇓ true
(1, 2) == (1, 2)   ⇓ true       (structural)
"ab" ++ "cd"       ⇓ "abcd"
[1, 2] ++ [3]      ⇓ [1, 2, 3]
0 :: [1, 2]        ⇓ [0, 1, 2]
(10, 20).1         ⇓ 20
false and (1 / 0)  ⇓ false      (right operand never evaluated)

And expressions with no value, which halt the run:

1 + true      ⇓ stuck   (an Int operator met a Bool)
5 / 0         ⇓ stuck   (division by zero)
"a" ++ [1]    ⇓ stuck   (++ needs both strings or both lists)
(1, 2).5      ⇓ stuck   (projection past the end of a 2-tuple)

How to know it works

cargo m1 runs the set of provided milestone M1 tests; all pass means the fragment is likely correct. Each test builds an Expr and calls eval_expr on it — one rule at a time. A value test checks the Value; a stuck test checks the error’s variant, its expected/found types, and the span it blames, but not its wording. The builders (int, binary, tuple, …) and the assertions value_of and stuck come with the repository, so a check reads close to the rule it exercises:

// 1 + 2 * 3  ⇓  7
let e = binary(Add, int(1, 0), binary(Mul, int(2, 2), int(3, 4), 3), 1);
assert_eq!(value_of(&e), Value::Int(7));

// 1 + true  is stuck: an Int operator met a Bool
let bad = binary(Add, int(1, 0), boolean(true, 2), 1);
assert!(matches!(stuck(&bad), RuntimeError::TypeError { .. }));

Fill an arm, run cargo m1, and repeat until the all M1 tests pass.

Design problems

Your eval_expr returns a single Value, never a set of them. Which property of the rules lets it, and where do the rules secure it?

Evaluation is deterministic: no M1 expression has two values. The rules are syntax-directed — the outermost form of an expression picks the rule that could conclude e ⇓ v — and the only forms with more than one rule, and and or, have mutually exclusive premises (the left operand is true in one rule and false in the other, and it cannot be both). Since each subexpression has at most one value, so does each expression built from them. A single Value return type is honest because the judgment e ⇓ v is a partial function: one value, or none when the expression is stuck. The Program Semantics chapter makes this argument in full.

Write the list [1, 2, 3] two ways using only M1 operators, and say whether the two expressions produce equal values.

The literal [1, 2, 3], and the same list built by prepending onto the empty list, 1 :: 2 :: 3 :: [] (or 1 :: [2, 3]). Both evaluate to the same value — the reference models a list as cons cells, and E-List builds [1, 2, 3] as exactly 1 :: 2 :: 3 :: [] (values), so the two expressions denote the same cons list. == on them is true: equality is structural, and they have the same shape and equal elements.

Write an M1 expression that evaluates to a value but would be stuck if and and or did not short-circuit. Which rule makes the difference?

false and (1 / 0) evaluates to false. The rule E-And-False concludes the result from the left operand alone — it has no premise about the right operand — so 1 / 0 is never evaluated and never gets a chance to be stuck. An evaluator that evaluated both operands before combining them would evaluate 1 / 0, hit the divide-by-zero, and leave the whole expression stuck. true or (1 / 0) ⇓ true works the same way through E-Or-True.

5 / 0 is stuck, and the run halts. A program might rather detect the zero and fall back to a default. At M1, can it? What later feature would let it?

At M1 it cannot. A stuck evaluation is the absence of a result, not a value the program can inspect — there is no result to branch on, and M1 has no branching anyway. Choosing a fallback needs two things that arrive later: a conditional to test the divisor first (Part IV), and, to carry “an answer or a failure” as a value the program handles with match, the Option and Result types built from algebraic data types in Part VIII. The type checker of Part VI rules out the type-shaped stuck cases — 1 + true and its kin — before a program runs, but division by zero is not one it can catch ahead of time, so it survives into every later milestone.

Environments and let

Through Part II an expression meant one value wherever it stood: 2 + 2 is 4 in every context, and the evaluator needed nothing but the expression itself to find it. A variable is the first form whose value the expression does not settle on its own. x stands for 3 where a binding says so, for "hi" where another one does, and for nothing at all where none does. The structure that supplies each name’s value is the environment, and this chapter describes what an environment is and how it came to be.

Algol 60 made where does a name refer? a question with a principled answer — a name introduced in a region of a program means something inside that region and not outside it — but left open what such a region is made of once the program runs. The environment is the answer: a map from the names in scope to the values they currently denote. In Bridger, we use let x = e to extend the environment with a binding of the name x to the value e evaluates to.

A name has a value only in context

The evaluation judgment has carried an environment since Part I, and every rule threaded the same through its premises. Until now nothing read or modified the environment. The arithmetic, boolean, string, and list forms have no variable to look up, so each rule passed along untouched and the derivations dropped it, writing ; the starter’s eval_expr did the same, taking an env parameter that no arm consulted. A variable changes that. Its rule reads the environment and nothing else:

is a finite map from variables in scope to values, ; is the value it binds to . When has no binding for , the premise cannot be met, no rule applies, and the expression is stuck — the same absence of a derivation that a type mismatch produces, reported as the runtime error UnboundVariable. An unbound name is the failure this chapter adds to the list; every other stuck case so far came from a value of the wrong shape, and this one comes from a name with no value at all.

The environment as an explicit part of an evaluator goes back to Peter Landin’s SECD machine, whose four components — stack, environment, control, dump — evaluate an expression by looking each variable up in the environment component, the E (Landin, 1964). Two years later the same author’s ISWIM gave the notation let x = e (and its mirror, the where clause) for naming a value over a region of an expression (Landin, 1966); the keyword has since landed in languages far from ISWIM, Bridger among them.

The let operator

A block is a sequence of items evaluated top to bottom, and its value is its final expression. A let is one kind of item: it evaluates its right-hand side and binds the name over the rest of the block. Writing for the block that remains after the first item, the rule extends the environment for that remainder and for nothing else:

The notation is the environment with bound to added: it agrees with on every other name and answers for . The right-hand side is evaluated in — the environment before the binding is added — so the name being bound is not yet in scope while its own value is computed. The rest of the block is evaluated in the extended environment, which is where the new name can be read.

An item that is a bare expression is evaluated for whatever it does and its value discarded; the block carries on in the same environment. The final expression is the block’s value, and an empty block is unit:

Because extends the environment only over the block’s remainder, a binding reaches from its let to the end of the enclosing block and no further. A name bound inside a block is not in scope after the block ends, and a name bound in an outer block is in scope within an inner one unless the inner block binds the same name again.

A binding is confined to its block even when that block is nested inside another. Take a block whose first item is itself a block:

{
    { let x = 1; x + 1 };   // inner block; x is bound only within it
    x                       // UnboundVariable: x's scope ended at the }
}

The outer block evaluates its first item — the inner block — by , which evaluates it in the current environment and discards the result. Inside, extends the environment with for the inner block’s remainder alone, yielding 2. then evaluates the rest of the outer block in the same environment it began with — the one with no x — so the final x is unbound. A block produces a value, and the binding made inside the inner block never leaves it.

Binding a name a second time is shadowing: the new binding answers for from that point on, and the earlier value is unreachable through the name for the rest of that region though it is not destroyed. Since the right-hand side is evaluated before the binding is added, a let may define a name in terms of the value the same name held before it:

let x = 1;
let x = x + 1;   // the x on the right is the old 1; x is now 2
x                // 2

Building it

The environment now reaches the evaluator’s signature. The provided method is

fn eval_expr(&mut self, e: &Expr, env: &Env) -> Result<Value, Control>

and the env parameter, carried unused since Milestone M1, is what the new arms read. An Env offers two operations: lookup, which returns the value a name is bound to or None, and extend, which returns a new environment with one more binding and leaves the one it was called on untouched. needs only the first: look the name up, and a miss is UnboundVariable. A block threads the second — it walks its items carrying an environment that grows as each let is met — and because every extension produces a fresh scope, that growth reaches the rest of the block and stops at its closing brace.

// M2: read a variable, and evaluate a block by threading a
// scope that each `let` extends — scope = scope.extend(x, v)
Expr::Var(..)   => todo_m2!("look up a variable"),
Expr::Block(..) => todo_m2!("evaluate a block"),

That the environment is never rewritten is the rule the whole chapter turns on. is read-only: a let adds a binding, and rebinding a name adds a new binding that shadows the old one, which is left as it was. Making a name’s value change over time — a counter that counts — is a separate mechanism, a mutable cell held in the store, and it arrives in Part IV. How a name’s binding is fixed by where it is written, the property that makes scope decidable before the program runs, is the next chapter; how an Env is represented, and what lookup costs as scopes nest, is the chapter after. Reading variables and evaluating blocks completes the evaluator’s work for Milestone M2.

The design space

let need not be a form of its own. Landin’s observation was that a block beginning let x = e_1; computes what applying the function — whose body is the rest of the block — to the argument computes: both evaluate , bind the result to , and evaluate that body in the extended environment. A language with functions can treat let as sugar for that application and carry one rule instead of two. Bridger keeps let a form in its own right — functions arrive in Part V, and let is available from the first block a program writes — but the two describe the same binding, and the closure rule in Part V will extend the environment the same way does.

Whether a name is in scope within its own right-hand side is a genuine fork, and takes a side: it evaluates in the environment before the binding is added, so x is bound only after its definition has been evaluated. A definition may therefore use the earlier meaning of a name it rebinds, and a let-bound function cannot call itself, since the name is not in scope while the function value is being built. Languages that want a let to bind a recursive function offer a separate recursive-binding form — let rec, or a letrec that binds the name over its own right-hand side — that closes exactly this gap. In Bridger, self-reference goes through a top-level fn, where every top-level name is in scope in every definition, so functions may call themselves and each other regardless of order.

Rebinding a name that is already in scope is a choice too. Bridger allows it — shadowing is ordinary — where some languages reject a second binding of a name in the same scope and require the programmer to choose a fresh one. Allowing it lets a block reuse a name for a series of related values; rejecting it turns an accidental reuse into an error the compiler catches. The tradeoff is between a convenience and a class of mistake, and Bridger takes the convenience.

Further reading

The environment and let: Peter Landin’s The Mechanical Evaluation of Expressions (The Computer Journal, 1964) introduces the SECD machine, whose environment component is the structure a variable is resolved against, and The Next 700 Programming Languages (Communications of the ACM, 1966) introduces ISWIM and its let and where notations for naming a value over a region of an expression, together with the reading of a local definition as a function applied to its right-hand side.

The environment model of evaluation: Abelson and Sussman’s Structure and Interpretation of Computer Programs (MIT Press, free online) develops evaluation as the extension of environments in its chapter of that name, working through how a binding form adds to the environment and how a name is looked up in it.

Concept checks

Evaluating x in an environment with no binding for x produces the runtime error UnboundVariable. In what sense is this the same kind of failure as 1 + true, and in what sense is it new?

It is the same kind in that it is stuck: ’s premise cannot be met, so no rule applies and there is no derivation, exactly as no rule applies to adding a boolean to an integer. The interpreter returns Err(Control::Raise(RuntimeError::UnboundVariable { … })) and halts the run; the missing value is the absence of a result, and no special result stands in for it. What is new is the cause. Every stuck case in Part II came from a value of the wrong shape reaching an operator; this one comes from a name that denotes no value at all, so it is caught by looking in the environment rather than by inspecting a computed value.

What does the block { let x = 1; let x = x + 1; x } evaluate to, and which environment is each x + 1 and final x read in?

It evaluates to 2. The first let evaluates 1 in the incoming environment and extends it to . The second let’s right-hand side x + 1 is evaluated in that environment — the one before the second binding is added, by — so its x is the 1 just bound, and the block extends to , in which x now reads 2. The final x is evaluated there and yields 2. The first binding is shadowed and left intact: it supplied the value the second definition read.

let x = e; rest and applying λx. rest to e compute the same value. Why does Bridger keep let as its own form, and why can't a let-bound lambda call itself?

The two agree because both evaluate e, bind the result to x, and evaluate the body in the extended environment; a language with functions can define let as that application. Bridger keeps let a primitive form because binding is available from the first block a program writes, before functions are introduced in Part V, and carrying it as its own rule keeps blocks self-contained. A let-bound lambda cannot call itself because evaluates the right-hand side in the environment before the binding is added: the name is not yet in scope while the function value is built, so the closure captures no binding for it. Self-reference in Bridger goes through a top-level fn, where all top-level names are mutually in scope.

The environment is described as read-only, yet a program plainly binds many names. What is read-only about it, and what does extend returning a new child scope guarantee?

Read-only means no operation changes a binding once made: extend produces a new environment with an added binding and leaves its parent exactly as it was, and lookup only reads. Binding x twice does not rewrite the first binding; it produces a further environment in which a lookup of x finds the newer one first. Because extend yields a child rather than mutating in place, the environment a block builds is local to that block — when evaluation of the block finishes, that environment is discarded and the caller’s environment is untouched, which is why a name bound inside a block is not in scope outside it. Making a name’s value change over time is a different mechanism, a mutable cell in the store, and it is the subject of Part IV.

What does the block { let x = 5; } — a block whose last item is a let, with no trailing expression — evaluate to?

It evaluates to (). A block’s value is its trailing expression, and this block has none: its only item is a binding. A let binds a name over the rest of the block and contributes no value itself, so a block that ends with one has the same value as an empty block, unit. The binding x is in scope only for whatever would follow it in the block, and here nothing does.

Lexical Scope

The environment is a runtime object: it is built as a program runs, growing by a binding each time evaluation meets a let and reverting to what it was when a block ends. Which value a name reads therefore depends on how far the program has run. Yet the question this chapter asks has an answer that does not wait for the program to run at all. In the following code block,

{
    let x = 1;
    let y = { let x = 2; x + 10 };
    x + y
}

the name x inside the inner block reads 2 and the x on the last line reads 1, and that interpretation is settled by determining which binding each reference to x refers to by reading the braces, without evaluating anything. Every use of a name in a program refers to one definite binding, and that binding is fixed by where the use is written. Determining a name’s binding from the shape of the program text, ahead of running it, is lexical scope (also called static scope), since the binding is settled before, and independently of, any execution.

Free and bound occurrences

For every occurrence of a name (one location where the name is written and read), we say the occurrence is bound in an enclosing block of code when that block contains a let binding for the name somewhere before the occurrence; otherwise, the occurrence is free in that block. In the following block of code,

{ let x = 2; x + 10 }

the occurrence of x in x + 10 is bound: the let x = 2 that precedes it in the same block is the binder that x is bound to. However, in the following block of code without the let binding,

x + 10        // x is free here — nothing in this fragment binds it

the occurrence of x is free, because the region under consideration no longer contains the let that bound x. Whether an occurrence is free or bound is relative to the region you are reading. A let binding reaches inward over the text it encloses, and an occurrence of a name is bound exactly when it falls inside the reach of a binder that binds its name.

The scope of a binding

The scope of a binding is the region of program text over which its name refers to it. For a let binding, the rule E-Let already fixed that region: a let extends its scope over the rest of its block, so the binding’s scope runs from just after the let binding to the closing brace of the block that holds it. This is the arrangement of Algol 60’s block structure — a name declared inside a region means something there and nowhere else — and it is what gives a block-structured program its nesting of scopes, each one the text between a binder and the brace that closes over it.

Scopes nest because blocks nest. An occurrence lies within the scope of a binding when it falls inside that binding’s region — the text from the let to the closing brace of its block, nested sub-blocks included. An occurrence can lie within the scopes of several bindings of the same name, one nested inside another; it is then bound by the innermost binding (the binding whose scope is the smallest) of the same name.

{ let x = 1; let y = { let x = 2; x + 10 }; x + y }

In the margin, each binding’s scope is drawn as a bar in its own colour. let x = 1 reaches over the rest of the outer block, but its bar breaks along the x + 10 line, where the inner let x = 2 binds x, its scope drawn by the orange bar. That break is a hole in the outer binding’s scope. We say that the innermost binding of x shadows the outer binding of x. The x in x + 10 lies inside both blocks and resolves to the orange binding; on the x + y line, past the inner block, the outer (blue) binding is in force again, so that x reads 1. let y reaches only the final line x + y, the one place y can be read, so its green bar covers just that row.

The rule for reading a program is uniform. An occurrence of a name refers to the binding of the nearest binder that encloses it — nearest meaning the smallest enclosing region that contains both the occurrence and a binder for the name. When no binder encloses it, the occurrence is free, and a name still free at the top of a program has no binding to resolve to: evaluating it results in an UnboundVariable error. None of this appeals to a value, an input, or how far the program has run. The binding each occurrence refers to is a function of the program text alone, which is why it can be read from the program’s text itself (without ever executing the program) — and why the word for the discipline is lexical, from the arrangement of the text itself.

Lexical scoping as a property

In Bridger, nothing new is added to the evaluator to enforce lexical scoping; instead, lexical scoping of let bindings is a property of the Milestone M2 semantic rules that were already described in the previous chapter. The E-Let rule extends the environment over the textual remainder of the block, and E-Var reads whatever environment has been threaded to the point of the occurrence: so the binding a variable finds at run time is the one the enclosing text put there.

The two views line up because the environments built at run time take the shape of the text. Each block is evaluated by extending the environment it was entered in, and a block is entered from the environment of the text that surrounds it, so at any point during the run the environments form a chain of children whose nesting matches the blocks that enclose that point in the source. Looking a name up — reading the current environment, then its parent, and outward — visits the same bindings, in the same order, as reading outward from the occurrence through the blocks that enclose it. The run-time lookup and the reading off the page cannot disagree, because they walk the same nesting. How that chain is represented, and what walking it costs as scopes deepen, is the topic of the next chapter.

Settling a name’s binding before the program runs is what later work builds on. The same chapter resolves every occurrence to a fixed position in the enclosing scopes, so that a name need not be searched for by spelling at all. And because a name’s binding is fixed by its definition, the type of what it holds can be too, which is what lets the checker in Part VI decide a program’s types without running it.

The design space

A name that is free in a fragment must ultimately resolve somewhere, and there are two disciplines for where. Under lexical scope (the scoping rules that Bridger uses), a free occurrence resolves in the environment that textually encloses it: the environment present where the code is written. Under dynamic scope, it resolves in the environment present where the code runs, the environment active at the moment control reaches the occurrence, which is decided by the execution order of the program.

In a language whose only way to enter a new environment is to enter a block, the code that runs in an environment is precisely the code written inside it, so the two disciplines choose the same binding for every occurrence, and nothing a program can do distinguishes them. They come apart only once a region of code can be carried away from where it was written and run somewhere else (i.e., via a function call from a separate part of the program). A function body mentions names it does not itself bind, and the two disciplines answer differently once its body runs under a call rather than where it was defined. That is where the choice becomes observable, and where Bridger’s commitment to lexical scope is made executable and weighed against the alternative (dynamic scoping); it is the subject of Part V.

Further reading

Block structure: the Report on the Algorithmic Language ALGOL 60 (1960) is where a name declared inside a region of a program is given a region of validity determined by the program’s own nesting — the arrangement this chapter reads scopes off of.

The environment model of scope: Abelson and Sussman’s Structure and Interpretation of Computer Programs (MIT Press, free online) develops evaluation as the extension of environments, and works through how a name is looked up by searching outward through the enclosing scopes.

Concept checks

List the free variables of { let a = b; a + c }. Which occurrences are bound, and by what?

The free variables are b and c. The occurrence of b on the right-hand side of the let is free: the let a has not taken effect yet where b is read (its scope begins after it), so nothing in the fragment binds b. The occurrence of c is free because no let c encloses it. The one use of a, in a + c, is bound by the let a = b, whose scope is the rest of the block. Read as a whole program, b and c are still free, so evaluating the block raises UnboundVariable on b.

In { let x = 1; { let x = 2; x }; x }, which binding does each of the two x occurrences refer to, and how did you decide without running it?

The x inside the inner block refers to let x = 2; the x on the last line refers to let x = 1. Each occurrence takes the nearest enclosing binder. The inner x sits inside both lets’ scopes and resolves to the smaller, inner one — the inner let x = 2 carves a hole in the outer binding’s scope over the inner block. The final x sits outside the inner block, where the outer binding is again in force. The decision needs only the braces: the binding each occurrence refers to is fixed by the text, so it is read off the nesting rather than computed by evaluating the program.

Lexical scope is also called static scope. What does "static" claim, and what does the claim make possible?

“Static” claims that the binding each occurrence refers to is settled before, and independently of, running the program — it is a function of the program text alone, settled without reference to any value, input, or how far execution has progressed. Because the binding is fixed ahead of time, work that would otherwise wait for the run can be done by reading: the next chapter resolves every name to a fixed position in its enclosing scopes, so no name need be searched for by spelling at run time, and the type checker of Part VI can fix the type of what a name holds at the name’s definition and certify a program’s types without executing it.

No evaluator code is added in this chapter, yet Bridger is committed to lexical scope. Which rule carries the commitment, and how?

E-Let carries it. E-Let extends the environment over the textual rest of the block — the region between the let and the closing brace — so the environment a variable is read in at run time is the one the enclosing text placed it in. Because each block is entered from the environment of the text around it, the environments built during a run nest the same way the blocks do, and E-Var reading the current environment and its parents visits the same bindings as reading outward through the enclosing blocks. The lexical reading and the run-time lookup agree because the rule already makes a binding’s reach follow the text.

In a language with blocks but no functions, no program can tell lexical scope from dynamic scope. Why, and what feature changes that?

Dynamic scope resolves a free occurrence in the environment active where the code runs, lexical scope in the environment where it is written. With blocks as the only way to enter a new environment, the code that runs in an environment is exactly the code written inside it, so “where it runs” and “where it is written” are the same place and the two disciplines choose the same binding every time. The feature that separates them is the function: a function body can be carried away from where it was written and run under a call somewhere else, so its free names have one environment at the definition and possibly another at the call. Once bodies run away from home the two disciplines diverge, which is why the question is taken up in Part V rather than here.

Environment Representations and Lookup

The previous chapter settled what an environment has to provide: lookup, which reads the value a name is currently bound to, and extend, which produces a new environment binding a name to a value — the two operations Bridger’s lexical scope is built from. A running program reads a variable at nearly every step and enters or leaves a block at many of them, so lookup and extend run constantly, and the way the environment is represented sets what they cost. This chapter opens the representation the Bridger starter repository provides: what the scope chain is made of, and what a lookup costs as the chain deepens. It is the environment your interpreter uses in Milestone M2 to evaluate variables, let bindings, and blocks.

The chain of scopes

In the previous chapter, we learned how to read the value a name was bound to by following lexical scoping rules. How do we make that mechanical? The general intuition we used in lexical scoping was that the value a name was bound to was defined by the innermost let binding for that name next to the occurrence being read. We use that intuition to represent the environment as an immutable linked list of bindings whose head is the innermost binding and whose tail is the outermost binding. Consider the following block of code:

let x = 1;
{
  let y = 2;
  {
    let z = 3;
    x + z
  }
}

On line 6, when we evaluate x + z, the scope has an environment with the bindings x = 1, y = 2, and z = 3. Pictorially, the linked list of bindings would appear as a chain with the innermost binding being z = 3, then y = 2, then x = 1, and finally any environment containing the block of code (e.g., the global/root environment). If we wanted to look up the value of x in the environment, we would walk from the innermost binding (z = 3) rightward until we found a binding for x, or reached the end of the list and reported that x is unbound.

look up x — walk outward until x is found innermost root z = 3 y = 2 x = 1 prelude miss miss hit

The figure traces that walk for x: a miss at z’s scope, a miss at y’s, and a hit at the third. That walk is E-Var — a name is read by starting at the innermost scope and following the links outward, taking the first binding it finds — and running off the end of the chain without a match is the UnboundVariable case. Because the walk stops at the first match, an inner binding is reached before an outer one of the same name: the walk is what makes shadowing resolve to the innermost binding.

Each extend produces one such scope — a binding and a link to the scope enclosing it — and leaves its parent untouched, because the environment is read-only. A parent is therefore shared by every scope built on top of it, and leaving a block discards only the innermost scope and exposes the chain below it unchanged: a name bound inside a block is out of scope the moment the block ends, because nothing was written that has to be undone.

In Rust, a scope is a small record — a name bound to a value, and a link to its parent:

// A scope: one binding, and a link to the scope that encloses
// it. `Rc` lets a scope be shared — several scopes can hold the
// same parent, and a value can keep a scope alive after the
// block that built it has returned.
struct Scope {
    name:   Name,
    val:    Value,
    parent: Option<Rc<Scope>>,
}

// `extend` puts a new scope in front and leaves its parent as
// it was — the read-only environment of the previous chapter.
fn extend(parent: &Rc<Scope>, x: Name, v: Value) -> Rc<Scope> {
    Rc::new(Scope { name: x, val: v, parent: Some(Rc::clone(parent)) })
}

// `lookup` walks outward: this scope, then its parent, and on
// until the name matches or the chain runs out — a run-out is
// the `UnboundVariable` of the previous chapter.
fn lookup(scope: &Rc<Scope>, x: &Name) -> Option<Value> {
    let mut cur = scope;
    loop {
        if cur.name == *x {
            return Some(cur.val.clone());
        }
        match &cur.parent {
            Some(p) => cur = p,
            None => return None,
        }
    }
}

The parent link is an Rc, a reference-counted shared pointer. A plain owned pointer — one scope owning its parent outright — would be enough while a scope lives no longer than the block that built it and is freed when that block ends. It stops being enough once a value can carry a scope away from its block: a function value keeps the scope it was defined in so it can read its free names when it later runs, and that scope has to stay alive after the block that created it has returned (the subject of Part V). Shared ownership lets the carried-away scope and the chain both hold it, and it lives as long as either does. The reason the chain is refcounted rather than owned lands with closures; the representation is chosen here so that it can.

Each scope holds a single binding because Bridger adds one scope per let, so the chain grows by a link at every binding. A common alternative gives each block one scope holding all of its bindings in a map; a lookup then walks scope to scope and probes the map at each. The distance a lookup travels is measured in bindings one way and in scopes the other, and the walk is otherwise the same.

What a lookup costs

A lookup costs its walk: the number of scopes visited before the binding is found. A name bound in the current block is a step away; a name from an enclosing block is as many steps as the scopes between the use and the binding; a prelude name read from deep inside nested blocks is the length of the whole chain. extend adds a single scope at the front, and discarding a scope drops the innermost link, so growing and shrinking the environment are each a constant-time operation and the cost of the representation sits on lookup.

A different representation moves that cost. A single map from every name currently in scope to its value answers a lookup in one probe, with no walk at all. What it does not get for nothing is the coming and going of scopes: entering a block has to record every name the block shadows, and leaving the block has to put the shadowed values back, so the map carries a stack of shadowed bindings per name, or a snapshot restored on exit. The bookkeeping the chain spends on a lookup, the flat map spends at every scope boundary instead. Which representation costs less depends on whether a program reads variables more often than it opens scopes, or the reverse.

Resolving names before the run

Lexical scope settles which binding an occurrence refers to before the program runs, so the walk’s outcome is fixed ahead of time too: for a given occurrence of a name, the number of scopes to skip before its binding is found is the same on every run, because the nesting of scopes at that point is the nesting of the enclosing blocks, and the text fixes that. Because each scope holds one binding, that count is the whole answer. A name can then be replaced, before the run, by the number of scopes the walk would have crossed, and the reference read by stepping out that many scopes — with no name compared along the way.

That single number is what Nicolaas de Bruijn introduced in 1972: drop the names entirely and write each variable as the distance to its binder — the count of enclosing binders to cross — so a term carries no variable names at all (de Bruijn, 1972). Two terms that differ only in their choice of names become the same term, and shadowing is just a larger distance, so the renaming questions that surround names do not arise. A scope that holds several bindings — the block representation, where one scope carries a whole block’s names — needs a second coordinate on top of the distance: which scope to stop at, and which binding within it. That pair is called lexical addressing, and over a single-binding chain its second coordinate is always zero, leaving de Bruijn’s lone index.

x becomes the index 2 skip 2 scopes depth 0 depth 1 depth 2 depth 3 z = 3 y = 2 x = 1 prelude the index lands here

It is the same chain, with x’s spelling replaced by its distance. Because the target is fixed by the text, computing that distance is a pass over the program made before it runs, and once done the run-time lookup steps out a fixed number of scopes, with no name searched for.

Bridger keeps names. Resolving references to coordinates is a pass a tree-walker can do without, and the speed it buys is not what a tree-walker is for; against it, a name is what an error reports. UnboundVariable names the variable, and so do the type errors of Part VI, while a coordinate has no name to hand back. The chain stays keyed by name, and a lookup stays a walk.

The design space

Several choices sit behind the environment, and Bridger has taken a point in each.

Where a lookup’s cost falls. A chain of scopes puts it on the walk and keeps extension and exit cheap; a flat map spends it on save-and-restore at each scope boundary and keeps a lookup a single probe. A program that reads variables far more often than it opens scopes is served by the first; a program dominated by scope changes over shallow lookups by the second.

By name or by position. Keyed by name, a reference is searched for by spelling at run time and every name survives into the running program, where an error can report it. Resolved to a position — a de Bruijn index over the single-binding chain, or the ⟨scope, binding⟩ pair of lexical addressing once a scope holds several — a reference is found without a search, at the price of a resolution pass and of the names an error might have used. This choice is open only because scope is lexical: a language deciding bindings while running could not resolve them before it.

Owned or shared. A singly owned chain is enough while a scope dies with its block. Sharing — Bridger’s Rc — is forced once a value outlives its defining block and has to carry that block’s scope with it, which is what a closure does. Bridger shares from the start, so the chain a block builds can be captured without copying it (Part V).

The one scope not built by an extend is the root, which holds the prelude and the top-level functions. Because those functions may call one another regardless of the order they are written in, the root is assembled by the interpreter’s provided setup before any of them runs, rather than grown a binding at a time the way a block’s scopes are.

Further reading

Nameless variable representations: N. G. de Bruijn’s Lambda calculus notation with nameless dummies (Indagationes Mathematicae, 1972) represents each variable by the distance to its binder, so that terms differing only in their choice of names coincide and renaming is unnecessary — the representation an interpreter borrows when it resolves references to positions.

Lexical addressing: Abelson and Sussman’s Structure and Interpretation of Computer Programs (MIT Press, free online) works through resolving a variable reference to a coordinate — which enclosing scope, and which binding within it — in its treatment of compilation, so that a name is found by position rather than searched for by spelling.

Concept checks

In { let x = 1; { let y = 2; { let z = 3; x + z } } }, how many scopes does the lookup of z walk on the last line, and how many does the lookup of x walk? Why are extend and leaving a block cheap however deep the chain is?

The chain at x + z is {z:3} → {y:2} → {x:1} → root. Looking up z finds it in the innermost scope — one scope visited. Looking up x misses in z’s scope, misses in y’s, and hits in the third — three scopes visited. extend adds one scope at the front and leaving a block drops the innermost link, so each is one operation regardless of how many scopes are already in the chain: the representation puts its whole cost on the lookup’s walk.

The parent link is an Rc (shared ownership) rather than a plain owned pointer. What later feature makes single ownership insufficient, and what would go wrong under it?

A function value (Part V) captures the scope it was defined in so it can read its free names when it is later called, which means that scope must stay alive after the block that built it has returned. Under single ownership the block owns the chain and it is freed when the block ends, so the captured scope would be left dangling — Rust would reject the program rather than allow it. Shared ownership lets the closure and the chain both hold the scope, and it lives as long as either does. Bridger shares from the start so capture takes no copy of the chain.

In { let a = 1; { let b = 2; a } }, give the de Bruijn index — the number of scopes to skip — for the occurrence of a. Why can it be computed without running the program, and why does Bridger keep names anyway?

At a the chain is {b:2} → {a:1} → root, so the index is 1: skip one scope (past b’s) and take the binding there. A ⟨scope, binding⟩ lexical address would be ⟨1, 0⟩ — the second coordinate stays zero while each scope holds one binding. It is computable ahead of the run because lexical scope fixes the binding from the text — the nesting of scopes at the occurrence is the nesting of the enclosing blocks, the same on every run, so the count to skip is a constant. Bridger keeps names because resolving to positions is an extra pass buying speed a tree-walker does not need, and because its errors report names: UnboundVariable and the type errors of Part VI name the variable, and an index has none to give.

A single flat map from names to values answers a lookup in one probe, with no walk. What does it have to do that the chain does not, and when does the trade favor each?

The map has to undo scope changes at every block boundary: entering a block records what each new binding shadows, and leaving it puts them back — a stack of shadowed bindings per name, or a snapshot restored on exit. The chain skips that (leaving a block just drops the innermost link) and pays a walk on every lookup instead. The map is favored when lookups are frequent relative to scope entry and exit and a one-probe lookup is worth the boundary bookkeeping; the chain is favored when extension and exit dominate and the walk runs over a shallow chain.

Leaving a block returns the environment to what it was before the block, and the chain does this with no work. Which property from the previous chapter makes that possible?

That extend returns a new child scope and leaves its parent untouched — the environment is read-only. The scope a block runs in is a fresh link on top of the caller’s chain, and the caller’s chain is never modified, so discarding the block’s innermost scope exposes the unchanged parent, which is exactly the environment before the block. No binding was overwritten, so none has to be put back: restoring is just dropping the child.

Milestone M2

Environments and Binding

Part III gave the evaluator names. The environment supplies each name’s value, let binds one over the rest of a block, lexical scope fixes how far a binding reaches, and the representation makes a lookup through nested scopes work. This milestone puts them into the evaluator: two arms of eval_expr that read a variable and evaluate a block, and the run of the language grows from single expressions to programs that name their intermediate values.

The codebase, the toolchain, and the build loop are the ones Milestone M1 set up; this milestone adds two arms to the same eval_expr. If you have not built the starter yet, do that first — M1 is required to be completed and correct before this milestone can be completed.

To-dos

grep -rn 'todo_m2!' src lists exactly what this milestone asks for — the two arms left as holes after M1:

// src/interp/eval.rs — the two M2 arms of eval_expr
Expr::Var(..)   => todo_m2!("E-Var"),
Expr::Block(..) => todo_m2!("E-Block / E-Let / E-Seq"),

These are the first arms that consult the env parameter. Through M1 it was carried unused, kept quiet by a let _ = env; at the top of eval_expr; delete that line once an arm reads from env, or the lint will point out that it is now redundant.

Milestone specification

Below each row gives the form, semantic rule, and the value (or error) each new construct this milestone adds:

FormRuleEvaluates to
xE-Varthe value the environment binds to x; stuck if none
let x = e; (block item)E-Letbinds x over the rest of the block
e; (block item)E-Seqruns e and discards its value
{ … e } (trailing)E-Blockthe value of the trailing expression
{ } (empty, or ends in ;)E-Empty()

The full rules are in the Environments chapter; this table is the checklist your evaluator is measured against, not a second statement of them.

Two contracts hold across the block. The environment is read-only: a let never rewrites an existing binding — it adds one that shadows the old, so the nearest binding of a name wins — and extend produces a child scope while leaving its parent untouched. That is why a binding reaches exactly the rest of its block and no further: the scope a block grows is discarded when the block ends, and the caller’s environment is the one it started with. And a block aborts at a stuck item: if an item gets stuck, the ? carries the error straight out with that item’s span, and the items after it never run.

Implementing Milestone M2

Destructure each arm’s .. into the fields you need. The two M2 variants and what they carry:

VariantFieldsCovers
Expr::Var(Name, Span)a variable reference
Expr::Block(Vec<Stmt>, Option<Box<Expr>>, Span)items, then an optional tail

A block’s items are Stmt values, of two kinds:

enum Stmt {
    Let(Name, Option<Ty>, Expr, Span),   // a binding; the Option<Ty>
    Expr(Expr),                          // is an annotation, ignored here
}

The Option<Ty> on a Let is an optional type annotation; the evaluator ignores it — it is read by the type checker we implement in Part VI.

Two tools do the work of both arms:

  • Look a name up with env.lookup(name), which returns Some(v) when the name is in scope and None when it is not. A miss is the stuck case: return RuntimeError::UnboundVariable { name, span }, carrying the offending variable’s own span.
  • Extend a scope with env.extend(name, value), which returns a new environment with one more binding and leaves the one it was called on unchanged.

A block threads that second tool through its items. Start from env, and walk the items in order: a Stmt::Let evaluates its right-hand side in the current scope and then extends the scope with the new binding for the items that follow; a Stmt::Expr evaluates its expression in the current scope and drops the value. The trailing expression, if there is one, is evaluated in the scope the items built and is the block’s value; a block with no trailing expression is Value::Unit. Because extend returns a child rather than mutating in place, the growing scope is local to the block — nothing you do to it reaches the environment the block was entered with.

E-Var you have already seen worked in the Environments chapter’s build section; the block arm is the same environment threading that chapter describes, carried out one item at a time.

Worked examples

An expression, the environment it needs, and the value it evaluates to:

x            (x bound to 5)          ⇓ 5
{ let x = 2; x + 3 }                 ⇓ 5
{ let x = 1; let x = x + 1; x }      ⇓ 2      (shadowing)
{ 1; 2; 3 }                          ⇓ 3      (the trailing expression)
{ let x = 5; }                       ⇓ ()     (ends in a binding)
{ }                                  ⇓ ()

And expressions with no value, which halt the run:

y            (y unbound)             ⇓ stuck  (UnboundVariable)
{ { let x = 1; x }; x }              ⇓ stuck  (x's scope ended at the inner })
{ z; 1 }                             ⇓ stuck  (z unbound; the block aborts at z)

How to know it works

cargo m2 runs the provided milestone M2 tests, and — because the milestones are cumulative — the M1 tests along with them, so a change that breaks the expression fragment shows up here too. Each test builds an Expr and checks the Value it evaluates to, or, for a stuck test, the error’s variant and the span it blames. The builders var, block, let_, and expr_stmt join the M1 ones, and env_with and eval_in evaluate an expression under a starting environment, so a check reads close to the rule it exercises:

// { let x = 2; x + 3 }  ⇓  5
let b = block(vec![let_("x", int(2, 1), 0)],
              Some(binary(Add, var("x", 2), int(3, 3), 4)), 5);
assert_eq!(value_of(&b), Value::Int(5));

// x, with x bound to 5, reads its binding
let env = env_with(&[("x", Value::Int(5))]);
assert_eq!(eval_in(&var("x", 0), &env).unwrap(), Value::Int(5));

// y is unbound: UnboundVariable at its span
assert!(matches!(stuck(&var("y", 3)),
    RuntimeError::UnboundVariable { .. }));

Fill an arm, run cargo m2, and repeat until every M2 test — and every M1 test — passes.

Design problems

The evaluator takes an environment in but never hands one back, while Part IV's store will be threaded both in and out. Given the Rc-chained representation, why does discarding a block's bindings when it ends cost nothing?

Evaluating an expression cannot change what a name is bound to, so the environment is an input the evaluator reads and never an output it revises — extend builds a child scope for the code under a binding and returns it downward, and nothing propagates a new environment back up. In the representation, a scope is a small node that holds its own bindings and an Rc pointer to its parent, and extend allocates one such node in front of the current chain. When a block ends, evaluation simply stops using the child node and goes on with the parent chain it already held; there is no unwinding to do and nothing to copy back, because the parent was never altered. Discarding the block’s bindings is just dropping a reference to the front of the chain. The store is threaded out precisely because it is changed by evaluation, and that is the distinction Part IV turns on.

A block let x = e_1; rest computes the same value as applying λx. rest to e_1. Since the two agree, why does the milestone still implement a block arm rather than desugaring let to a function call?

The equivalence, which is Landin’s, holds because both evaluate e_1, bind the result to x, and evaluate the body in the extended environment — the block arm’s extend is exactly the environment step a function call will make when Part V arrives. Bridger keeps let a form of its own because binding is available from the first block a program writes, before functions exist, so the evaluator needs a rule for it that does not depend on a feature two parts away; desugaring let to a call would make the whole binding mechanism wait on closures. It costs one arm now and buys blocks that stand on their own. The equivalence pays off: when the closure rule lands, it extends the environment as this arm does, so there is one binding mechanism, reached by two forms.

Using only the variables and blocks of this milestone — no mutation — can you write a program whose result depends on the order in which two bindings are evaluated? If not, what would you need?

You cannot. A let’s right-hand side is evaluated in the environment before the binding is added, and the environment is read-only, so evaluating one binding cannot change what a later binding sees except by putting a value in scope under a name — and a name, once bound, denotes that one value wherever it is read. Two bindings that do not mention each other are independent, and one that mentions the other reads a fixed value, not a value the second could have altered. Order becomes observable only once a step can change something a later step reads — a cell whose contents one binding writes and another reads — which is the mutation of Part IV. Until then the language is order-insensitive in this sense, which is exactly what a read-only environment buys and what the threaded store gives up.

Bridger allows a block to bind a name that is already in scope, shadowing the earlier binding; some languages reject a second binding of the same name in one scope. What does each choice make easy, and what does each rule out?

Allowing shadowing lets a block reuse one name across a series of related values — let x = read(); let x = trim(x); let x = parse(x) — without inventing x1, x2, x3, and each let may define the name in terms of the value it held before, since the right-hand side is evaluated before the new binding is added. What it gives up is a check: a name accidentally reused, where a fresh one was meant, is silently accepted rather than flagged. A language that rejects the second binding turns that accident into a compile error, at the cost of the deliberate reuse, which must then be spelled with a new name or an explicit mutation. The trade is a convenience against a class of caught mistake; Bridger takes the convenience, and its scope rules make the shadow’s reach exactly the rest of the block, so an inner shadow is gone once the block ends.

Expressions, Statements, and Blocks

Part II focused on how to evaluate a literal expression to a value. Part III extended the language we consider to programs that bound values to names and used those names within expressions. A program built only from those parts returns a single value. This part introduces constructs that do something when they are evaluated: e.g., r := r + 1 changes the value a memory cell holds, a while loop repeats the actions of its body, an if takes one of two branches. Each raises a question the earlier forms never did — what is the value of a construct that does something?

Two answers are in wide use. A language can treat such a construct as a statement: a form executed for its effect, in a category apart from expressions, carrying no value and unable to be used where a value is required. Or it can treat it as an expression like any other, evaluated for a value even when the reason to write it is the effect. The choice runs through the whole language: its grammar, its evaluator, and later its type checker. In this chapter, we detail the choice we make in Bridger.

Effects

An effect is a change a construct makes when it runs that a later step can observe, over and above whatever value the construct computes. Assigning to a memory cell is one: r := e overwrites what the cell holds, and code that reads the cell afterward finds the new value where the old one had been. Printing a line to a screen is an effect; so is reading input from a keyboard or a file, writing a file, and sending or receiving bytes over a network connection. What these share is that running the construct leaves something changed — a cell, the screen, a file, a machine across the network — in a way that persists past the evaluation and sets the run apart from one in which the construct never ran.

Bridger’s built-in effect is mutation. A program changes state by writing a value to a memory cell with r := e, and that cell’s contents are what a later read observes; the cell and the assignment that writes it are the subject of the next chapter. Bridger also includes built-in functions for printing and reading primitive values; however, Bridger does not support reading or writing to files and network connections. This part focuses on creating, modifying, and reading mutable memory cells and the control flow required to compute using these primitives. This chapter examines the question of how to combine constructs for expressing value expressions and effectful computations.

Where languages draw the line

Fortran and Algol 60 drew a firm boundary between the two. An expression — a + b, f(x) — appeared inside a statement, and a statement — an assignment, a loop, a jump — was a different kind of thing: it produced no value and could not appear where a value was expected. A program was a sequence of statements, and expressions were the material they were built from. The conditional came twice over, once in each category: a conditional expression that chose between two values, and an if statement that chose between two courses of action.

Algol 68 lowered the boundary. Its report built a program out of units, each with a value and a type, and gave the value of a construct written for its effect a type of its own, void; an assignment and a loop were units like any other, and a place where a statement had stood became a place holding a void-valued expression (van Wijngaarden et al., 1976). Lisp had been built from expressions since 1960; Algol 68’s contribution was to carry that arrangement into the imperative, block-structured line Fortran and Algol 60 had established, and to name the value a construct leaves behind when its point was the effect. That value, and the decision of where to draw the line between value-expressions and statements, are the focus of the rest of this chapter.

The value of an effectful form

A construct run for its effect still has to fit into a language built from expressions: if it may stand where a value is expected, it has to produce one. The value that carries no information is the one to hand back, since there is nothing to report except that the work was done. Bridger’s unit type, whose single value is written (), plays that part. Assignment r := e yields (); a while loop yields (); an if with no else yields (). Unit arrived in Part II described as the value of a statement; a statement is the kind of form that produces it.

Unit is what lets the expression grammar absorb the effectful forms without a special case. A while loop has a value, so it may appear as a block item, as the right-hand side of a binding, or anywhere else an expression may go; its value happens to be the one that says only “done.” One category of form covers both the computations that report a result and the ones that report their completion.

Where Bridger draws the line

Almost every construct in Bridger is an expression with a value. if, match, while, for, and a block { … } all evaluate to something, so any of them may be used where a value is expected:

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

binds n to whichever branch is taken. The forms whose purpose is an effect are expressions too, and their value is (). So the statement category, which in Algol 60 held assignments and loops and conditionals, is nearly empty in Bridger — those forms have moved into the expressions.

What remains of a statement lives inside a block. Blocks arrived in Part III as the vehicle for binding; read now as the construct that sequences a program’s steps, a block is a sequence of items followed by an optional trailing expression. Each item is either a let or ref binding, or an expression closed with ;, which runs the expression and discards its value. The trailing expression, written with no ;, is the block’s value; a block with no trailing expression has the value ().

block       ::= '{' { block_item } [ expr ] '}' ;
block_item  ::= let_binding | ref_binding
              | ( if_expr | match_expr | while_expr | for_expr | block ) [ ';' ]
              | expr_stmt ;
expr_stmt   ::= expr ';' ;

The ; is the whole of what makes an expression a statement here: f() in a block is an expression whose value becomes the block’s value if it is last, and f(); is that same expression run for its effect with its value thrown away. The rules that evaluate a block — an item threaded for effect, the trailing expression as the value, an empty block as () — are the ones stated in Part III; they need no addition for this part.

The two forms that stay out of the expression grammar are the let and ref bindings. A binding is an item of a block and never an expression, so

let x = let y = 0;   // does not parse

is rejected: the right-hand side of a let must be an expression, and a let is not one. The line sits there because a binding’s work is to extend the environment over what follows it, and only a block supplies a “what follows.” A bare let x = 5 with no surrounding block would bind x over nothing and have no value to give; keeping it an item of a block is what makes its scope well defined. return stays outside the operator grammar as well — it is an expression, but one that abandons the enclosing function rather than yielding a value in place, and it travels by the same Control path the interpreter already uses for errors.

Building it

The provided evaluator has a single entry, eval_expr, returning a Value; there is no eval_stmt beside it. A block is one arm of that function, and a statement is a shape it walks rather than a thing evaluated to a value of its own:

// A block is statements then an optional trailing expression,
// which is the block's value; a statement is a binding or an
// expression run for its effect.
Expr::Block(Vec<Stmt>, Option<Box<Expr>>, Span)

enum Stmt {
    Let(Name, Option<Ty>, Expr, Span),   // a binding
    Expr(Expr),                          // run for effect
}

A Stmt::Expr(e) evaluates e and drops the value; a Stmt::Let evaluates the right-hand side and extends the environment over the rest of the block, exactly as let does. Because assignment, loops, and conditionals are ordinary expressions, they are evaluated by the same arms that handle 2 + 2, and the evaluator needs no second traversal for a category of forms that produce no value. Drawing the line low collapses two kinds of evaluation into one.

The same economy reaches the type checker in Part VI: one judgment assigns a type to every form, with the effectful ones typed (), where a language with a statement category needs a second judgment for the forms that have no type to assign. Collecting the rules this part contributes is the work of Milestone M3.

The design space

C and Java keep the Algol 60 boundary. An if is a statement with no value, so choosing between two values calls for a separate expression-level form, the conditional operator c ? a : b; the language carries two conditionals because the statement one cannot be used where a value is wanted. Assignment goes the other way: in C a = b is an expression with a value, which is what lets if (x = 0) compile as a test — an assignment mistaken for a comparison. The split forces some forms to be duplicated across the two categories and lets others slip between them.

ML, Ruby, and Rust draw the line low, as Bridger does. In ML an if is an expression and a sequence yields its last value; Ruby lets nearly every construct return a value. Rust is the model Bridger follows most closely: it is expression-oriented, yet a let is a statement, a block’s trailing expression with no ; is the block’s value, and a ; discards. Bridger’s blocks work the same way, down to let being a block item rather than an expression. The Algol 68 arrangement of the previous section is the older member of this family; its void is the role Bridger’s () fills.

What the low line costs is a reader’s expectation that an assignment or a loop is “just a statement”: here each has a value, and a stray while where an Int was wanted is a type error against () rather than a syntax error. What it buys is a single expression grammar, a single evaluator, and a single typing judgment, with the unit type carrying the forms whose only result is that they ran. The bindings are the one place the line is drawn above the expressions, and they are drawn there because a binding means nothing except over a region that follows it.

Further reading

Expression-oriented languages: the Revised Report on the Algorithmic Language ALGOL 68 (A. van Wijngaarden and colleagues, 1976) builds a program from units carrying a value and a type and introduces the void type for a construct evaluated for its effect, the arrangement Bridger’s unit type follows. The Rust Reference describes the expression-oriented model Bridger adopts most directly — expressions and expression statements, the trailing expression of a block as its value, and let as a statement — in its chapter on expressions. The reading of a local binding as a function applied to its right-hand side, from Landin’s ISWIM, is in Part III.

Concept checks

Assignment r := e evaluates to (). In C, a = b evaluates to the assigned value instead. What does each choice make possible, and why does Bridger's fit its expression orientation?

C’s choice lets an assignment stand anywhere a value is wanted, including a condition, which is why if (x = 0) compiles — an assignment where a comparison was meant, with no complaint from the grammar. Bridger’s := produces () because assignment is written for its effect and unit is the value that reports only that the effect happened; the writer already knows what was assigned, so the assigned value carries no information back. Producing a value keeps := an ordinary expression, so it fits an expression-oriented language, while producing unit rather than the assigned value keeps r := 0 from silently passing where a Bool or an Int was expected — that use is a type error against ().

The block let x = let y = 0; does not parse, even though a let's right-hand side is an expression. Why is a let binding a block item rather than an expression?

A binding’s work is to extend the environment over the part of the block that follows it, so it only has meaning as an item of a block, which is what supplies a “rest of the block” for the name to be in scope over. Written on its own, let y = 0 would bind y over nothing and would have no value to hand back as the right-hand side of the outer let. Keeping bindings out of the expression grammar is what makes a name’s scope exactly the remainder of its block; the right-hand side of a let is an expression, and a let itself is not one.

C has both an if statement and a conditional operator c ? a : b; Bridger has only if. What accounts for the difference?

C’s if is a statement, so it produces no value and cannot be used to choose between two values — let n = if c { a } else { b } has no counterpart written with the if statement. The conditional operator exists to fill that gap: it is an expression-level conditional for exactly the case the statement one cannot serve. Bridger’s if is already an expression, so a single form does both jobs — it chooses a course of action when its branches produce (), and it chooses a value when they produce anything else — and no second conditional is needed.

How does drawing the statement/expression line low affect the evaluator and the type checker, compared with keeping a separate statement category?

With almost every form an expression, the evaluator is one function that returns a value: assignment, loops, and conditionals are handled by the same machinery as arithmetic, and the effectful ones return (). A language with a statement category needs a second path — a way to execute a statement that yields no value — alongside the one that evaluates an expression, and a form that exists in both categories must be handled in both. The same holds for the type checker: one judgment assigns a type to every form, with the effectful forms typed (), where a two-category language needs a separate treatment for the forms that have no type. The unit type is what makes the single path enough, giving the effectful forms a value to carry.

Mutation and References

A let binds a name to a value, and the binding does not change: within its scope the name denotes that one value, and Part III built the environment so that looking the name up always finds it. A program that only binds and reads can compute a result, but it has no way to express a quantity that changes over time while the name that refers to it stays put — a counter that climbs, a running total a loop adds into, a balance that a deposit raises. Each of those is one thing, referred to by one name, holding a different value at a later moment than it held before.

The environment cannot supply that on its own. It maps a name to a value once, when the name is bound, and a later read of the name returns the same value; rebinding the name with a second let creates a new, separately scoped binding rather than altering the first. What changes over time has to live somewhere the environment points at rather than somewhere it is — a cell whose contents a program can overwrite, with the name holding the cell rather than the contents. This chapter introduces that cell. It is the mutation the previous chapter named as Bridger’s one built-in effect; here are the forms that create a cell, read it, and write it, and the addition to the evaluation judgment that lets a written value be read back later.

Origins of mutable cells

Two traditions answer the question of what a name denotes, and they place mutation differently.

In Fortran, Algol 60, and their descendants C and Java, a variable is a mutable cell, and the name and the cell are the same thing. Writing x = x + 1 reads the cell the name x denotes, adds one, and stores the result back into that same cell; the name does not acquire a new binding, the cell it has always named has the value it contains overwritten. Assignment to a variable is the primitive action, and every variable is a memory cell a program can read or write.

The functional tradition places it elsewhere. In Lisp and in Landin’s ISWIM, the reading of let the book took in Part III treats a binding as a fixed association between a name and a value; a name, once bound, denotes its value for the extent of its scope and is never modified. A language built only from that has no mutation at all. Standard ML reconciled the two by keeping the binding immutable and adding a separate kind of value — a reference cell, a box allocated on demand, whose contents a program may read and overwrite while the binding that holds the box stays fixed. Mutation moves out of the binding and into a value the binding refers to. Bridger follows that split: a let binds once and for all, and a program that needs a value to change asks for a cell and mutates the cell.

The store

A cell needs somewhere to live. The environment already maps names to values and does not change an existing entry, so the cells go in a second map, the store, written : a finite map from locations to values. A location, written , is the identity of one allocated cell — a value in its own right, the thing a name holds when it refers to a cell. The environment maps a name to a value; the store maps a location to a value; and a name bound to a cell is a name the environment maps to a location that the store, in turn, maps to the cell’s current contents.

The store changes as a program runs, so evaluation has to carry it. The judgment grew in Part III from to when names arrived; it grows again here to thread the store through the evaluation, taking one store in and handing one store out:

read as: in environment , evaluating against the store yields the value and leaves the store . The environment goes in but does not come back — evaluating an expression cannot change what a name is bound to — while the store goes in and comes out, because evaluating an expression can change what a cell holds. (The reference’s full judgment threads one more component, the relation database of Part VII, and carries the extra outcome a return produces; this part uses the env-and-store slice.)

Every rule now threads the store, and the threading fixes the order in which subexpressions run. A form with two operands evaluates the first against the incoming store, then the second against the store the first handed out, then combines the results — left to right, each step’s output store the next step’s input:

Addition takes the store in and passes it along unchanged in the sense that it writes nothing of its own, but it still threads it: if evaluating allocates a cell, evaluates against the store that already holds it. Part II left the order of +’s operands open, observing that it could be fixed once effects existed; the threaded store is what fixes it. Most forms are like +: they thread the store to pin down order and hand it back untouched. Three forms change it.

Creating, reading, and writing a cell

Bridger writes the three operations as ref e, deref e, and e1 := e2.

ref e allocates a fresh cell holding the value of e, and the cell — its location — is the value the form returns. The rule evaluates e, picks a location not already in use, and extends the store so that the new location maps to the value:

The value handed back is , the location, not ; a program keeps the cell by binding the location to a name, let counter = ref 0.

deref e reads a cell. It evaluates e to a location and returns what the store maps that location to, leaving the store as it found it:

e1 := e2 overwrites a cell. It evaluates e1 to the location to write, then e2 to the value to store there, then updates the store at that location and returns () — assignment is an effect, and its value is the unit that reports only that the effect happened, exactly as the previous chapter described for the effectful forms:

The order is the cell first, then the value written into it. It is visible when both sides have effects of their own: in (f()) := (g()), f runs before g, because e1 is evaluated against the incoming store and e2 against the store e1 produced. A cell together with these three forms is enough to count:

let counter = ref 0;
counter := deref counter + 1;
deref counter                  // 1

ref 0 allocates a cell holding 0 and binds counter to its location. The assignment reads the cell (deref counter is 0), adds one, and writes 1 back into the same cell; the location counter holds has not changed, its contents have. The trailing deref counter reads the cell again and finds 1. This is the shape a while loop repeats to accumulate a result across its iterations, which is why the cell arrives before the loop that needs it.

A location is a value like any other, so it can be read from a cell, passed to a function, or returned — and two names can end up holding the same location:

let a = ref 0;
let b = a;                     // b holds the same location as a
a := 1;
deref b                        // 1

let b = a binds b to the value a holds, which is a location, so a and b name one cell. Writing through a changes the cell both refer to, and reading through b finds the new value. This is aliasing: two names for one mutable cell, so that a write reached by one name is observed through the other. It follows directly from a name holding the location rather than the contents, and it is what makes shared mutable state — and structures that point at each other — expressible. It is also what makes a program with mutation harder to reason about than one without, since a write here can be a change seen there; Part V returns to the style that keeps mutation scarce for that reason.

Building it

The store is a piece of interpreter state beside the environment. The provided runtime supplies it as a Store with three operations — allocate a cell and get back its location, read a location, and write a location — and a location is a small handle that a Value can carry:

Value::Ref(Loc)                // a value that is a location in the store

impl Store {
    fn alloc(&mut self, v: Value) -> Loc;   // E-Ref: fresh cell, gives its location
    fn read(&self, l: &Loc) -> Value;       // E-Deref: the cell's contents
    fn write(&mut self, l: &Loc, v: Value); // E-Assign: overwrite the cell
}

The three forms are three arms of eval_expr. ref e evaluates e and calls alloc, returning Value::Ref of the location. deref e evaluates e to a Value::Ref(l) and returns store.read(&l). e1 := e2 evaluates e1 to a Value::Ref(l), then e2 to a value, calls store.write(&l, v), and returns Value::Unit; a deref or := whose operand is not a location is a runtime type error, the store’s stuck case. The rules thread a store in and a store out; the interpreter instead keeps the one store on the interpreter and mutates it in place, so alloc and write are the step from to — the threading made physical. A cell, once allocated, is never reclaimed: the store only grows, matching E-Ref, which always takes a fresh location and never reuses one. When the store is read back as a space cost, that choice returns as a subject in Part X.

The surface form ref x = e is not a new binding. It reads as let x = ref e: allocate a cell for e and bind x to its location. It earns its own keyword because binding a name to a fresh cell is the common case and ref x = 0 says it more plainly than let x = ref 0, but the parser expands it to the let and the evaluator sees only the two forms it already has.

The type a reference carries, written ref<τ> for a cell holding values of type τ, and the rules that keep deref and := from being applied to a non-location before the program runs, are the subject of the type checker in Part VI. The evaluator here admits any location where one is required and reaches its stuck case at runtime when handed something else.

The design space

The three languages that keep the Algol boundary of the previous chapter also keep the variable-as-cell model. In C, Java, and Python a plain variable is mutable: x = e reassigns the name, and there is no separate word to allocate a cell or to read one, because every variable already is one. What Bridger writes as ref/deref/:= is, in those languages, the unmarked default state of every name. Aliasing is present there too, carried by pointers or object references, and it is likewise implicit — two variables may refer to one object with nothing in the syntax to say so.

ML draws the line where Bridger does: a binding is immutable, a reference cell is a separate value created by ref and reached by an explicit read and write, and a mutation site is visible in the program as one of those operations. The cost is the two extra words, ref and deref, on every use of a cell; what they buy is that a name not bound to a ref cannot be assigned into at all, so the places a program mutates are exactly the places those words appear.

Rust marks mutation on the binding but restricts the sharing. A let is immutable and let mut makes a rebindable variable, closer to the variable-as-cell model; sharing goes through borrows, & and &mut, and the compiler’s rules forbid a mutable borrow to coexist with any other borrow of the same value, so the aliasing that Bridger and ML permit freely is the case Rust’s borrow checker exists to rule out. Bridger takes ML’s first-class cell rather than Rust’s borrowing because a cell that is an ordinary value needs no second discipline layered over the language — one immutable let binds cells and non-cells alike, and a location is passed, stored, and compared like any other value. What that leaves on the table is the compiler-checked guarantee against data races and aliased writes that Rust’s rules provide; Bridger permits the aliasing and asks the programmer to manage it.

Across the three, the decision is where a program says “this can change”: on every variable by default, on a binding marked mut, or on a value obtained through ref. Bridger’s answer keeps the binding immutable and names the cell, so that the environment of Part III is left exactly as it was and everything that changes over time lives in the store.

Further reading

Reference cells and the store: The Definition of Standard ML (Robin Milner, Mads Tofte, Robert Harper, and David MacQueen, revised edition, 1997) gives the reference cell — ref, !, and := — its formal semantics over a store, the model Bridger’s cell follows. Benjamin Pierce’s Types and Programming Languages (2002) devotes its chapter “References” to building mutable cells on top of a pure calculus: the store as a map from locations to values, the evaluation relation threaded through it, and the typing of Ref T, in the same order this part and Part VI take them. The variable-as-cell model, for contrast, is described in the “References” and ownership chapters of The Rust Programming Language, where mutability is a property of a binding and a borrow rather than a value, and the borrow checker rules out the aliased writes the store model here permits.

Concept checks

After let a = ref 0; let b = a; a := 1;, what does deref b evaluate to, and why?

1. ref 0 allocates one cell and returns its location; let a binds a to that location, and let b = a binds b to the value a holds — the same location — so a and b name one cell. a := 1 writes 1 into that cell. deref b reads the cell b holds, which is the cell a wrote, so it finds 1. The two names are aliases because each holds the location, not a copy of the contents; if let b = a had somehow copied the cell rather than the location, deref b would still be 0.

e1 := e2 evaluates the cell e1 before the value e2. When is that order observable, and what value does the assignment return?

The order matters whenever evaluating e1 and e2 both have effects, since one runs against the store the other leaves behind. In (f()) := (g()), f is called before g: e1 evaluates against the incoming store and e2 against the store e1 produced, so an allocation or a write inside f is already in place when g runs. If f and g are pure the order cannot be told apart. The assignment itself returns (): it is written for its effect on the cell, and unit is the value that reports only that the effect happened.

Why does the evaluation judgment take a store in and hand a store out, while the environment only goes in?

Evaluating an expression can change what a cell holds — that is the whole point of := — so the store that a later step sees has to be the one earlier steps produced, which means each evaluation must both consume and produce a store. Threading it this way is also what fixes left-to-right order: a two-operand form evaluates its first operand against the incoming store and its second against the store the first handed out. The environment, by contrast, is not changed by evaluating an expression: a let extends the environment for the code in its scope, but no expression reassigns an existing binding, so the environment is an input the evaluation reads and never an output it revises.

C makes every variable a mutable cell; Bridger keeps bindings immutable and creates cells with ref. What does each choice make easy, and what does each give up?

C’s default costs nothing to write — x = e mutates with no extra word, because a variable already is a cell — and the price is that every name is a possible mutation site and aliasing through pointers is implicit, so a reader cannot tell from a name whether it changes or is shared. Bridger’s default is the reverse: a name bound by let cannot be assigned into, and mutation shows up in the program as ref, deref, or :=, so the places state changes are exactly the places those forms appear; the price is the extra words on every use of a cell. One makes mutation frictionless and its extent invisible; the other makes mutation deliberate and its extent visible. Neither removes aliasing — both allow two names for one cell — they differ in whether the syntax marks where it can happen.

The surface form ref x = e is not a distinct binding form. What does it stand for, and why does keeping it sugar simplify the evaluator?

ref x = e reads as let x = ref e: allocate a fresh cell holding e and bind x to its location. Because it expands to a let of a ref expression, the parser can rewrite it away and the evaluator never sees a third binding form — it has the let binding of Part III and the ref expression of this chapter, and their composition is all ref x = e means. Giving the common case its own spelling costs nothing at evaluation time, since the two forms it abbreviates already carry the semantics.

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.

Milestone M3

Control Flow and Mutation

Part IV let the language do things. A cell in the store holds a value that changes, if chooses a course of action, while and for repeat one, and return carries a value out past the code around it. This milestone puts them into the evaluator, and the run of the language grows from computing a value to changing state and controlling the order in which it changes.

The codebase, the toolchain, and the build loop are the ones Milestone M1 set up. What is new here on the provided side is the store: a Store that lives on the interpreter, with three operations — allocate a cell, read one, write one — and a Value::Ref that carries a cell’s location. It is the state the mutation forms act on, described in full in the Mutation chapter’s build section.

To-dos

grep -rn 'todo_m3!' src lists five arms left as holes after M2:

// src/interp/eval.rs — the five M3 arms of eval_expr
Expr::If(..)     => todo_m3!("E-If"),
Expr::While(..)  => todo_m3!("E-While"),
Expr::For(..)    => todo_m3!("E-For"),
Expr::Assign(..) => todo_m3!("E-Assign"),
Expr::Return(..) => todo_m3!("E-Return"),

Two more cases are not on that list. ref and deref are UnOp variants, so they are two new arms of the match inside Expr::Unary — the arm you filled at M1 for -e and not e, whose stub was labelled “ref & deref join at M3”. The grep under-counts M3 by exactly those two: seven forms in all, five of them their own Expr variant and two of them cases of an existing one.

Milestone specification

Each form this milestone adds, the rule that gives it meaning, and the case that leaves it stuck:

FormRuleStuck when
if c { … } else { … }E-Ifc is not a Bool
ref eE-Ref—
deref eE-Derefe is not a reference
e1 := e2E-Assigne1 is not a reference
while c { … }E-Whilec is not a Bool
for x in e { … }E-Fore is not a list
return eE-Return—

The full rules are in Mutation and References and Conditionals and Loops; this table is the checklist your evaluator is measured against, not a second statement of them. What each form evaluates to — the branch’s value for if, a fresh reference for ref, () for the effectful forms — is the subject of the contracts below.

Three contracts hold across these arms. The effectful forms yield unit: :=, while, and for each evaluate to (), and an else-less if does too when its condition is false. A loop discards its body’s value each turn — a while body runs for its effect on the store, a for body for its effect once per element, and the loop’s own value is (). And a return leaves on a channel of its own: it is not an ordinary value the arms combine but a control transfer that travels straight out through the loops and blocks in its way, abandoning the iterations that would have followed.

Implementing Milestone M3

Destructure each arm’s .. into the fields you need:

VariantFieldsCovers
Expr::If(Box<Expr>, Box<Expr>, Option<Box<Expr>>, Span)cond, then, optional else
Expr::While(Box<Expr>, Box<Expr>, Span)condition, body
Expr::For(Name, Box<Expr>, Box<Expr>, Span)bound name, iterable, body
Expr::Assign(Box<Expr>, Box<Expr>, Span)the cell (left), the value (right)
Expr::Return(Box<Expr>, Span)the returned expression
Expr::Unary(UnOp, Box<Expr>, Span)now also UnOp::Ref, UnOp::Deref

The store is reached through the interpreter, self.store, and its three operations are the three mutation forms:

  • ref e evaluates e and calls self.store.alloc(v), which makes a fresh cell and returns its Loc; the value the form returns is Value::Ref(loc).
  • deref e evaluates e to a Value::Ref(loc) and returns self.store.read(loc).
  • e1 := e2 evaluates e1 to a Value::Ref(loc), then e2 to a value, calls self.store.write(loc, v), and returns Value::Unit.

A deref or := whose operand is not a Value::Ref is stuck — a RuntimeError::TypeError built with Ty::reference(..) for the expected type. The rules of the Mutation chapter thread a store in and a store out; the interpreter keeps the store on itself and mutates it in place, so alloc and write are the step from one store to the next.

The control forms match the value they branch on:

  • if evaluates its condition; on Value::Bool(true) it evaluates the then-branch, on Value::Bool(false) the else-branch — or Value::Unit when there is none. Only the branch taken is evaluated. A non-boolean condition is stuck.
  • while is a host loop: re-evaluate the condition each turn, run the body while it is Value::Bool(true) (the body’s value taken with ? and dropped), and stop with Value::Unit when it is false. A non-boolean condition is stuck.
  • for evaluates its iterable to a Value::List, then walks it, evaluating the body once per element in env.extend(x, item) — a fresh binding of x each turn, discarded after — and yields Value::Unit. An iterable that is not a list is stuck.
  • return e evaluates e and leaves through the return channel: Err(Control::Return(v)). This is the second variant of Control, beside the Control::Raise an error rides (error handling). Because a loop body runs with ?, a return in the body carries straight out of the host loop, which is what abandons the remaining iterations.

At this milestone a return has no enclosing function to catch it — that boundary arrives with functions in Part V. Reaching a return where none is allowed is ruled out before the program runs, so the evaluator’s job is only to raise the channel; nothing in this milestone catches it.

Worked examples

A program, and the value it evaluates to:

if true { 1 } else { 2 }             ⇓ 1
if false { 9 }                       ⇓ ()   (else-less, condition false)
deref (ref 5)                        ⇓ 5

let r = ref 0;
r := 7;
deref r                              ⇓ 7    (write, then read it back)

let a = ref 1;
let b = a;                           // b holds the same cell as a
b := 9;
deref a                              ⇓ 9    (aliasing)

let i = ref 0;
while deref i < 3 { i := deref i + 1 };
deref i                              ⇓ 3

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

Programs that leave through the return channel rather than with a value:

return 5                             → returns 5
while true { return 7 }              → returns 7   (the loop is abandoned)

And programs with no value, which halt the run:

if 3 { 1 } else { 2 }                ⇓ stuck  (condition is not a Bool)
while 1 { }                          ⇓ stuck  (condition is not a Bool)
for x in 5 { }                       ⇓ stuck  (iterable is not a list)
deref 5                              ⇓ stuck  (deref of a non-reference)
5 := 6                               ⇓ stuck  (assign to a non-reference)

How to know it works

cargo m3 runs the provided milestone M3 tests and, because the milestones are cumulative, the M2 and M1 tests with them. Each test builds an Expr and checks the Value it evaluates to, the error a stuck test blames (its variant and span), or — for a return — the value it carries out. The builders if_, if_else, while_, for_, assign, and return_ join the earlier ones, ref and deref are written as unary(UnOp::Ref, …) and unary(UnOp::Deref, …), and the assertion returned unwraps the return channel where value_of and stuck unwrap a value or an error:

// if true { 1 } else { 2 }  ⇓  1
let e = if_else(boolean(true, 1), int(1, 2), int(2, 3), 0);
assert_eq!(value_of(&e), Value::Int(1));

// deref (ref 5)  ⇓  5   (ref / deref are UnOp::Ref / UnOp::Deref)
let e = unary(UnOp::Deref, unary(UnOp::Ref, int(5, 2), 1), 0);
assert_eq!(value_of(&e), Value::Int(5));

// while true { return 7 }  returns 7 through the return channel
let e = while_(boolean(true, 1), return_(int(7, 3), 2), 0);
assert_eq!(returned(&e), Value::Int(7));

Fill an arm, run cargo m3, and repeat until every M3 test — and every M2 and M1 test — passes.

Design problems

Your eval_expr returns Result<Value, Control>, and a return travels as Err(Control::Return(v)) — the same Err side an error uses. Why carry a return as an Err at all, rather than as an ordinary Value, and how do the two channels stay distinct?

A return has to leave the code around it without that code folding it into a larger value — the 1 + in 1 + return 2 must not add anything, the rest of a block after return must not run, the turns of a loop still to come must not happen. An ordinary Value cannot do that: the arm that receives it would go on and use it. Travelling on the Err side is what gives it that reach — every ? in the evaluator passes an Err straight out untouched, so a return threads out through arithmetic, blocks, and loops alike with no arm having to check for it. It rides the same channel as an error for exactly that shared behaviour: both are non-local exits. They stay distinct as two variants of Control — Raise for an error, which the top level reports and halts on, and Return for a value, which a function boundary in Part V catches and turns back into the call’s result. At this milestone nothing catches Return, so a bare return surfaces at the top; the error-handling chapter is where the Control channel itself is built.

The E-While-True rule names the same while among its premises, and E-Assign threads a store in and a store out; yet your while is a plain host loop and your store is one field mutated in place. What property of the rules lets the implementation drop the recursion and the threading, and where would dropping them go wrong?

Both simplifications rest on the rules being deterministic and single-threaded: evaluation proceeds in one order, each step producing exactly one store that the next step consumes, with no branch of the derivation needing a store some other branch also holds. Because the store is never shared between two live continuations, one mutable field can stand in for the whole sequence — writing it in place is indistinguishable from a fresh store, since nothing still refers to the old one. And because E-While-True’s self-reference is a plain tail repetition against the body’s output store, a host loop that re-tests the condition reproduces the finite stack of the rule without the evaluator calling itself. It would go wrong anywhere the derivation forks over a shared store and both forks matter — if a form evaluated a subexpression, then backtracked and evaluated it again from the original store, the in-place store would already carry the first attempt’s writes. Bridger has no such form; the relations of Part VII search, but over their own database rather than the mutable store, which is why they keep a channel apart from it.

Using ref, for, and the list operators of Part II, write a Bridger program that reverses the list [1, 2, 3], and explain why the accumulator is a cell.
let acc = ref [];
for x in [1, 2, 3] {
  acc := x :: deref acc
};
deref acc                            // [3, 2, 1]

The for binds x to 1, then 2, then 3 in turn, and each turn prepends the current element onto the front of the list the cell holds: [] becomes [1], then [2, 1], then [3, 2, 1]. The result comes out reversed because prepending puts the last element seen at the front. The accumulator has to be a cell, not a plain binding, because it must carry a value across the turns of the loop — the binding x is remade each turn and gone by the next, and a let inside the body would be scoped to that one turn. Only a store cell, allocated before the loop and read and written inside it, survives from one element to the next; the loop supplies the repetition and the store supplies the memory.

Bridger has no break. Write a while that scans upward from 0 and stops as soon as it reaches 5, using a second cell as a flag, and say why the flag is needed rather than a bare condition.
let i = ref 0;
let running = ref true;
while deref running {
  if deref i == 5 {
    running := false
  } else {
    i := deref i + 1
  }
};
deref i                              // 5

The loop’s condition tests the flag cell running; the body either clears the flag, which stops the loop on the next test, or steps the counter. A flag is needed because the only way out of a Bridger loop short of running its condition false is return, which would leave the whole enclosing function, not just the loop — there is no break to leave the loop alone. Folding the stopping decision into a cell the condition reads turns “leave early” into “make the condition false early”, which is a step the loop’s own machinery already takes. The cost is the extra cell and the if inside the body; what it keeps is a loop covered entirely by the two while rules, with no second way for control to leave it.

First-Class Functions

A program from Part IV that adds up the squares of a list and a second program that adds up the cubes are the same loop with one line changed. Writing the loop once and handing it the line that differs requires two things. The loop has to be written somewhere that both programs can reach and given a name; and the line that differs — square this, cube this — has to become something that can be passed as a value, the same way a list is passed. Passing data between parts of a program is as old as stored-program computing; however, for years the code a program calls was treated as separate from the numbers it computed with—even if early machines stored data and code the same way. This chapter covers how programs started passing around code blocks (functions) as “first-class” values the same way as any other data a program computes with.

Subroutines

Herman Goldstine and John von Neumann’s Planning and Coding of Problems for an Electronic Computing Instrument (1948) wrote:

We call the coded sequence of a problem a routine, and one which is formed with the purpose of possible substitution into other routines, a subroutine.

and envisaged “an extensive collection of such subroutines” kept as a library on wire or tape. On the EDSAC the library was real: the relocation of Initial Orders 2, described in the history chapter, existed so that a library routine could be loaded wherever the store had room for it. What relocation did not solve is how a routine gets back. A routine reached by a jump has to finish by jumping to the order after the one that called it, and that order is in a different place for every caller.

The EDSAC’s answer, the Wheeler jump, used the fact that an order is a number. The addresses below come from a small program in Campbell-Kelly’s guide to the EDSAC, and they use the θ relocation of the history chapter: an address tagged θ counts from the start of the routine it appears in, and the loader adds that routine’s origin as the tape is read. The program’s master routine, loaded at 88, calls the library print routine P6, loaded at 56, with two orders:

 θ+7    A 7 θ      add the order at θ+7 — this order — into the accumulator
 θ+8    G 56 F     jump to the subroutine at 56

and the subroutine begins with two of its own:

 θ+0    A 3 F      add a constant kept in location 3: build the return jump
 θ+1    T 25 θ     store it at θ+25, the subroutine's exit: update the return jump

A 7 θ names its own position without the programmer knowing where that will be: loaded at 88, it arrives in the store as A 95 F, and reading location 95 reads the order itself. The subroutine’s address is written absolutely, G 56 F, because the programmer drew up a table placing each routine before the tape was punched, and location 3 lies inside the initial orders, which never move. Adding the fixed constant turns the copy of the calling order into a jump back to 97, the master routine’s θ+9, and the subroutine writes that jump into its own exit order. When the subroutine finishes it runs the updated return jump and lands after the call. The return address exists only while the call is in progress, as an order the routine wrote into itself — the self-modifying technique the history chapter showed loops using for arrays, applied to control.

David Wheeler’s two-page “The use of sub-routines in programmes” (1952) describes a sub-routine as “a self-contained part of a programme, which is capable of being used in different programmes,” and locates the hard part somewhere other than the coding: once a routine is written and tested, “there still remains the considerable task of writing a description so that people not acquainted with the interior coding can nevertheless use it easily. This last task may be the most difficult.” Its closing advice for building a library is “All complexities should—if possible—be buried out of sight.” The same paper describes an integration routine that is given “access to an auxiliary sub-routine which is capable of evaluating f(x)”: a routine whose job is only defined once it is handed another routine. That is the squares-and-cubes problem again, in 1952.

Functions and procedures

Fortran made routines part of its language, in two forms. Fortran I had functions in the sense that formulas already used — a sine or a square root, supplied by the system, written inside an arithmetic expression — and, documented in the 1957 addenda to its manual, a one-line function statement that let a program define its own, of the form FIRSTF(X) = A*X+B. A function was anything that could appear inside a formula and stand for one number. FORTRAN II, distributed in the spring of 1958, added separately compiled subprograms and a second kind of routine beside the function. A FUNCTION subprogram is used by writing its name in an expression; a SUBROUTINE is run by a CALL statement and produces its results by storing into its arguments, which it receives as locations. The FORTRAN II manual states the difference in two sentences:

A FUNCTION subprogram always produces a single result. A subroutine may produce as many results as are specified as arguments in the CALL statement.

The Algol 58 report drew the same line under different names: a function declaration declares an expression “to be a function of certain of its variables,” and a procedure declaration declares “a closed unit,” with input parameters, output parameters, and possible exits, “which may be regarded as a single compound operation (in the sense of a generalized function).” The Algol 60 report folded the two into the procedure. A procedure is called by a procedure statement; a procedure whose declaration carries a type, and whose body assigns to the procedure’s own name, may also be called inside an expression, where the call is a function designator and the last value assigned is its value. “Not every procedure declaration defines the value of a function designator.”

In all three languages, “function” names a position in the program — a call inside an expression, yielding one value — and “procedure” or “subroutine” names a call that stands as a statement. Neither word says whether the routine has effects. A Fortran function could change the variables it was handed, and an Algol function designator could assign to anything in scope, so evaluating f(x) + g(x) could give different answers depending on which call ran first. Some languages took the mathematical reading of the word and made it a rule. Niklaus Wirth’s 1970 report on Pascal, which kept procedure and function as separate declarations, put it plainly: “In order to eliminate side-effects, assignments to non-local variables are not allowed to occur within the function.” The Fortran 77 standard forbade a function reference from changing any other entity in the statement in which it appears, and required that its value not depend on the order in which the processor chose to evaluate the statement. Both rules protect the same freedom that Part II left open for +: an implementation may evaluate operands in any order only if no operand can see another’s effects.

C collapsed the two kinds into one. Every C routine is a function, and a routine called for its effect is a function whose result the caller ignores. The first edition of The C Programming Language (1978) had no word for “no result”; by Dennis Ritchie’s account it “mentioned neither the void or enum types,” both of which were in use well before the language was standardized. void gave the procedure a spelling of its own as a return type, without a second kind of declaration.

Second-class citizens

Being able to call a routine, from a statement or from an expression, still leaves open what else a program can do with it. In Algol 60 a procedure’s name could be passed as an argument to another procedure, so Wheeler’s integration routine could be written with its integrand as a parameter. It could do nothing else. A procedure could not be assigned to a variable, placed in an array, or returned as a result, and the report says why a procedure could not even be passed by value: procedure identifiers “do not possess values.”

Christopher Strachey’s 1967 lecture notes, Fundamental Concepts in Programming Languages, describe the asymmetry in a section titled “First and second class objects”:

In ALGOL a real number may appear in an expression or be assigned to a variable, and either may appear as an actual parameter in a procedure call. A procedure, on the other hand, may only appear in another procedure call either as the operator (the most common case) or as one of the actual parameters. There are no other expressions involving procedures or whose results are procedures. Thus in a sense procedures in ALGOL are second class citizens—they always have to appear in person and can never be represented by a variable or expression (except in the case of a formal parameter)…

The distinction between first- and second-class values is usually traced to these lectures. The test it sets is about the language rather than about any one program: is there anything a program may do with a number that it may not do with a function?

By 1967 a language that answered “no” had been running for years, on a notation older than any computer. Alonzo Church’s λ-calculus (introduced previously) has only functions, and writes one without naming it: is the function that adds one, an expression like any other that can be an argument to another function or the result of one. John McCarthy borrowed the notation for Lisp, and in his history of the language, written for the first History of Programming Languages conference, gives the reason:

To use functions as arguments, one needs a notation for functions, and it seemed natural to use the λ-notation of Church (1941). I didn’t understand the rest of his book, so I wasn’t tempted to try to implement his more general mechanism for defining functions.

Lisp’s maplist applied a function given as an argument to each element of a list — the squares-and-cubes loop, written once. What a Lisp function value had to carry with it, and what Lisp at first got wrong about it, is the subject of a later chapter (cf. static and dynamic scope).

Functions as values in Bridger

In Bridger a function is a value. It can be bound by let, passed to another function, returned from one, and stored in a tuple, a list, or a cell, exactly as an integer can:

fn square(x: Int) -> Int = x * x;

fn twice(f: fn(Int) -> Int, x: Int) -> Int = f(f(x));

fn pick(double: Bool) -> fn(Int) -> Int {
    if double { |x: Int| x * 2 } else { |x: Int| x + 1 }
}

fn main() {
    let inc = |x: Int| x + 1;
    println(twice(inc, 5));                        // 7
    println(twice(square, 3));                     // 81
    let ops = [inc, square, pick(true)];
    println(map(ops, |g: fn(Int) -> Int| g(10)));  // [11, 100, 20]
    let cell = ref inc;
    cell := square;
    println((deref cell)(7));                      // 49
}

There are two ways to make a function value. A top-level fn declaration binds a name to one: square on its own, without an argument list, is an expression whose value is the function, and it is passed to twice in the same way inc is. A lambda, written |x: Int| x + 1, is an expression that produces a function without naming it — Church’s in Rust’s notation — and can appear anywhere an expression can: as a let’s right-hand side, as an argument, as a branch of an if. The parameter annotations and the type fn(Int) -> Int are read by the type checker of Part VI; the evaluator ignores them.

The prelude’s map, filter, and fold take a function argument, and the functions they are handed are ordinary values; so is map itself. The squares-and-cubes problem needs nothing more:

fn sum_of(f: fn(Int) -> Int, xs: [Int]) -> Int =
    fold(xs, 0, |acc: Int, x: Int| acc + f(x));

sum_of(square, range(1, 4))                        // 1 + 4 + 9 = 14

A Bridger function called for its effect is a function like any other. Bridger is expression-oriented: a form evaluated for its effect produces (), the value that reports only that the effect happened. A procedure is therefore a function whose result is (), and a declaration that omits its result type has result type ():

fn log(msg: String) {
    println("log: " ++ msg);
}

There is one kind of declaration; the Fortran II distinction survives as a type, () against everything else, and a procedure can be passed wherever a function is expected.

There are two things a program can do with an integer and cannot do with a function. Comparing two functions with == is stuck, for the reason Part II gave — no interpreter can decide in general whether two functions agree on every input — and a function has no printed form. Strachey’s test is about expressions and variables, and Bridger’s functions pass it; they are values without being data that can be inspected.

What a lambda evaluates to

Evaluating a lambda runs nothing. Its body is code to be run later, when the function is called, and until then it is not evaluated at all:

let never = |x: Int| x / 0;        // fine: nothing is divided

The division would be stuck, but no rule reaches it until never is applied to something.

What a lambda does record is the place it was evaluated. Its body may mention names that it does not bind — f in sum_of’s |acc: Int, x: Int| acc + f(x) is bound by sum_of’s parameter list, outside the lambda. These are the free occurrences of Part III, and Bridger’s lexical scope resolves each in the environment where the code is written. Until now, code always ran where it was written: a block’s body is evaluated in the environment the block extends, and nowhere else. A function body is the first code that runs somewhere else — wherever the function is called, which may be inside fold, or long after the block that built the function has finished. For the body’s free names to keep meaning what they meant where it was written, the function value has to take that environment with it. It does:

The rule has no premises, since nothing is evaluated, and the store is handed back unchanged. The value is a closure: the parameters, the body, and the environment in force when the lambda was evaluated. A top-level fn evaluates by E-Var: the program’s environment binds its name to a closure whose environment is the program’s environment, which is how top-level functions can call one another (cf. recursion).

How a call uses the captured environment is the rule for application, in the next chapter. What the capture makes possible — a function that outlives the call that created it — is the subject of closures.

Building it

The AST node and the value it produces are both provided. A lambda is Expr::Lambda, whose parameters carry an optional type annotation; its value is the Closure variant of Value, which holds a shared pointer to one of two kinds of function:

Expr::Lambda(Vec<LambdaParam>, Box<Expr>, Span)

pub struct LambdaParam { pub name: Name, pub ty: Option<Ty>, pub span: Span }

Value::Closure(Rc<Closure>)

pub enum Closure {
    Bridger { params: Vec<Name>, body: Expr, env: Env },  // a lambda or a `fn`
    Native(BuiltinId),                                    // a prelude primitive
}

The Lambda arm of eval_expr is E-Lam: it builds a Closure::Bridger from the parameter names (the annotations are Part VI’s business and are dropped), a copy of the body, and the environment the arm was handed, and returns it as a Value::Closure. It evaluates nothing and cannot fail. Holding the environment is cheap: an Env is the shared chain of scopes chosen in Part III so that it could be captured without being copied.

Top-level functions need no arm of their own. The provided eval_program binds every fn to a Closure::Bridger whose env is the program environment before main runs, and the prelude’s built-in primitives — println, the readers — arrive as Closure::Native, so map(xs, println) passes a primitive exactly as it passes a lambda.

The design space

Whether a language has one kind of routine or two is a choice about what a call is allowed to mean. Fortran II and Pascal keep functions and procedures apart, and a reader who sees a call in an expression knows it yields one value; Pascal’s rule on non-local assignment goes further and tells the reader the call cannot disturb the expression around it. The cost is a second declaration form and, in Pascal’s case, a restriction that forbids a function from updating a counter or caching a result. C, ML, Rust, and Bridger have one kind of routine and mark a procedure by its result type — void in C, a unit type in the others — so a procedure can be passed wherever a function is expected, and map over a list with a printing function needs no special case. What they give up is the guarantee: a call in an expression may have any effect a statement could. Bridger answers the evaluation-order question by fixing the order, left to right, through the threaded store, rather than by restricting what a function may do. Languages that record effects in a function’s type, so that “this call does nothing but compute a value” is checked, are among the alternatives taken up with programming functionally.

How far a routine is a value is a second axis. Algol 60 and Pascal let a procedure be passed as an argument and nothing more. C lets a function be named by a pointer, and a pointer can be stored, passed, and returned — but C has no nested functions, so every name a function body mentions is either its own local or global to the whole program, and the pointer needs to carry nothing but the code’s address. The Lisp line, and then Scheme, ML, and Haskell, made functions values with no restrictions and a captured environment, and most languages in wide use now do the same — Python, JavaScript, C#, Rust, and Java since version 8. The restricted designs are simpler to implement for a concrete reason. A procedure passed down to a routine it calls is used while the procedure’s defining environment still exists, so a pointer to that environment on the call stack stays valid; a procedure returned up past its defining call, or stored somewhere that outlives it, carries a pointer to an environment that the stack has already discarded. Allowing that requires environments that live as long as any function refers to them, which is what Bridger’s shared Env provides and what closures examines.

Bridger takes the unrestricted end of both axes: one kind of routine, with () for procedures, and function values that are passed, returned, and stored with their environments. It stops short of treating functions as data — no equality and no printing — because neither has an answer that describes the function rather than its representation.

Further reading

Subroutines: Goldstine and von Neumann’s Planning and Coding of Problems for an Electronic Computing Instrument, Part II, Volume III (1948), sets out the subroutine and the library of them in §12 before any machine to run them had been built. Wheeler’s “The use of sub-routines in programmes” (1952) is two pages long and covers the EDSAC’s closed sub-routines, the library, interpretive routines, and the problem of describing a routine to the people who will use it. The calling sequence of the Wheeler jump is laid out order by order in Martin Campbell-Kelly’s Tutorial Guide to the EDSAC Simulator, where it can be run.

Functions and procedures in the first languages: Backus’s “The History of Fortran I, II, and III” (1978) records how function statements, and then separately compiled SUBROUTINE and FUNCTION subprograms, came into Fortran, and the FORTRAN II Reference Manual (IBM, 1958) gives the calling conventions down to where each argument’s address is found. The Algol 58 report and the Revised Report on Algol 60 (1963) show the function and the procedure as separate declarations and then as one; §4.7 and §5.4 of the latter define procedure statements, function designators, and the call-by-name rule taken up in the next chapter. Wirth’s “The Programming Language Pascal” (Acta Informatica, 1971) is the published form of the Pascal report, with procedure and function declarations in separate sections.

First-class functions: Strachey’s Fundamental Concepts in Programming Languages, lecture notes from 1967 first published in 2000, covers first- and second-class objects in §3.5.1 alongside l-values and r-values, parametric polymorphism, and the other terms it put into circulation. McCarthy’s “History of Lisp” (1979) describes borrowing Church’s notation and the first programs that passed functions as arguments. Church’s own account is The Calculi of Lambda-Conversion (Annals of Mathematics Studies 6, Princeton University Press, 1941).

Concept checks

What does let never = |x: Int| x / 0; evaluate to, and why is there no division-by-zero error?

It binds never to a closure: the parameter x, the body x / 0, and the environment in force at that point. E-Lam has no premises — evaluating a lambda evaluates nothing inside it — so the body is not run and the division is never reached. The error would occur only if never were applied to an argument, at which point the body would be evaluated and x / 0 would be stuck.

Which lines of the Bridger program in this chapter could not be written in Algol 60, and which could?

Passing a function as an argument is allowed in Algol 60: twice(square, 3) corresponds to a procedure call with a procedure identifier as an actual parameter. Everything else is not. let inc = |x: Int| x + 1 produces a procedure from an expression and binds it to a variable; pick returns a procedure as its result; [inc, square, pick(true)] stores procedures in a data structure; cell := square assigns one to a variable. In Strachey’s terms, Algol procedures “always have to appear in person” — named directly in a call — while each of these lines represents a function by a variable or an expression.

A function value records the environment in which it was created. Why does it need to, when a block's body never did?

A block’s body is evaluated where it is written, in the environment the block extends, so its free names are resolved in the only environment it ever runs in. A function body is evaluated when the function is called, which can be inside another function (fold calling the lambda in sum_of) or after the block that created the function has finished. Under lexical scope its free names must resolve where the body was written, and that environment is not the one in force at the call, so the value has to take it along. A lambda with no free names would not need the record, which is the situation C arranges by having no nested functions.

FORTRAN II distinguishes FUNCTION from SUBROUTINE; Bridger has one form and writes a procedure as a function returning (). What does each design make easy, and what does each give up?

With two forms, a call’s role is visible from its syntax: a FUNCTION sits in an expression and yields one value, a SUBROUTINE is a statement that produces results through its arguments. A language can then attach rules to the function form — Pascal forbids assignment to non-locals in a function, Fortran 77 forbids a function reference from changing anything else in its statement — so a reader and a compiler know an expression’s calls cannot interfere with one another. The cost is two declaration forms and routines that cannot stand in for each other. With one form, a procedure is just a function whose result is (), so anything that accepts a function accepts a procedure (map with a printing function), and there is one rule for calls. What is given up is any guarantee from the syntax: a call inside an expression can have effects, and Bridger has to fix the evaluation order instead of leaving it to the implementation.

The Wheeler jump stored its return address by writing into the subroutine's own exit order. What goes wrong if the subroutine calls itself?

The inner call writes its return jump into the same location as the outer call, overwriting it. When the inner call finishes, it returns correctly to its caller — the subroutine itself — but when the outer call finishes, the order at its end now jumps back into the subroutine rather than to the original caller, whose return address has been lost. The scheme stores one return address per routine, so it supports one active call per routine at a time. Supporting recursion requires a fresh place for the return address, and for the locals, on each call — the stack of activations that the recursion chapter makes visible in the evaluator.

Evaluating Function Calls

The previous chapter made functions into values. A function value is a closure: the parameter names, the body, and the environment where the function was written. This chapter is about what happens when a program calls one.

Here are two small functions. trace prints a label and then returns a number; sub subtracts:

fn trace(label: String, v: Int) -> Int {
    println(label);
    v
}

fn sub(a: Int, b: Int) -> Int = a - b;

sub(trace("a", 10), trace("b", 3))

The call’s value is 7. Getting there takes several steps, and at each one the language has to decide something:

  • What is evaluated first? The interpreter evaluates sub to find its closure, and each argument to get its value. Because trace prints, the output shows the order: a then b, or b then a.
  • When is an argument evaluated? Before the body starts, or only when the body uses the parameter — and so never, if the body never does.
  • Where does the body run? The body looks up its parameters, and any other names it mentions, in some environment. The call has to build that environment.
  • What if the body leaves early? A return in the body has to become the call’s value and go no further.

Languages in use today still answer the first two questions differently. The second divided Algol 60 and Lisp at the end of the 1950s, and the chapter begins there before giving Bridger’s answers as one inference rule, E-App.

Call by value, call by name

The Algol 60 report defined a procedure call by describing an edit to the program text. Its §4.7.3 lists the operations “on the program at the time of execution of the procedure statement”. A parameter listed in the procedure’s value part is called by value: the actual parameter is evaluated and its value assigned to a variable local to the body, as though in “an additional block embracing the procedure body”. Every other parameter is called by name:

Any formal parameter not quoted in the value list is replaced, throughout the procedure body, by the corresponding actual parameter, after enclosing this latter in parentheses wherever syntactically possible. Possible conflicts between identifiers inserted through this process and other identifiers already present within the procedure body will be avoided by suitable systematic changes of the formal or local identifiers involved.

“Finally the procedure body, modified as above, is inserted in place of the procedure statement and executed.” Under call by name an argument is an expression, pasted into the body wherever the parameter appears, and evaluated each time execution reaches one of those places — once, many times, or never. The renaming clauses keep the paste from changing what any name means: the body’s own identifiers are renamed away from those in the pasted expression, and, when the procedure is called from somewhere its nonlocal names are not in scope, the caller’s identifiers are renamed away from the body’s.

Call by name made possible a kind of procedure that call by value cannot express directly. A procedure could take an expression and a variable that appears in it, change the variable, and re-evaluate the expression:

real procedure sum(i, lo, hi, term);
    value lo, hi;
    integer i, lo, hi;  real term;
begin real s;
    s := 0;
    for i := lo step 1 until hi do s := s + term;
    sum := s
end

A call sum(k, 1, n, a[k] × b[k]) pastes a[k] × b[k] in for term and k in for i, so each turn of the loop assigns the next index to k and then evaluates the product at that index; the call computes an inner product. The technique came to be known as Jensen’s device.

A compiler cannot literally edit the program text at every call, so it has to produce the same effect another way. Peter Ingerman’s 1961 paper, titled simply “Thunks”, describes compiling each by-name argument into a small routine that computes the argument’s expression in the caller’s environment; the body runs that routine every time it uses the parameter. The paper’s title became the name for such a routine, and for any delayed computation packaged to run later.

Lisp took the other answer for every argument. McCarthy’s eval evaluates each argument of a function call first and hands the function the resulting values — call by value — so an argument is evaluated exactly once, before the body starts, whether or not the body uses it. C, ML, Java, Python, Rust, and Bridger all evaluate arguments the same way.

The rule for application

Bridger’s call is one rule. In the env-and-store judgment of Part IV:

Read top to bottom, the premises are the order the interpreter works in.

The first premise evaluates the operator , in the caller’s environment , and requires the result to be a closure : parameters , body , and the environment that E-Lam recorded when the closure was made. The operator is an expression like any other. It is usually a name — sub, which E-Var looks up — but pick(true)(10) and (deref cell)(7) are calls too.

The second line evaluates the arguments through , also in , one after another. The store threads through them in index order — starts from the store the operator left, , and each later argument starts from the store the one before it left — which fixes the order at left to right, exactly as the threaded store fixed the order of +’s operands in mutation. Each is a value by the time the body starts. That makes the rule call by value, and the call from the start of the chapter prints a and then b before sub’s body subtracts.

The third premise runs the body. Its environment is : the closure’s captured environment, extended with each parameter bound to its argument . The caller’s environment appears in the first two premises and not in the third. Whatever the caller has bound, the body sees only what was in scope where the function was written, plus its own parameters:

let scale = 10;

fn scaled(x: Int) -> Int = x * scale;

fn main() {
    let scale = 2;
    let x = 99;
    println(scaled(4));               // 40
}

scaled’s closure captured the program environment, where scale is 10; extending it with x ↦ 4 shadows nothing the caller holds, because the caller’s x and scale are not in it. This is the lexical scope that Part III committed Bridger to, now at work on code that runs away from where it was written. What the other choice — looking free names up in the caller’s environment — would do to programs like this one is the subject of static and dynamic scope.

The bindings the body extends are new. A call does not change the closure’s environment ; it builds a child of it for the length of the body, the same way a block builds a child scope. Two calls of the same function, or a function that calls itself, each get their own parameter bindings on top of the same captured environment.

The conclusion writes out both outcomes of evaluation, which the rules since E-Return have left implicit. The body’s result is either an ordinary or the returning outcome that return produces, and

turns either into the call’s value, marked again. Under the convention that a premise yielding makes its rule yield , a return travels outward through the loops and blocks around it; E-App is where it stops. The call is the function boundary that conditionals and loops described without having one:

fn first_negative(xs: [Int]) -> Int {
    for x in xs {
        if x < 0 { return x; }
    }
    0
}

first_negative([3, -4, 5, -6]) + 100    // 96

The return x abandons the rest of the loop and the trailing 0; the call to first_negative catches it and yields -4, and the + 100 around the call proceeds as it would for any value.

When a call is stuck

Two things can prevent E-App from applying. The operator may evaluate to something other than a closure — 5(1), or a name bound to a list — and then the first premise cannot hold. Or the number of arguments may differ from the number of parameters, and then , which pairs the -th parameter with the -th value, has no meaning. The Algol 60 report stated the second condition in as many words: the actual parameter list “must have the same number of entries as the formal parameter list” of the declaration.

The premises also say when each is detected. A non-function operator is stuck at the first premise, before any argument has run; a wrong count is stuck only once the arguments have all been evaluated, since the rule compares the values with the parameters. In 5(trace("a", 1)) nothing is printed; in sub(trace("a", 1)) the a is printed and then evaluation is stuck.

Both are runtime errors in the interpreter built so far. With the type checker of Part VI, a call’s operator and its argument count are known before the program runs, and both become static errors.

Building it

A call is Expr::Call, holding the operator, the argument expressions, and the span of the whole call:

Expr::Call(Box<Expr>, Vec<Expr>, Span)

Everything the Call arm needs beyond eval_expr itself is provided. The closure it finds is the Closure enum from the previous chapter; the environment for the body is built with Env::extend, which returns a child scope and leaves its receiver untouched; the stuck cases have their own RuntimeError variants; and two methods on Interpreter keep count of how deep the calls are nested:

impl Env {
    pub fn extend(&self, x: Name, v: Value) -> Env;
}

RuntimeError::NotAFunction { found: Ty, span: Span }
RuntimeError::ArityMismatch { expected: usize, found: usize, span: Span }

impl Interpreter {
    fn enter_call(&mut self, span: Span) -> Result<(), RuntimeError>;
    fn leave_call(&mut self);
    fn eval_prelude(&mut self, id: BuiltinId, args: Vec<Value>, span: Span)
        -> Result<Value, RuntimeError>;
}

The arm follows E-App’s premises in order. It evaluates the operator; a value that is not a Value::Closure is NotAFunction, with found filled from the value’s runtime type, before any argument is touched. It evaluates the arguments left to right into a vector of values. What happens next depends on which kind of closure it holds. A Closure::Native is a prelude primitive, and eval_prelude runs it on the argument values. A Closure::Bridger checks the argument count against its parameters (ArityMismatch if they differ), extends its own captured env with one binding per parameter, and evaluates the body in that environment.

A Bridger body is bracketed by enter_call before it and leave_call after it, whatever the body’s outcome. The pair counts frames: a program that recurses without end is stopped at a fixed depth with a StackOverflow error, where the Rust stack running eval_expr would otherwise overflow and abort the whole interpreter.

The body’s outcome is the last thing the arm looks at. Evaluation hands it back as the Control channel: a value, a Control::Return, or a Control::Raise. The Call arm is the one place in the evaluator that inspects Control::Return instead of passing it on — it takes the returned value as the call’s value, which is — while a Raise continues outward as it does everywhere else.

In Part VII, the operator of a call may also evaluate to a relation, and the Call arm gains one more case.

The design space

Other languages have answered E-App’s questions differently, most visibly the order in which a call’s parts are evaluated and when an argument is evaluated at all.

Evaluation order

Bridger fixes the order: operator, then arguments left to right. Java fixes the same order for arguments — the Java Language Specification has a section titled “Argument Lists are Evaluated Left-to-Right”. Scheme leaves it open: the R7RS (§4.1.3) says only that the operator and operands of a procedure call are evaluated in an unspecified order. C goes further. Under the C11 standard (§6.5) the side effects of a call’s arguments are unsequenced relative to one another, and if two of them modify the same object, as in f(i++, i++), the program’s behaviour is undefined: the standard places no requirement on what it does.

Leaving the order open lets a compiler evaluate the arguments in whatever order suits the machine: pushing them onto a stack from right to left, or computing the one that needs the most registers first. The cost falls on programs whose arguments have effects. A call such as printf("%d %d\n", next(), next()), where next advances a counter, prints its two numbers in either order, and the order can change with the compiler, its version, or its optimization level. A program that happens to rely on one order works until it is rebuilt somewhere else. A fixed order costs the compiler that freedom and gives every call with effects a single meaning; in the semantics it costs nothing, since threading the store left to right is already an order.

When arguments are evaluated

Call by value evaluates each argument once, before the body starts. A function that ignores one of its arguments still pays for it — and if that argument is stuck, the call is stuck:

fn choose(c: Bool, a: Int, b: Int) -> Int = if c { a } else { b };

choose(true, 1, 1 / 0)            // stuck: division by zero

Call by name would have returned 1: b is never reached in the body, so 1 / 0 is never evaluated. The price is paid on the other side. A by-name argument is evaluated every time the body uses its parameter, so a parameter used inside a loop re-evaluates its argument on every turn, and an argument with an effect has that effect once per use. Jensen’s device relies on exactly this, and it is also why a reader of an Algol 60 procedure cannot tell, from the call alone, how many times each argument will run.

Call by need keeps call by name’s promise to evaluate an argument only if it is used, and adds a second promise: at most once. The first use evaluates the argument and records its value; every later use reads the record. Christopher Wadsworth’s 1971 Oxford thesis is usually credited with the technique, as a way of evaluating λ-calculus terms without repeating work. It is the evaluation strategy of lazy functional languages. The Haskell 2010 Report specifies “non-strict semantics” — a function may return a result without evaluating an argument it does not need — and leaves the mechanism to the implementation; the widely used Glasgow Haskell Compiler implements it with call by need.

Call by need makes the number of times an argument runs, and when, depend on how the body happens to use it. An argument that prints, or writes a cell, would print or write at a moment nobody chose. Haskell’s answer is to have no such arguments: an ordinary Haskell expression cannot perform an effect, and effects are sequenced by a separate mechanism in the types. A language with effects in ordinary expressions, Bridger among them, would have to choose between that restriction and effects whose timing a programmer cannot predict.

Bridger’s lambdas can simulate a by-name argument where one is wanted. A lambda with no parameters, written ||, is a thunk in Ingerman’s sense: it packages an expression to be evaluated later, in the environment where it was written, and a call with no arguments runs it:

fn choose_lazy(c: Bool, a: fn() -> Int, b: fn() -> Int) -> Int =
    if c { a() } else { b() };

choose_lazy(true, || 1, || 1 / 0)      // 1

The delay is written out at the call and the forcing in the body, so it is visible exactly where it happens. Call by need adds one more thing, the record of the first result; with a mutable cell to hold it, that too can be built by hand.

A fourth answer, where a procedure receives its argument’s location and assignments to the parameter write the caller’s variable, belongs to the same family. FORTRAN II passed arguments as locations, as the previous chapter showed, and Pascal’s var parameters make the choice per parameter. Bridger gets the same effect with an explicit value: a function that should update its caller’s state is passed a ref cell, and the cell’s location travels by value like any other.

Further reading

Parameter passing in Algol 60: the Revised Report on Algol 60 (1963) defines value assignment, name replacement, and body replacement in §4.7.3, in under a column. Ingerman’s “Thunks” (1961) describes how a compiler can carry out name replacement without editing the program.

Evaluation strategies and the λ-calculus: Gordon Plotkin’s “Call-by-name, call-by-value and the λ-calculus” (1975) gives each strategy its own calculus and proves how the two relate to evaluation in a machine; the argument is carried almost entirely in the formal development.

Laziness in practice: “A History of Haskell: Being Lazy with Class” (Hudak, Hughes, Peyton Jones, and Wadler, 2007) tells how the lazy functional languages came to share one design, and how laziness pushed Haskell toward purity and a type-based treatment of effects. John Hughes’s “Why Functional Programming Matters” (1989) uses lazy evaluation to separate a program that generates candidate answers from the one that decides how many to look at.

Concept checks

In sub(trace("a", 10), trace("b", 3)), which premise of E-App fixes that a is printed before b?

The second, through its store threading. Argument is evaluated starting from , the store argument left behind, so cannot start until has finished. Printing is an effect, and the order of effects follows the order in which premises are discharged. A rule that gave every argument the same starting store would leave the order unspecified — and would also lose any cell allocated before ran.

E-App evaluates the body in . What goes wrong if a call instead evaluated the body in and extended it permanently — so that the parameter bindings stayed in the closure's environment after the call?

The next call would find the previous call’s parameters already bound. A body with a free occurrence of a name that happens to match a parameter of an earlier call would see that stale value, and a recursive call would overwrite its caller’s parameters, so the caller would resume with the wrong arguments. extend builds a fresh child of for each call and leaves itself unchanged, so every call, including each level of a recursion, has its own bindings on top of the same captured environment.

What does sum_until_negative([1, 2, -3, 4]) return? Which call catches the return?
fn sum_until_negative(xs: [Int]) -> Int =
    fold(xs, 0, |acc: Int, x: Int| if x < 0 { return acc; } else { acc + x });

It returns 7. The return is inside the lambda, so the call that catches it is the call of the lambda, which fold makes once per element; the call of sum_until_negative never sees it. At -3 the lambda returns the accumulator, 3, as that one call’s result; fold receives 3 as it would any result and continues with 4, giving 7. A return leaves the nearest enclosing function, and a lambda is a function. Stopping the fold early would need a loop with a return in sum_until_negative’s own body.

Why is 5(trace("a", 1)) stuck without printing anything, while sub(trace("a", 1)) prints a and then is stuck?

The premises are discharged in order. The first requires the operator to evaluate to a closure; 5 does not, so no later premise is attempted and the argument never runs. sub does evaluate to a closure, so the second premise evaluates the argument — printing a — and only the third needs the parameters and values to pair up, which they cannot with two parameters and one value.

Would call by need be a good choice for Bridger's function calls? Consider choose(true, 1, 1 / 0) and a call whose argument is println("x").

It would make choose(true, 1, 1 / 0) return 1, as call by name would, and it would cost no more than call by value for an argument used once. But Bridger’s expressions can have effects. An argument of println("x") would print when, and if, the body first used its parameter, so the output of a program would depend on the internals of every function it called; two calls that differ only in whether the body inspects an argument would behave differently. Lazy languages avoid this by keeping effects out of ordinary expressions altogether. Bridger keeps effects in expressions and call by value, and a program that wants delay writes it explicitly with a || thunk.

Closures

A function in the previous chapter ran its body in the environment it captured, and the examples called functions that were defined at the top of the program, whose environment lasts as long as the program does. A function can also be made inside another call and handed back as its result:

fn make_adder(k: Int) -> fn(Int) -> Int = |x: Int| x + k;

fn make_counter() -> fn() -> Int {
    ref n = 0;
    || { n := deref n + 1; deref n }
}

fn main() {
    let add5 = make_adder(5);
    println(add5(10));                  // 15

    let c1 = make_counter();
    let c2 = make_counter();
    c1(); c1();
    println(c1());                      // 3
    println(c2());                      // 1
}

By the time add5(10) runs, the call make_adder(5) is over. Its parameter k was bound when the call began, and nothing in main can name it; yet the body x + k finds k and finds it bound to 5. make_counter goes further. Each call makes a cell and returns a function that updates it, and the cell goes on counting across calls of that function, long after the call that made it has returned. c1 and c2 each have a count of their own.

Both programs depend on a binding lasting longer than the call that created it. In most early compiled languages, a call’s bindings ended when the call did.

Bindings on a stack

Algol 60 let procedures be declared inside blocks and inside other procedures, and let a body refer to the variables of the blocks around it. The usual implementation keeps each call’s variables in an activation record on a stack: entering a block or procedure pushes a record, and leaving it pops the record, so the storage for a call’s bindings is reclaimed the moment the call returns. The Wheeler jump of the first chapter of this part kept one return address per routine; a stack keeps one record per active call, which is what lets a procedure call itself.

A stack makes a binding’s lifetime and its scope line up. A name declared in a block can be read only from the text inside that block, and while that text runs the block’s record is on the stack. As first-class functions observed, the one kind of function value Algol 60 allowed, a procedure passed down as an argument, fits the stack: the procedure is called while the routine that received it is running, which is while its defining block’s record is still in place. make_adder does not fit. It returns |x: Int| x + k upward, past the end of its own call, and a stack would pop k at exactly that moment.

Peter Landin’s “The mechanical evaluation of expressions” (1964) described a machine for evaluating expressions written in Church’s λ-notation, where functions are produced by expressions and returned like any other value. Its state has four components — a stack, an environment, a control, and a dump — and it is often called the SECD machine after them. The environment is “a list-structure made up of name/value pairs”, the same kind of object the machine computes with, so a value can hold one:

Also we represent the value of a λ-expression by a bundle of information called a “closure,” comprising the λ-expression and the environment relative to which it was evaluated.

Landin gives the closure an environment part and a control part, and notes that the environment part “would be unnecessary if λ-expressions containing free variables were prohibited.” The word closure in this book, and E-Lam’s pair of a lambda and an environment, come from this paper.

Scope and extent

The scope of a binding, as Part III defined it, is a region of program text: the part of the program where the name refers to that binding. The extent of a binding is a span of time: how long, while the program runs, the binding exists. Scope is settled by reading the program; extent is a fact about one run.

On a stack, a binding’s extent is the activation of its block — from the moment the block is entered until it is left — and since a block’s text runs only while it is active, extent and scope never disagree. A closure separates them. The body of |x: Int| x + k lies inside make_adder, so k is in scope there; but it runs when add5 is called, after make_adder’s activation has ended. For that body to find k, the binding’s extent has to reach as far as add5 can be called, and a function value can be kept, passed along, and called at any later time. Common Lisp the Language calls this indefinite extent: “The entity continues to exist as long as the possibility of reference remains.” It calls the other kind, a binding that exists from when it is established until it is explicitly disestablished, dynamic extent.

The rules of Part V already give bindings that extent, because no rule ever discards an environment. In the judgment , an environment is a mathematical map, and E-Lam puts one inside a value. Follow make_adder(5) and then add5(10) through the rules:

  • make_adder evaluates by E-Var to its closure , where is the program environment and is the lambda |x: Int| x + k.
  • E-App evaluates the body in . The body is a lambda, and E-Lam turns it into the closure without evaluating anything. That closure is the call’s value, and let binds add5 to it.
  • add5(10) is E-App again. The operator’s closure carries , so the body x + k runs in , where is , and the result is 15.

The binding was made by the first call and used by the second. Between them it lived in a value — the closure bound to add5 — and the rules have no step at which a value’s contents go away. Nothing in the semantics corresponds to popping a stack. An interpreter that reclaims a scope when its block or call ends departs from the rules whenever a closure made inside that block or call is still reachable.

A call is not needed to see this. A block’s value can be a lambda that mentions the block’s own bindings:

let f = {
    let k = 3;
    |x: Int| x + k
};
f(4)                                // 7

k’s scope is the block, and the block has ended by the time f(4) runs; k’s extent is as long as f’s.

State in a captured cell

make_counter adds mutation. Its first line, ref n = 0;, is let n = ref 0;: E-Ref picks a fresh location , puts 0 in it, and let binds n to . The lambda on the next line captures an environment in which . Each call of that lambda looks n up, finds , reads the cell with E-Deref, and writes it with E-Assign.

The binding never changes; the store’s entry for does. The closure keeps the location, and the location’s contents persist in the store between calls, so the count survives. Each call to make_counter runs ref 0 again, and E-Ref’s side condition makes every run allocate a location no other cell uses. c1 and c2 are two closures over two scopes, each binding n to a different location:

closures environment store c1 c2 ⟨ || …, • ⟩ ⟨ || …, • ⟩ n = ℓ₀ n = ℓ₁ root ℓ₀ : 3 ℓ₁ : 1

The environment side of the figure is never written after it is built; every change happens in the store. Several closures can capture the same scope, and then they share its cells:

fn make_account(start: Int) -> (fn(Int) -> (), fn() -> Int) {
    ref balance = start;
    let deposit = |amt: Int| { balance := deref balance + amt; };
    let current = || deref balance;
    (deposit, current)
}

Both lambdas are evaluated in environments that bind balance to one location, so a deposit is visible to the current returned beside it: after deposit(25) and deposit(5) on an account opened with 100, current() is 130. No other code can reach that cell. balance is out of scope everywhere except the two lambda bodies, and the location travels only inside the two closures; whatever reads or changes the balance goes through one of them. A cell whose only access is through a fixed set of functions is the arrangement the objects of Part IX give a name and a syntax.

What the capture holds

A closure holds the bindings of its environment, and a binding may hold a location. Which of those the closure’s body reads at its call decides what it sees. These two loops each build a list of three functions that take no arguments:

ref fs: [fn() -> Int] = [];
for i in range(0, 3) {
    fs := (|| i) :: deref fs;
}
map(deref fs, |g: fn() -> Int| g())         // [2, 1, 0]

ref gs: [fn() -> Int] = [];
ref j = 0;
while deref j < 3 {
    gs := (|| deref j) :: deref gs;
    j := deref j + 1;
}
map(deref gs, |g: fn() -> Int| g())         // [3, 3, 3]

The for loop’s rule, E-For-Cons, evaluates the body once per element in : a fresh binding of i for each element. Each || i captures a different binding, holding 0, 1, or 2, and calling it later returns that number. The list comes out in reverse because each new function is consed on the front.

The while loop has one binding, j bound to one location, and all three || deref j capture it. None of them reads the cell until it is called, and by then the loop has left 3 there. To keep the value the cell held on a given turn, the body has to read it on that turn and bind the result:

    let v = deref j;
    gs := (|| v) :: deref gs;

and the list becomes [2, 1, 0]. In both loops the closure captured exactly its environment. The difference is whether the name it reads is bound to a number or to a cell.

Building it

There is no new arm. The Lambda arm from first-class functions stores the environment it was handed in a Closure::Bridger, and the Call arm from the previous chapter extends that environment with the parameters. Those two arms already run every program in this chapter. What remains is why the environment they share survives the call that built it.

An Env is a handle to a scope chain, and cloning one clones an Rc:

pub struct Env(Rc<Scope>);   // cheap to clone: one more holder

enum Scope {
    Frame { name: Name, val: Value, parent: Env },
    // … the root scope, built by provided setup
}

Trace make_adder(5) through the arms. The Call arm calls extend to bind k to 5, building a new scope whose parent is the root, and holds it in a local variable while the body runs: one holder. The body is a lambda, so the Lambda arm builds a closure whose env is a clone of that handle: two holders. The body’s value goes back to the Call arm, which returns it and drops its local: one holder, the closure. stays alive because the closure still points to it, and let add5 keeps the closure. When add5 is no longer reachable, the closure is dropped, ’s count reaches zero, and is freed, which in turn drops one holder of the root. A scope lives as long as the last closure or evaluation that can reach it, which is the extent the rules require.

Rust’s own closures meet the same problem, and the compiler reports it. The adder, written in Rust with a closure that borrows its free variable:

fn make_adder(k: i32) -> impl Fn(i32) -> i32 {
    |x| x + k
}

is rejected:

error[E0373]: closure may outlive the current function, but it borrows `k`,
              which is owned by the current function
 --> r1.rs:2:5
  |
2 |     |x| x + k
  |     ^^^     - `k` is borrowed here
  |     |
  |     may outlive borrowed value `k`
  …
help: to force the closure to take ownership of `k` (and any other referenced
      variables), use the `move` keyword

A borrow of k is a pointer into make_adder’s activation, and the activation ends at the return. Writing move |x| x + k copies k into the closure, and the program compiles. An interpreter whose Closure held a borrowed &Env would run into the same kind of error for the same reason, at the first lambda returned from a call. Holding an Rc gives the closure a share in the scope’s ownership, so the scope’s lifetime is set by the count of its holders.

The scope chain needs no RefCell for this. Bindings are immutable, and cells live in the store, whose Loc handles are plain indices; a captured scope is read, never written, so a shared read-only Rc is enough. (The root is the exception, and provided setup handles it: every top-level fn is a closure that captures the root, and the root holds every top-level fn, which is how they can call one another. That circle of Rcs is never freed, and does not need to be, since the root is in use for the whole run.)

The design space

Every language with closures decides what a closure captures when a captured variable can change, and where captured environments are kept.

Capturing variables and capturing values

In Scheme and JavaScript a closure captures the variable itself. An assignment made to the variable after the closure is created is seen by the closure, and an assignment made by the closure is seen outside it. Python closures capture the variable too, though an assignment inside a closure makes a new local unless the name is declared nonlocal. The bank account of Structure and Interpretation of Computer Programs (§3.2.3) is make-withdraw, a procedure whose returned lambda assigns to its own parameter balance with set!; the parameter is the account’s state, with no separate cell.

C++ asks the programmer to decide, one variable at a time. A lambda’s capture list takes a variable by copy ([=], or the bare name) or by reference ([&], or &name). A copy is made when the lambda is created and is unaffected by later assignments. A reference follows the variable, and cppreference states what happens when the variable is gone: calling the closure after the captured entity’s lifetime has ended is undefined behaviour, and “C++ closures do not extend the lifetimes of objects captured by reference.” Rust makes the same choice with borrowing and move, and the borrow checker rejects a borrowing closure that would outlive what it borrows, as E0373 showed.

Java removes the question. The Java Language Specification (§15.27.2) requires that “any local variable, formal parameter, or exception parameter used but not declared in a lambda expression must either be final or effectively final” — never assigned after its initialization — and gives the reason: the restriction “prohibits access to dynamically-changing local variables, whose capture would likely introduce concurrency problems.” When the variable cannot change, a copy and the variable are indistinguishable, and the implementation copies.

ML and Bridger arrive at Java’s position from the other side. No binding is ever assigned, so every capture behaves as a copy, and state that a closure shares with anything else lives in a ref cell, created by a visible ref and reached through deref and :=. Scheme’s make-withdraw and Bridger’s make_account keep their state the same way; in Bridger the shared state is marked where it is made.

Loops

Closures made inside a loop show the difference between one variable per loop and one per turn. The Python FAQ has an entry, “Why do lambdas defined in a loop with different values all return the same result?”: a loop variable is a single variable, “accessed when the lambda is called — not when it is defined,” so every lambda sees its last value. The fix it gives is lambda n=x: n**2, a default argument evaluated when the lambda is made — the let v = deref j of the while example above.

Several languages have changed their loops so that each turn gets a fresh variable. ECMAScript 2015 gave a for loop whose variable is declared with let a new binding for each iteration, copied from the previous one (the abstract operation CreatePerIterationEnvironment, §13.7.4.9 of the 6th edition); a loop variable declared with the older var is still one variable for the whole loop. C# 5 made the variable of a foreach loop fresh on each iteration and left the for loop alone; Eric Lippert’s “Closing over the loop variable considered harmful” (2009) set out the case, and an update at its head records the decision. Go 1.22, released in February 2024, did the same for the variables declared by every for loop. Its release notes say: “Previously, the variables declared by a ‘for’ loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs.”

The C# and Go changes altered the meaning of existing programs, made after the one-variable loop had been in use for years. ECMAScript’s did not: let was new in the same edition, and a var loop kept its meaning. Bridger’s for rule binds the loop variable afresh for each element from the start, and since no binding can be assigned, a closure that captures a loop variable sees one value. The shared behaviour can still be written, with a while loop over a cell, and then the cell is in the program text.

Where environments live

A language can keep environments on a stack only if no closure outlives the activation it captured. Algol 60 and Pascal guarantee that by letting a procedure be passed down and nowhere else. GNU C allows a function to be defined inside another as an extension, and a pointer to the inner function can be stored or returned like any function pointer; the inner function’s free variables are the outer function’s locals, which live on the stack. The GCC manual says the technique “works only so long as the containing function … does not exit”, and: “If you try to call the nested function through its address after the containing function exits, all hell breaks loose.” The language leaves the extent of the environment to the stack and the burden of respecting it to the programmer.

Languages with unrestricted closures keep captured environments in the heap and reclaim them when nothing refers to them. Bridger’s interpreter counts references with Rc. A tracing garbage collector — Scheme’s, ML’s, JavaScript’s, Java’s — instead periodically finds everything reachable from the running program and frees the rest. Reference counting frees a scope the moment its last holder goes, and cannot free a cycle, like the root’s; tracing collects cycles, and does its work in periodic passes over everything still in use.

A closure also need not hold its whole environment. Bridger’s closure holds a pointer to the scope chain, which keeps every binding along the chain alive, including ones the body never mentions. An implementation can instead build a flat closure, copying into it the values of just the free variables of the body, so the body reads them without a walk and nothing else is kept alive. The copy is correct only if the captured variables cannot change afterwards — the condition Java imposes, and the one Bridger’s immutable bindings meet everywhere. When a language lets captured variables be assigned, its compiler first moves each such variable into a heap cell and copies the cell’s location, which is the arrangement a Bridger program writes out by hand with ref.

Further reading

The closure and the machine: Landin’s “The mechanical evaluation of expressions” (1964) defines closures and the four-part machine that evaluates with them, together with the treatment of expressions in λ-notation that the machine runs.

Scope and extent: chapter 3 of Guy Steele’s Common Lisp the Language, 2nd edition (1990) defines lexical and indefinite scope, dynamic and indefinite extent, and shows which Common Lisp constructs have which, including a compose whose parameter bindings outlive its call.

Local state in closures: §3.2 of Abelson and Sussman’s Structure and Interpretation of Computer Programs draws environments as linked frames and follows make-withdraw through them, showing where each account’s balance lives.

Loops and captured variables: Eric Lippert’s two posts, “Closing over the loop variable considered harmful” and its part two (2009), lay out the arguments for and against changing the meaning of C#’s foreach, from a member of the team that made the change.

Concept checks

After let add5 = make_adder(5);, the call make_adder(5) has returned. Where is the binding k ↦ 5, and what keeps it alive?

It is in the scope the Call arm built for make_adder’s body, . The body was a lambda, so E-Lam put that environment into the closure it returned, and add5 holds the closure. In the interpreter the closure’s env is an Rc handle to the scope; the call’s own handle was dropped when the call returned, and the closure’s handle is the one that remains. The scope is freed only when add5, and any other copy of the closure, is gone.

An implementation reclaims each block's scope when the block ends. For which programs is that correct, and what would go wrong in let f = { let k = 3; |x: Int| x + k }; f(4)?

It is correct for every block from which no closure escapes: if nothing built inside the block outlives it, no later evaluation can reach the block’s bindings, and reclaiming them early changes nothing a program can observe. In the example the block’s value is a closure over k, and f(4) runs after the block has ended, so the body x + k would look up k in a scope that no longer exists. The rules give k as long an extent as f, and the implementation has to as well.

c1 and c2 come from two calls of make_counter. Which rule guarantees that they do not share a count?

E-Ref. Each call of make_counter evaluates ref 0, and E-Ref requires the new location to be outside the domain of the store, so the two calls allocate two different cells. Each lambda then captures an environment binding n to its own call’s location. Nothing in E-Lam or E-App keeps the counts apart; the two closures would share a count if they captured one location, as the two closures from one call of make_account do.

In the while loop, each || deref j returns 3. Would making the loop variable a fresh binding on each turn, as for does, fix it?

Not by itself. The closures return 3 because each reads the cell when it is called, and there is one cell. A fresh binding per turn that still bound j to the same location would leave all three reading that location. What changes the result is capturing a binding whose value is the number: let v = deref j; reads the cell on that turn, and || v captures v. for gives the right answer because it binds i to each element itself, with no cell involved.

Java requires a local variable captured by a lambda to be final or effectively final. Bridger has no assignment to let-bound names at all. What does each rule make possible, and what does a program that wants a shared, changing count have to do in each language?

Both rules make “does the closure see the variable or a copy of its value?” unanswerable by any program, because the variable never changes after the closure is made; an implementation is then free to copy the value into the closure. A program that wants state shared between a closure and its surroundings has to put the state somewhere mutable that both can reach: in Java, a field of an object or a one-element array; in Bridger, a ref cell. The shared state then shows up in the program text as a cell, visible at the point where it is made.

Static vs. Dynamic Scope

Evaluating function calls ran this program and printed 40:

let scale = 10;

fn scaled(x: Int) -> Int = x * scale;

fn main() {
    let scale = 2;
    println(scaled(4));               // 40
}

scale is free in the body of scaled: nothing inside the function binds it. Two bindings of scale exist when the call happens. One is at the top of the program, where scaled was written, and holds 10. The other is in main, where scaled is called, and holds 2. Bridger’s rule for application runs the body in the environment the closure captured, so scale is 10. A language that ran the body in the caller’s environment instead would print 8.

A second program makes the difference sharper. twice applies a function to an argument two times, and add_x is a lambda whose body reads a name x from the block around it:

fn twice(f: fn(Int) -> Int, x: Int) -> Int = f(f(x));

fn main() {
    let x = 1;
    let add_x = |y: Int| y + x;
    println(twice(add_x, 10));        // 12
}

Bridger prints 12: add_x adds 1 each time it is called. Run add_x’s body in its caller’s environment and its caller is twice, whose parameter happens to be named x too. There x is 10, add_x(10) is 20, add_x(20) is 30, and the program prints 30. Rename twice’s parameter to n and the same rule prints 12 again.

The two programs raise these questions:

  • Where does a free name in a function body get its value? From the environment where the function was written, or from the one where it is called?
  • Is the answer a fact about the program text? If the caller’s environment decides, can anyone reading add_x say what it computes?
  • What does each answer cost? What does it make easy, what does it break, and what does it do to a language that checks types before running?

The first implementation of Lisp gave the second answer to the first question, and its authors found out what that answer cost from a bug report.

Lisp’s free variables

McCarthy’s eval kept the environment as an association list, the a-list: a list of name/value pairs, handed to each evaluation and searched from the front when a variable was read. Applying a function paired its parameters with its arguments and put the pairs on the front of the a-list in hand, then evaluated the body with the longer list. A function passed as an argument was its λ-expression, a piece of list structure like any other, and when it was applied the a-list in hand was the one at the point of the call. Its free variables were looked up there.

In his “History of Lisp” (1979), McCarthy recalls the program that exposed this. James Slagle wrote a recursive search function that took, as one of its arguments, a function to call if the search failed, and in the recursive case passed a new λ-expression that mentioned the current call’s x. When that λ-expression ran, it was inside a deeper recursive call, and its x found the deeper call’s binding:

The difficulty was that when an inner recursion occurred, the value of car[x] wanted was the outer value, but the inner value was actually used. In modern terminology, lexical scoping was wanted, and dynamic scoping was obtained.

I must confess that I regarded this difficulty as just a bug and expressed confidence that Steve Russell would soon fix it. He did fix it but by inventing the so-called FUNARG device that took the lexical environment along with the functional argument.

It is the twice program’s failure: a function finds a binding of the right name made by the wrong call. Russell’s repair was to make a functional argument carry its environment, a pair of the λ-expression and the a-list in force where it was written, which is a closure under another name. McCarthy adds that the problem “later showed up in Algol 60”, and that while the FUNARG device “worked well in the interpreter, comprehensiveness and speed seem to be opposed in compiled code, and this led to a succession of compromises.”

The repair did not change Lisp’s default. In the LISP 1.5 Programmer’s Manual, a programmer who wanted the environment kept wrote FUNCTION around the λ-expression, and the interpreter built a FUNARG from it; written plainly, under QUOTE, it was a bare λ-expression as before. The manual (§3.1) says when the difference does not matter: “QUOTE will work instead of FUNCTION, provided that there are no free variables present.” The compiler, described in the same manual, made a third arrangement. An ordinary variable of a compiled function got a storage location on the stack that “other functions cannot find”, so no other function could use it as a free variable at all; a variable meant to be read freely had to be declared SPECIAL, and “SPECIAL variables cannot be communicated between the interpreter and compiled functions.”

Joel Moses’s MIT memo “The Function of FUNCTION in LISP” (1970) set out the general problem. “An important point which must be realized about functional arguments (abbreviated FUNARGs) is that two different environments are involved in such cases”: the one where the function was made, which he calls the binding environment, and the one where it is applied, the activation environment. He treats a function passed down to another function as the easy case and a function returned as a value as the hard one, and the memo’s subtitle — Why the FUNARG Problem Should be Called the Environment Problem — gives his reason in a footnote: “One should also handle returns of functions”.

Two rules for a call

Bridger’s rule for application, E-App, evaluates the operator to a closure and runs the body in : the captured environment, extended with the parameters. The caller’s environment is used to evaluate the operator and the arguments, and then set aside. Dynamic scope changes the third premise and nothing else:

The body runs in , the environment of the call site with the parameters added. The operator’s value is a bare lambda, , with no environment beside it, because the rule would have no use for one. Under this rule E-Lam need not capture anything, and a function value is just its code. Landin’s remark that a closure’s environment part “would be unnecessary if λ-expressions containing free variables were prohibited” (closures) has a second case: the environment part is also unnecessary if free variables are looked up where the function is called.

Lexical scope described the alternative as resolving a free name “in the environment present where the code runs”. E-App-Dyn says exactly which environment that is. Each call extends its caller’s environment, which extends its own caller’s, back to main, so the environment a body runs in is a record of the calls that are in progress: the chain of active calls, each adding its parameters and its lets. A free name in a body finds the most recent binding of that name made by any call that has not yet returned.

Scope as a span of time

Under static scope, a binding’s scope is a region of text, as Part III defined it. Under dynamic scope, a binding made by let scale = 2 in main can be read by the body of scaled, which is nowhere inside main’s text, and by any other function that runs while main’s block is active and mentions scale. Where it can be read is no longer a region of text. When it can be read is fixed: from the let until the block that holds it ends. That is the dynamic extent that closures described, and a dynamically scoped binding is visible to any code, written anywhere, that runs during it. Common Lisp the Language builds the name from those two parts — “it is convenient to define dynamic scope to mean indefinite scope and dynamic extent” — and adds: “The term “dynamic scope” is a misnomer. Nevertheless it is both traditional and useful.“

The examples above show what follows from that.

A body’s meaning depends on its callers. Under E-App-Dyn, scaled multiplies by 2 when called from main, and by 10 when called from a function that binds no scale of its own, where the lookup reaches the top-level binding. Without the top-level binding, that second call gets stuck. Nothing in the text of scaled says which. Its behaviour is a property of the whole program and of the path by which control reaches it.

Renaming changes meaning. Under static scope, consistently renaming a bound name — a parameter and its uses — never changes what a program does. That is what the renaming clauses of Algol 60’s call by name were written to protect, and what de Bruijn’s indices build in by having no names to rename. Under E-App-Dyn, renaming twice’s parameter from x to n changed the program’s output from 30 to 12. The name a function gives its parameter, invisible from outside under static scope, becomes something every function it calls can see. Guy Steele and Gerald Sussman’s “The Art of the Interpreter” (1978) builds the same failure from a SCALE function that passes a lambda mentioning L to a MAPCAR whose own variable is named L, and concludes: “That the behavior of the SCALE program depends on the choice of names for its local variables is a violation of referential transparency.” A programmer writing twice has to know the free names of every function that might be passed to it, and a programmer writing add_x has to know the parameter names of every function it might be passed to.

A returned function loses its free names. make_adder under E-App-Dyn:

fn make_adder(k: Int) -> fn(Int) -> Int = |x: Int| x + k;

fn main() {
    let add5 = make_adder(5);
    println(add5(10));                // static: 15; dynamic: UnboundVariable k
}

k is bound for the dynamic extent of the call make_adder(5). When add5(10) runs, that call has returned, and the chain of active calls is main alone, which binds no k. Under dynamic scope a binding’s extent is the activation that made it, exactly as on a stack, and a function cannot carry a binding past it. The closures of the previous chapter, the adder, the counters, the account, have no dynamically scoped version.

A function passed down, to be called while its creator is still running, fares better. add_x’s creator is main, which is still running when twice calls it, so main’s x is in the chain. What goes wrong is that twice’s x is in the chain too, and nearer. The function finds a binding of the right name made by the wrong code.

Bridger’s answer

Bridger is statically scoped. There is no switch and no second mode: E-Lam records the defining environment, E-App runs the body there, and every free name in every function body is resolved the way Part III resolved the names in nested blocks. That was settled when first-class functions gave E-Lam its environment. There is nothing to build in this chapter; the Lambda and Call arms that run the programs of closures already implement it.

The decision is permanent for a reason in Part VI. The type checker built there checks a function body once, before the program runs, against the types of the names in scope where the body is written. A function’s free names are found in its definition’s environment, so their types are found in the definition’s types, and the checker can give scaled the type fn(Int) -> Int by reading it. Take away the top-level scale and give the program two callers:

fn scaled(x: Int) -> Int = x * scale;

fn a() -> Int {
    let scale = 2;
    scaled(4)
}

fn b() -> Int {
    let scale = true;
    scaled(4)
}

Under static scope scale is unbound in scaled. The M4 evaluator reports UnboundVariable at the first call, and Part VI’s checker rejects the program before anything runs. Under dynamic scope the program runs: a() returns 8, and b() gets stuck inside scaled’s body, multiplying an Int by a Bool. To check scaled before running it, a checker would have to know the type of scale in the body, and the body has none of its own: scale is an Int when the call comes through a and a Bool when it comes through b. Checking each body at each call site does not suffice either. scaled can be passed as a value and called from inside a function that never names it, and the chain of calls that reaches it is a fact about one run. Part VI’s checker gives each name one type for its whole scope, and under dynamic scope a free name has one type per path through the program.

The same property served Part III. Resolving names before the run replaced each name with the distance to its binding, computed from the text. Under dynamic scope the binding a free name finds depends on which calls are active, and the distance changes from call to call.

An experiment

The difference between the two rules is one environment in one arm of your evaluator. In a copy of your finished M4 interpreter, change the Call arm so that it extends the environment it was called with, instead of the closure’s captured environment, with the parameters. Then run:

  • scaled from the start of this chapter, and watch 40 become 8;
  • twice(add_x, 10), then rename twice’s parameter, and watch 30 become 12;
  • the adder, counter, and account programs from closures, and find which still run;
  • the two-caller program above, and try to write down a type for scale.

Then change it back. Bridger’s semantics is the captured environment, and nothing later in the book works under the other one.

The design space

Lisp after FUNARG

Gerald Sussman and Guy Steele’s “Scheme: An Interpreter for Extended Lambda Calculus” (1975) made capture the only behaviour: “SCHEME is essentially a full-funarg LISP. LAMBDA expressions need not be QUOTEd, FUNCTIONed, or *FUNCTIONed when passed as arguments or returned as values; they will evaluate to closures of themselves.” The memo lists what follows from closing every lambda in its “lexical” or “static” environment: “First, the axioms of lambda calculus are automatically preserved. Thus, referential transparency is enforced.” Richard Gabriel and Guy Steele’s “The Evolution of Lisp” calls Scheme “one of the first languages to have taken seriously the implications of lexical scoping (as opposed to local scoping, which had been in use in Lisp compilers for almost a decade) and first-class functions.” The local scoping of the compilers is the LISP 1.5 arrangement above: a variable no other function could see, with no closure to carry it anywhere.

Common Lisp (1984) made lexical scope the default and kept dynamic scope for variables declared special, an arrangement described below. Emacs Lisp stayed dynamically scoped longer. Emacs 24.1 (2012) added a variable, lexical-binding, that a file sets on its first line to choose lexical scope; Emacs 30.1 (2025) made the byte compiler warn about a file with no such line, and Emacs 31.1 (2026) warns when one is loaded. A file that says nothing still gets the old rule. The manual’s section on scoping rules puts it this way: “The old dynamic-only Emacs Lisp dialect is still the default in code loaded or evaluated from Lisp files that lack a dialect declaration. Eventually the modern dialect will be made the default.” Changing a language’s scope rule changes the meaning of every existing program that relies on the old one, and the change has been made one declared file at a time.

Implementing a dynamically scoped variable

Under dynamic scope, a variable’s current value is the most recent binding of its name made by an active call. Moses’s memo describes two ways to find it, and they are the two representations of environment representations. The deep access approach, “used in the classical “alist” implementation of LISP“, keeps the bindings in a chain and searches it: “The “deep access” approach forces one to search upward in the stack for the most recent value of the free variable.“ That is the chain of scopes, walked at every lookup. The shallow access approach keeps one cell per name holding its current value, and pays at the boundaries instead: “On entering such a function or block one usually saves the old value which is in the special cell on the stack … On leaving such a function or block one must remember to store the value saved in the stack in the special cell.” That is the flat map with its stack of shadowed bindings per name. LISP 1.5’s SPECIAL variables worked this way, and Moses calls the approach “favored in recent implementations of LISP.”

The costs fall where Part III found them: deep access pays at each lookup, shallow access at each call and return. What has no counterpart is resolving names before the run, which needs the binding to be fixed by the text. And a closure is hard to add to shallow access: a function that outlives its call needs the values its free variables had, and the cells hold only the values they have now.

Call-site resolution on purpose

Some names are meant to be read where code runs. Which output port print writes to, whether a search ignores case, how many digits a number prints with: each is a setting that a caller wants to change for everything it calls, for as long as the call lasts, without passing the setting as an argument through every function in between. The Emacs Lisp Reference Manual gives the case as its example:

(defun search-for-abc ()
  "Search for the string \"abc\", ignoring case differences."
  (let ((case-fold-search t))
    (re-search-forward "abc")))

case-fold-search is read deep inside re-search-forward, which search-for-abc did not write and cannot pass the setting to. The let changes it for the extent of the call, and when the let ends the old setting is back, however the body exited.

Languages that keep this behaviour mostly keep it as a marked exception to lexical scope. Common Lisp’s defvar and defparameter “establish name as a dynamic variable”, and by convention such names are written with asterisks, *print-base*, so a reader can tell them apart. Scheme’s parameter objects, introduced as SRFI 39 (2003) and part of R7RS under the heading “Dynamic bindings” (§4.2.6), are values created with make-parameter and rebound with parameterize; the Racket Guide says a parameterize “adjusts the value of a parameter during the whole time that the parameterize body is evaluated, even for uses of the parameter that are textually outside of the parameterize body”. Clojure’s vars are static unless tagged ^:dynamic. In each, an ordinary name is lexically scoped, and a name is dynamically scoped only when its declaration says so.

Perl kept both under two keywords. my declares a lexical variable; local, in the words of perlsub, “just gives temporary values to global (meaning package) variables. It does not create a local variable. This is known as dynamic scoping.” The Unix shells kept dynamic scope as the only rule for function locals. The Bash Reference Manual states it directly: “The value of a variable that a function sees depends on its value within its caller, if any, whether that caller is the global scope or another shell function.”

Exception handlers are found by the same rule, in languages that would never describe their variables that way. When a Java program throws, the Java Language Specification (§11.3) transfers control “to the nearest dynamically enclosing catch clause, if any, of a try statement that can handle the exception”, where a statement is dynamically enclosed by a catch if it is in the try block “or if the caller of the statement or expression is dynamically enclosed by the catch clause.” A method that throws does not know, and does not need to know, who will catch: the handler is whichever one the chain of active calls has installed most recently. That is E-App-Dyn’s lookup applied to handlers. Bridger has no exceptions; its Result values travel back through each caller as ordinary return values, and every function between the error and its handling says so in its type.

Typing a dynamically bound name

Part VI’s checker cannot give a dynamically scoped free name a type because the function’s type says nothing about it: scaled : fn(Int) -> Int mentions what scaled takes and returns, and the scale it reads from its caller is not among them. A type system can record that too. Jeffrey Lewis, Mark Shields, Erik Meijer, and John Launchbury’s “Implicit parameters: dynamic scoping with static types” (2000) did so for Haskell, and GHC implements it as the ImplicitParams extension. A dynamically bound name is written with a question mark, ?scale, and every function that reads one, or calls a function that does, carries it in its type. The GHC User’s Guide reads a type of the form (?x::t') => t as “this function uses a dynamically-bound variable ?x of type t’”. A caller binds ?scale for the duration of a call, and the checker confirms at every call that the binding is there and has the right type. Under that arrangement the name scaled reads from its caller is part of its interface, written in its type, and renaming it is a change to the interface.

Further reading

The FUNARG problem: McCarthy’s “History of Lisp” (1979) tells the Slagle story in its section on the move from LISP 1 to LISP 1.5 and points the reader to Moses for the rest. Moses’s “The Function of FUNCTION in LISP” (1970) takes up from there: the binding and activation environments, a function passed down against one returned, and the shallow and deep ways of finding a free variable’s value, with the stack and the a-list drawn out.

Dynamic and lexical scoping side by side: Steele and Sussman’s “The Art of the Interpreter” (1978) builds a sequence of small Lisp interpreters, changing one thing at a time. Part One runs the SCALE/MAPCAR example through a dynamically scoped evaluator, then closes each procedure in its environment by changing EVAL and APPLY, with the code for both on the page. Part Two returns to dynamic scoping as a way of decomposing a program’s state.

Scope and extent as separate ideas: chapter 3 of Common Lisp the Language defines lexical and indefinite scope, dynamic and indefinite extent, and names the constructs of Common Lisp that have each combination, including special variables.

Dynamic binding with static types: Lewis, Shields, Meijer, and Launchbury’s “Implicit parameters: dynamic scoping with static types” (2000) gives the type rules for dynamically bound names in Haskell, the design GHC’s ImplicitParams extension implements; the GHC User’s Guide section linked above shows it on a sort function whose comparison comes from its caller.

Concept checks

Under E-App-Dyn, what does twice(add_x, 10) evaluate to, and why does renaming twice's parameter from x to n change the answer?

30. twice calls add_x(10) from inside its own body, so add_x’s body y + x runs in twice’s environment extended with y ↦ 10. That environment binds x twice: main’s x ↦ 1, and, nearer, twice’s parameter x ↦ 10. The nearer one wins, add_x(10) is 20, and the second call gives 30. Once the parameter is n, the only x in the chain of active calls is main’s, and the result is 12, the same as under static scope. Under static scope the parameter’s name is invisible to add_x, so renaming it changes nothing.

A language has functions but allows them to be defined only at the top level of a program, and has no global variables. Can a program tell static scope from dynamic scope?

It can. A top-level function’s free names, under static scope, can refer only to other top-level functions, since there are no global variables. Under dynamic scope the same free name looks first in the chain of active calls, so a function whose body mentions a name f will find a caller’s parameter or local named f before the top-level f. A program in which some caller binds a local with the same name as a top-level function, and then calls a function that refers to that name, behaves differently under the two rules. Removing nested functions narrows the difference to name clashes with top-level names; it does not remove it.

Why does make_adder have no dynamically scoped version, while twice(add_x, 10) at least runs?

Under dynamic scope a binding exists only for the dynamic extent of the block or call that made it, and a body can read only the bindings of calls still in progress. add_x is called while main, which bound its x, is still running, so some x is always available — the problem is only which one. The lambda returned by make_adder is called after make_adder has returned, when its k no longer exists anywhere in the chain, so the lookup fails. Static scope keeps k because the closure holds the environment that binds it.

GHC's implicit parameters give a type to a dynamically bound name. What does a function's type have to say for the checker to accept scaled written that way, and what does that make visible?

The type has to name the dynamically bound variable and its type, as in (?scale :: Int) => Int -> Int. The checker then requires every call of scaled to be made where ?scale is bound to an Int, and a function that calls scaled without binding ?scale itself inherits the requirement in its own type. What was implicit under untyped dynamic scope — which names a function reads from its callers — becomes part of the function’s interface, checked at every call. The program with callers a and b would be rejected at b, where ?scale is a Bool.

Recursion and Higher-Order Functions

The functions of this part so far have called other functions. This one calls itself. fact computes the factorial of a non-negative integer, , with :

fn fact(n: Int) -> Int = if n == 0 { 1 } else { n * fact(n - 1) };

fn main() {
    println(fact(5));                                   // 120
}

The body of fact mentions fact. fact(5) evaluates 5 * fact(4), which needs fact(4), and so on down to fact(0), which is 1 without another call. The same definition written as a lambda inside main does not run:

fn main() {
    let fact = |n: Int| if n == 0 { 1 } else { n * fact(n - 1) };
    println(fact(5));                         // UnboundVariable fact
}

A function that counts its way through a range shows a third behaviour. sum_to(n) adds the integers from 1 to n:

fn sum_to(n: Int) -> Int = if n == 0 { 0 } else { n + sum_to(n - 1) };

fn main() {
    println(sum_to(10000));                             // 50005000
    println(sum_to(1000000));                           // StackOverflow
}

Nothing is wrong with the arithmetic of the second call; the prelude’s fold(range(1, 1000001), 0, |acc: Int, x: Int| acc + x) computes the same sum, 500000500000.

The three programs raise these questions:

  • How does the body of fact find fact? A body runs in the environment its closure captured. For the call fact(n - 1) to work, that environment has to hold the closure that captured it.
  • Why does the lambda in main fail, and can it be repaired? Is self-reference something a language has to provide, or can a program build it from functions it already has?
  • What does each level of a recursion cost? What stops sum_to(1000000), and is there a way to write a recursive function that does not pay it?

The first languages to allow a function to call itself had to answer the first question in their notation and the third in their implementation.

A function that names itself

The program John McCarthy wanted to write in 1958 was a symbolic differentiator, and the language he first tried to write it in, FLPL, was a set of list-processing subroutines for Fortran. In his “History of Lisp” (1979) he recalls that the program “was not implemented that summer, because FLPL allows neither conditional expressions nor recursive use of subroutines.” Differentiating a sum means differentiating each of its terms, and the natural way to write that is a function that calls itself on the parts of an expression. The same history credits the differentiator with Lisp’s maplist, the function that applies a functional argument to each element of a list.

Fortran’s restriction held for a long time. The FORTRAN 77 standard (§15.2) still says: “A subprogram must not reference itself, either directly or indirectly.” Fortran 90 added a keyword, RECURSIVE, that a procedure must carry if it calls itself, so recursion there became something a procedure declares.

McCarthy’s paper “Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I” (1960) wrote functions in Church’s λ-notation, and the notation had a gap. A λ-expression denotes a function without naming it, so a function written that way has no name to call itself by. The paper (§2f) says so directly: “The λ-notation is inadequate for naming functions defined recursively.” Its repair was a second binder, label: stands for the expression , with each occurrence of inside referring to the whole expression. In the history, McCarthy credits the notation to Nathaniel Rochester, and records that it was not strictly needed:

Logical completeness required that the notation used to express functions used as functional arguments be extended to provide for recursive functions, and the LABEL notation was invented by Nathaniel Rochester for that purpose. D.M.R. Park pointed out that LABEL was logically unnecessary since the result could be achieved using only LAMBDA - by a construction analogous to Church’s Y-operator, albeit in a more complicated way.

The implementation needed a place for the variables of each active call. A routine whose locals sit in fixed locations can have one call in progress at a time, the limit the Wheeler jump of first-class functions ran into with its return address. Lisp’s first implementers, McCarthy writes, “decided to use SAVE and UNSAVE routines that use a single contiguous public stack array to save the values of variables and subroutine return addresses in the implementation of recursive subroutines.” Edsger Dijkstra’s “Recursive Programming” (1960) made the same structure the organizing idea of an implementation for Algol 60. A routine that keeps its working storage in fixed places, he wrote, cannot be called “while one or more previous activations of the same subroutine have not yet come to an end, without losing the possibility of finishing them off properly later on.” A stack gives each activation its own storage, and the activation records that closures described are its entries.

Park’s remark is the subject of the next section: a language that has λ already has recursion, if it is willing to let a function be applied to itself.

Recursion as a fixed point

Start from the failing lambda. Its body needs a function to call for the smaller case, and nothing in scope provides one. A parameter can provide one. Give the lambda an extra parameter, me, and call me where the body called fact:

let f = |me, n| if n == 0 { 1 } else { n * me(me, n - 1) };
println(f(f, 5));                                       // 120

f(f, 5) runs the body with me bound to f itself and n to 5. The recursive case calls me(me, 4), which is f(f, 4): the same function, handed itself again, so that it can make the next call in turn. Each call passes the function down to the next. Nothing in the program refers to itself by name; each closure captures main’s environment, which has no f in it while the lambda is being built, and the self-reference travels as an argument. (The parameter is me because self is a reserved word in Bridger, kept for the methods of Part IX.)

The equation fact solves

The extra argument clutters the body. Separate the part that describes one step of factorial from the part that ties it to itself. One step, given some function me to use for the smaller case, is

is not recursive. Handed a function, it returns a new function that handles 0 itself and passes every other case to the one it was handed. Handed a function that computes factorial, it returns a function that also computes factorial: the two agree on every input. Written as an equation,

A value with is a fixed point of , and factorial is a fixed point of the one-step function . Every recursive definition can be read this way: replace each recursive call with a call to a parameter, and the function being defined is the one that the result leaves unchanged.

A fixed-point combinator is a function fix that, given any such , produces a fixed point of it, so that . The one usually written is

Apply it to and take one step, substituting the second for in the first:

The argument handed to on the right is the expression on the left, so unfolds to one step at a time. The function is applied to itself, x x, as f was applied to itself above; is that trick with the one-step function factored out. Haskell Curry and Robert Feys’s Combinatory Logic (1958) calls the paradoxical combinator, and McCarthy’s “Church’s Y-operator” is the same construction.

Fixed points under call by value

Written in Bridger, does not produce factorial:

let y = |g| (|x| g(x(x)))(|x| g(x(x)));
let fact = y(|me| |n| if n == 0 { 1 } else { n * me(n - 1) });
                                                // StackOverflow at x(x)

The reason is the evaluation order that E-App fixes. A call evaluates its arguments before it runs the body, so g(x(x)) evaluates x(x) before g is ever called; x(x) is a call whose body is g(x(x)) again, which evaluates x(x) again. The unfolding that the reduction above performs one step at a time on demand, call by value performs eagerly and without end, and no call of g is ever reached. Under the call by name of Algol 60, the argument would be passed unevaluated, and would work as written.

The repair is to hand g something whose evaluation runs nothing. A lambda is such a thing, by E-Lam. Wrap x(x) in a lambda that calls it only when it is itself called, |v| x(x)(v), and the result is the fixed-point combinator for call by value, often written :

let fix = |g| (|x| g(|v| x(x)(v)))(|x| g(|v| x(x)(v)));
let fact = fix(|me| |n| if n == 0 { 1 } else { n * me(n - 1) });
let fib = fix(|me| |n| if n < 2 { n } else { me(n - 1) + me(n - 2) });
println(fact(5));                                       // 120
println(fib(10));                                       // 55

fix takes the step functions and nothing else. Neither fact nor fib mentions its own name, and fix mentions no function but its argument; the recursion is built out of λ alone, as Park said it could be. It costs something. Each level of fact built this way passes through me, the self-application x(x), and g before it reaches the step’s body, and holds two frames at once where the top-level fact holds one: a sum written with fix runs 49 990 levels deep and fails at 50 010.

The program’s environment is a fixed point

The top-level fact is a fixed point too, of a different function. Appendix D defines the program’s environment by a condition that mentions on both sides:

Each top-level function is a closure whose captured environment is , and holds each of those closures. Read as an equation, is a fixed point of the function that takes an environment and builds a closure over it for every fn in the program. When the body of fact reaches fact(n - 1), E-Var looks fact up in extended with n, finds the closure, and E-App runs its body in extended again. Nothing about the call is special; the environment already contains what the body needs.

The lambda in main has no such environment. E-Let evaluates its right-hand side in the environment before the binding, so E-Lam captures an environment that has no fact, and the binding added afterward is outside the closure’s reach.

Bridger’s recursion

Bridger provides recursion through the program’s environment and nowhere else. A top-level fn may call itself and every other top-level fn, whichever is written first. A let-bound lambda cannot call itself, as the environments chapter decided when it gave E-Let its scope. There is no let rec.

A program that wants a recursive function inside a block has two ways to build one from what the language already has. The first is the fixed-point combinator above. The second ties the knot through the store, using a reference cell: allocate a cell holding a placeholder function, then overwrite it with a function whose body calls whatever the cell holds.

let r = ref (|n: Int| 0);
r := (|n: Int| if n == 0 { 1 } else { n * (deref r)(n - 1) });
println((deref r)(5));                                  // 120

The lambda captures r, which is in scope because the cell was bound first. By the time the body runs, the cell holds the lambda itself, and (deref r)(n - 1) calls it. (A lambda on the right of := needs parentheses.)

Building it

There is no new evaluator arm in this chapter. The Var and Call arms of the last two chapters already run fact, is_even, and the knot through the store, and f(f, 5) and fix are lambdas and calls like any other.

is provided. It is built the way the knot through the store is: the interpreter makes the program’s root scope first, with no functions in it, and then installs each top-level fn as a closure that captures that root.

impl Env {
    /// Install a top-level `fn` as a Bridger closure that captures *this*
    /// root environment.
    pub(crate) fn add_function(&self, name: Name, params: Vec<Name>, body: Expr);
}

The root’s bindings sit behind a RefCell so they can be added after the root is shared. When the last fn is installed, every closure in the root points to the root, which holds every closure: , built by allocating the environment and then filling it in. As closures observed, this is the one reference cycle in the environments, and it lasts as long as the program does.

What a level costs

A call’s body is bracketed by enter_call and leave_call (evaluating function calls), and between them it holds a frame: a count of nested Bridger calls in progress. Every application of a top-level function, a lambda, or a method opens one, and enter_call refuses to open more than 100 000:

pub const MAX_DEPTH: usize = 100_000;

sum_to(1000000) needs a million frames at once. sum_to(n) cannot finish until sum_to(n - 1) has returned a value to add n to, so every level stays open until the bottom is reached. The evaluator reports StackOverflow at the call that would have opened the 100 001st frame.

The limit stands in for one the evaluator cannot otherwise control. Your eval_expr is a recursive Rust function, and a single Bridger call is several nested Rust calls of it, each with a frame on the Rust stack. Running out of that stack aborts the whole interpreter process without a Bridger error or a span. The provided main runs the interpreter on a thread with a 2 GiB stack, and the frame count stops a Bridger program well before that stack is exhausted, with an error that points at the call. The prelude’s map, filter, and fold are native: they iterate over their list in Rust and open no frames of their own, which is why the fold over a million elements in the opening ran. The function handed to them opens one per call, and returns before the next.

Recursive higher-order functions

A higher-order function can be recursive. fold_range folds a function over the integers from lo up to hi, as the prelude’s fold would over range(lo, hi):

fn fold_range(lo: Int, hi: Int, z: Int, f: fn(Int, Int) -> Int) -> Int =
    if lo >= hi { z } else { fold_range(lo + 1, hi, f(z, lo), f) };

fold_range(1, 5, 0, |acc: Int, x: Int| acc + x)         // 10
fold_range(1, 6, 1, |acc: Int, x: Int| acc * x)         // 120

Factorial and the sum are each one fold_range with a different step. fold_range names the recursion once and takes what varies as an argument: the starting value and the function that combines it with each integer. Graham Hutton’s tutorial on fold (1999) describes fold as a “standard operator that encapsulates a simple pattern of recursion for processing lists”. Recursion over a list itself needs a way to take a list apart into its first element and the rest, which is the pattern matching of Part VIII; with it, map, filter, and fold can be written in Bridger in a few lines each.

fold_range differs from sum_to in where its recursive call sits. sum_to’s call is an operand of +, so the caller still has work to do when it returns. fold_range’s call is the result: when it returns, its caller returns the same value and does nothing else. A call in that position is a tail call. Bridger gives tail calls no special treatment. fold_range(0, 1000000, 0, …) would hold a million frames, one per pending call with nothing left to do, and it stops with StackOverflow where fold(range(0, 1000000), …) runs.

The design space

Binding a recursive name

Languages place the recursive binding in different forms. ML and OCaml use let rec, a separate form whose name is in scope in its own right-hand side; Scheme’s letrec does the same and environments mentioned both. In Haskell every let and where binding is recursive, so the failing lambda in main would work there as written; the cost is that let x = x + 1 is a definition of x in terms of itself rather than a reference to an outer x. Rust lets a function item be declared inside a block, where it can call itself, but such an item cannot capture the block’s local variables; a Rust closure can capture them and cannot name itself. Bridger’s arrangement is the same split at a larger grain: the top level provides recursion, the lambda provides capture.

A language can also leave the binding alone and provide the fixed point. The step function above is an ordinary non-recursive function, and a primitive fix with the property gives every recursive definition in terms of one construct. Typed calculi used in the theory of programming languages often take this route, for the reason the next subsection gives.

Recursion and types

Part VI’s checker accepts the top-level fact, the knot through the store, and fold_range. It rejects f(f, 5), , and , each with the same error: cannot construct the infinite type. In me(me, n - 1), me is called with itself as an argument. If me had a type , that type would have to be a function type whose first parameter is : . No type written with finitely many fns satisfies that equation, since substituting the right side into itself never ends. Every self-application has the same problem.

A type system like Part VI’s therefore cannot express recursion with λ alone, and a typed language supplies recursion as a construct of its own: recursive top-level declarations, let rec, or a primitive fix. In Bridger the top-level fn is that construct. The fixed-point combinators of this chapter run on the M4 evaluator, which checks nothing before it runs, and stop running once the checker arrives.

Tail calls

Guy Steele’s MIT memo “Debunking the ‘Expensive Procedure Call’ Myth, or, Procedure Call Implementations Considered Harmful, or, Lambda: The Ultimate GOTO” (1977) argued that a call with nothing left to do after it needs no frame of its own:

In this way, if the last thing a procedure does is call another (external) procedure, that call can be compiled as a GOTO. Such a call is called tail-recursive, because the call appears to be recursive, but is not, since it appears at the tail end of the caller.

The caller’s frame is no longer needed, so the callee can reuse it, and a chain of tail calls runs in the space of one frame. Scheme made this a requirement of the language. The R7RS report (§3.5) states that “Implementations of Scheme are required to be properly tail-recursive”, and gives the consequence in its overview (§1.1): “iteration can be expressed using the ordinary procedure-call mechanics, so that special iteration constructs are useful only as syntactic sugar.” In Scheme, fold_range is a loop.

The 2015 edition of ECMAScript specified the same guarantee for JavaScript’s strict mode (§14.6, “Tail Position Calls”). WebKit’s announcement of its implementation, “ECMAScript 6 Proper Tail Calls in WebKit” (2016), described the requirement — “such a call must reuse the stack space of its own frame instead of pushing another frame onto the call stack” — and its effect on debugging: “the tail deleted function will not show up in a stack trace.” V8’s team reported the same year that proper tail calls had “been implemented but not yet shipped”, and the flags were later removed.

Python’s designer declined it. In “Tail Recursion Elimination” (2009) Guido van Rossum gave the stack trace as the first reason: “when a tail recursion is eliminated, there’s no stack frame left to use to print a traceback when something goes wrong later”, and “Python’s default is and should always be to be maximally helpful for debugging.” Python instead bounds the depth, as Bridger does; its documentation says the limit “prevents infinite recursion from causing an overflow of the C stack and crashing Python.” Rust makes no guarantee either; an explicit form, a become keyword that the programmer writes in place of return to demand a tail call, has been proposed and exists as an experiment in the nightly compiler.

Bridger sides with the stack trace. Every call holds a frame until it returns, and a StackOverflow points at the call that would have gone deeper. Eliminating tail calls in a tree-walking evaluator is a change to the Call arm. A call in tail position would stop short of running its body and pass the closure and its arguments outward, through the control channel that already carries return, to the Call arm whose body it ends. That arm would run its body in a loop: on receiving a tail call, it would bind the new arguments and go around again, without a nested call of eval_expr, so neither the frame count nor the Rust stack would grow. Bridger’s semantics would say nothing different about such a program’s value; only the depth at which it fails would change.

Further reading

Recursion in early Lisp: McCarthy’s “Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I” (1960) defines its functions by conditional expressions and recursion and introduces label in §2f; the author’s retyped copy is at jmc.stanford.edu. His “History of Lisp” (1979) describes the differentiation program, FLPL’s limits, maplist, LABEL, and the stack the first implementation used.

The stack: Dijkstra’s “Recursive Programming” (1960) describes a stack-based implementation of procedure calls, including the treatment of parameters and of procedures that call themselves; a scan of the journal printing is in the CWI repository.

Fixed points: Daniel Friedman and Matthias Felleisen’s The Little Schemer (4th ed., MIT Press, 1996), chapter 9, arrives at the call-by-value by repeatedly removing the name from a recursive length function, in the book’s question-and-answer format.

Fold: Hutton’s “A tutorial on the universality and expressiveness of fold” (Journal of Functional Programming, 1999) uses one property of the right fold over lists to turn recursive functions into folds and to prove facts about them, and shows how much a fold can compute once it may return tuples and functions.

Tail calls: Steele’s “Lambda: The Ultimate GOTO” (1977) memo makes the case for compiling a procedure call as a jump with arguments, with examples in Lisp and in compiled code. Van Rossum’s first post and his follow-up, “Final Words on Tail Calls” (2009), give the case against, and the comments under each argue it.

Concept checks

The lambda let fact = |n: Int| … fact(n - 1) …; in main fails with UnboundVariable. Which rule's premise puts fact out of the body's reach?

E-Let evaluates the right-hand side in the environment that the block had before the binding, and E-Lam captures exactly that environment in the closure. The binding of fact is added afterward, to , which the closure never sees. When the closure is called, E-App runs the body in the captured extended with n, and the lookup of fact fails. A top-level fn avoids this because the environment its closure captures is , which contains the closure.

is_even and is_odd call each other. Does it matter which is written first, and how many frames does is_even(10) hold at its deepest?
fn is_even(n: Int) -> Bool = if n == 0 { true } else { is_odd(n - 1) };
fn is_odd(n: Int) -> Bool = if n == 0 { false } else { is_even(n - 1) };

Order does not matter: both closures capture , which holds both, so each body finds the other whichever was declared first. is_even(10) calls is_odd(9), which calls is_even(8), and so on to is_even(0): eleven calls, all open at once, since each is in tail position but Bridger keeps a frame for every call until it returns. With main’s frame, twelve. The answer is true.

Under call by value, why does |g| (|x| g(x(x)))(|x| g(x(x))) never call g, while the version with |v| x(x)(v) does?

E-App evaluates a call’s argument before it runs the body. In g(x(x)) the argument is x(x), a call whose body is g(x(x)) again, so evaluation enters x(x) without end and g’s body is never reached; the evaluator stops it at the frame limit. In g(|v| x(x)(v)) the argument is a lambda, and by E-Lam evaluating a lambda runs nothing: it yields a closure at once, and g is called with it. The self-application happens only when g’s body calls that closure, which it does only for a case that needs a smaller answer.

The knot through the store is accepted by Part VI's checker, and f(f, 5) is not. Both build a recursive function inside a block. What does the store version have that the self-application lacks?

A type for every name. r has type ref fn(Int) -> Int, the placeholder and the real lambda both have type fn(Int) -> Int, and (deref r)(n - 1) is a call of an fn(Int) -> Int; nothing mentions its own type. In f(f, 5), f’s first parameter must accept f, so f’s type would have to contain itself, , which no finite type satisfies. The store version gets its self-reference from mutation at run time, through a cell whose type is fixed in advance; the self-application gets it by passing a value to itself, which needs a type that contains itself.

Suppose Bridger eliminated tail calls, so that fold_range(0, 1000000, 0, …) ran. What would a programmer lose, and what would a recursive function have to look like to benefit?

The frames of tail calls would no longer exist, so an error raised deep in a tail-recursive loop could not be traced back through the calls that led to it, and a StackOverflow report would no longer catch a runaway tail recursion: a tail-recursive function with no base case would run forever instead of stopping at the limit. Only calls in tail position would benefit. sum_to, whose recursive call is an operand of +, would still hold a frame per level; to run in constant frames it would have to be rewritten with an accumulator, as fold_range is, so that the call is the whole result. Python and Scheme weighed these two consequences and decided in opposite ways.

Programming Functionally

The last chapter wrote fold_range, which names a pattern of recursion once and takes what varies as arguments. This chapter writes whole programs that way. A bank statement is a list of amounts, deposits positive and withdrawals negative. total_withdrawn adds up the withdrawals. Written with the loops and cells of Part IV, it walks the list and accumulates into a cell:

fn total_withdrawn(amounts: [Int]) -> Int {
    ref total = 0;
    for a in amounts {
        if a < 0 { total := deref total + abs(a) }
    };
    deref total
}

total_withdrawn([100, -20, 35, -5, -40])                // 65

The prelude’s abs is the absolute value, filter(xs, p) keeps the elements of xs for which p returns true, map(xs, f) applies f to each element, and fold(xs, z, f) combines the elements from the left starting from z (Appendix C). With them the same function is one expression, with no cell and no loop:

fn total_withdrawn(amounts: [Int]) -> Int =
    fold(map(filter(amounts, |a: Int| a < 0), abs), 0, |t: Int, w: Int| t + w);

Read from the inside out: keep the negative amounts, take the absolute value of each, add them up. Both versions return 65. The second raises these questions:

  • What has become of the loop’s variables? The loop has a cell that changes four times; the pipeline has no name whose value changes. Where did the state go?
  • Where do the stages come from? |a: Int| a < 0 is < with one argument fixed, and t + w is + taken whole. Can a program build such stages from functions it already has, instead of writing a lambda each time?
  • When can one pipeline replace another? The same total can be computed with a single fold that tests and adds in one step. Is there a rule that turns one into the other, and when does it fail?
  • What does the pipeline cost? filter builds a list of withdrawals and map a second list of their sizes, both before fold reads either. The loop builds neither.

A 1977 lecture by one of the designers of Fortran took up these questions, as an argument against the style Fortran had made standard.

The von Neumann style

John Backus led the team that built Fortran. His Turing Award lecture, “Can Programming Be Liberated from the von Neumann Style? A Functional Style and Its Algebra of Programs” (delivered 1977, published 1978), argued that the languages descended from Fortran had inherited the shape of the machine they ran on. A von Neumann computer has a processor and a store joined by a single channel, which Backus called the von Neumann bottleneck, and it computes by sending words through that channel one at a time. A conventional language has the same shape: its variables imitate the machine’s storage cells and its assignments the machine’s fetches and stores. In Backus’s words, “The assignment statement is the von Neumann bottleneck of programming languages and keeps us thinking in word-at-a-time terms in much the same way the computer’s bottleneck does.”

His example was the inner product of two vectors, , written in the style of Algol:

c := 0
for i := 1 step 1 until n do
    c := c + a[i] × b[i]

The loop version of total_withdrawn has the same structure: a cell set to zero, a loop that visits the elements one at a time, and an assignment that adds one more term. Backus listed the properties of such a program, beginning with “Its statements operate on an invisible ‘state’ according to complex rules.” To know what c holds at the end, a reader runs the loop in their head.

His alternative, a language he called FP, builds programs without assignment. A program is a function of one argument, and new programs are made from old ones by a fixed set of functional forms (his abstract also calls them combining forms): operations that take functions and return a function. Among them:

  • Composition: is the function that applies and then .
  • Apply to all: applies to every member of a sequence. It is map.
  • Insert: combines the members of a sequence with . It is a fold, grouped from the right: applied to is .
  • Binary to unary: is defined by , where is FP’s notation for applying to . It turns a function of a pair into a function of one value by fixing the first member of the pair.

The inner product in FP is a single definition, given in the lecture first in full and then abbreviated:

Read right to left: transpose the pair of vectors into a sequence of pairs, multiply each pair, and insert + between the products. The definition mentions no vector, no index, and no accumulator. Backus’s list for this program answers the first one point for point; among its entries, “It does not name its arguments,” and it can be understood “without mentally executing it.”

The second half of the title is what the change buys. An FP program is an expression built from functions by a fixed set of forms, so programs satisfy algebraic laws, and one program can be turned into another by rewriting with them, as an equation is rewritten in school algebra. Backus called this an algebra of programs, and wrote that “it can help one to understand and prove things about programs in a systematic, rather mechanical way.” One of the laws he listed, numbered III.4, is

Applying to every member is the same as applying to every member and then to every member of the result. The same lecture says where such properties come from and what removes them. Of the expressions on the right-hand sides of assignments, Backus wrote that they form “a world that has useful algebraic properties (except that those properties are often destroyed by side effects).”

FP found few users. The forms it gathered were older than the lecture; Lisp had maplist from its first years, as the last chapter recounted. Under these or similar names, map, filter, fold, and composition are part of the standard library of Lisp, ML, Haskell, Python, JavaScript, and Rust, and of Bridger’s prelude.

Programs built from functions

Bridger has no functional forms built in beyond the prelude’s three. A function that takes functions and returns a function is an ordinary closure, so each of Backus’s forms can be written as a Bridger function.

Composition

fn compose(f: fn(Int) -> Int, g: fn(Int) -> Int) -> fn(Int) -> Int =
    |x: Int| f(g(x));

compose(f, g) returns a closure that has captured f and g; calling it with x computes f(g(x)). The types here fix every function to Int, since that is the only kind of function type Bridger can write so far. The definition never looks at the integers, and Part VI gives it a type that says so: compose works for any g whose result f accepts. The same holds for every combinator in this section.

Fixing an argument

Backus’s takes a two-argument function and one argument and returns the function of the other:

fn bu(f: fn(Int, Int) -> Int, x: Int) -> fn(Int) -> Int = |y: Int| f(x, y);

fn add(x: Int, y: Int) -> Int = x + y;

map(range(1, 6), bu(add, 10))                           // [11, 12, 13, 14, 15]

Supplying some of a function’s arguments and getting back a function of the rest is called partial application. The predicate in total_withdrawn is a partial application written out by hand: |a: Int| a < 0 is < with its second argument fixed at 0. A function that returns such predicates makes the fixed value a parameter:

fn below(n: Int) -> fn(Int) -> Bool = |a: Int| a < n;

filter([100, -20, 35, -5, -40], below(0))               // [-20, -5, -40]
filter([100, -20, 35, -5, -40], below(-10))             // [-20, -40]

below(0) and below(-10) are two closures over two different bindings of n, as the adders of closures were.

A two-argument function can also be rewritten once, so that every partial application of it comes for free. curry turns a function of two arguments into a function of one that returns a function of the other, and uncurry turns it back:

fn curry(f: fn(Int, Int) -> Int) -> fn(Int) -> fn(Int) -> Int =
    |x: Int| |y: Int| f(x, y);
fn uncurry(f: fn(Int) -> fn(Int) -> Int) -> fn(Int, Int) -> Int =
    |x: Int, y: Int| f(x)(y);

fn mul(x: Int, y: Int) -> Int = x * y;

map(range(1, 6), curry(mul)(3))                         // [3, 6, 9, 12, 15]
uncurry(curry(add))(2, 3)                               // 5

curry(mul)(3) is mul with its first argument fixed, the same function as bu(mul, 3). The transformation is called currying, from its use in the work of the logician Haskell Curry, although Curry himself attributed it to Moses Schönfinkel’s 1924 paper on combinatory logic, and Gottlob Frege had used it earlier still. Felice Cardone and J. Roger Hindley’s history of the λ-calculus and combinatory logic (2006, §3) gives the sources. In the λ-calculus every function takes one argument, and a function of several arguments is written curried from the start: . ML and Haskell follow the calculus, so that in those languages add 10 is already a function. Bridger’s functions take all their arguments at once, as Rust’s do, and partial application is a lambda or a bu away.

Laws

Each stage of total_withdrawn is a function applied to a whole list, and a stage’s result depends on nothing but its input. That makes it possible to state facts about pipelines that do not depend on what the stages are. Two such facts, for any list xs and any functions of the right types:

The first is Backus’s law III.4 written with map, and is called map fusion: mapping twice is mapping once with the composition. The second says that a map feeding a fold can be absorbed into the fold’s combining function. Both can be checked by following one element through each side: on the left of the first, x becomes g(x) in the intermediate list and then f(g(x)); on the right it becomes compose(f, g)(x), which is f(g(x)).

Laws like these are how one pipeline becomes another. The predicate in filter can be absorbed into a fold the same way, by a combining function that skips the elements it rejects. Applying the second law and then that one to total_withdrawn gives a single pass with no intermediate lists:

fn total_withdrawn(amounts: [Int]) -> Int =
    fold(amounts, 0, |t: Int, a: Int| if a < 0 { t + abs(a) } else { t });

It returns 65, like the other two. The derivation never ran the program; it rewrote one expression into an equal one.

An expression is referentially transparent when it can be replaced by its value, or by any other expression with the same value, without changing what the program does. The laws need the stages to be referentially transparent, because rewriting with them replaces expressions by equal ones and assumes nothing else notices. The term came up already in static and dynamic scope, where renaming a parameter changed a program’s output under dynamic scope; static scope is one of the conditions for it, and the absence of effects is another.

Where the laws fail

A stage that has an effect is not determined by its input, and the laws stop holding. Two stages that print what they are doing:

let g = |x: Int| { print("g"); print(x); print(" "); x * 2 };
let f = |x: Int| { print("f"); print(x); print(" "); x + 1 };

map(map([1, 2, 3], g), f)        // prints g1 g2 g3 f2 f4 f6, returns [3, 5, 7]
map([1, 2, 3], compose(f, g))    // prints g1 f2 g2 f4 g3 f6, returns [3, 5, 7]

The two sides return the same list and print different text. A stage that reads a cell another stage writes changes the value as well:

ref seen = 0;
let g = |x: Int| { seen := deref seen + 1; x };
let f = |x: Int| 10 * x + deref seen;

map(map([1, 2, 3], g), f)        // [13, 23, 33]
map([1, 2, 3], compose(f, g))    // [11, 22, 33], with seen reset to 0 first

On the left, g runs on every element before f runs on any, so f always sees 3; on the right, the two alternate. Map fusion assumes that the order in which stages run does not matter, and effects are exactly what makes it matter.

A call with an effect is not interchangeable with its value either. make_counter from closures returns a function that adds one to a captured cell and returns the new count:

let next = make_counter();
next() + next()                                         // 3

let next = make_counter();
let n = next();
n + n                                                   // 2

Naming the result of a call and using the name twice is the same replacement, and for next it changes the answer.

Bridger’s functional style

Bridger supports the style of this chapter and does not require it. Functions are values, closures capture under static scope, the prelude’s map, filter, and fold take functions, and every combining form above is a few lines of ordinary Bridger. Bindings never change; a list is a value, and map and filter return new lists and leave the lists they are given as they were. Mutation stays available, in the one place Part IV put it: a reference cell, allocated by ref and changed by :=.

Bridger also does not record in a function’s type whether calling it has an effect. g above and |x: Int| x * 2 have the same type, fn(Int) -> Int, and nothing stops the first from being handed to map. The laws of the last section are therefore laws about the pure part of the language, and a programmer applying them checks the side condition by reading the stages. Bridger checks purity mechanically in one place, where its semantics depends on it: the filters of a rule in Part VII, which the evaluator may run any number of times in any order. There a purity judgment works out, from the program itself, which functions can reach an effect.

The state the loop kept in a cell, a pipeline keeps in the accumulator of a fold. A fold whose accumulator is a tuple carries several values at once, so counting the withdrawals and totalling them takes one pass and no cells:

fold([100, -20, 35, -5, -40], (0, 0),
     |acc: (Int, Int), a: Int|
         if a < 0 { (acc.0 + 1, acc.1 + abs(a)) } else { acc })
                                                        // (3, 65)

Each step returns a new pair. The sequence of states is still there, as the sequence of values passed from one call of the combining function to the next, but no name holds more than one of them.

Recursion is the remaining replacement for the loop, and recursion and higher-order functions measured what it costs in Bridger: a frame per level, up to 100 000, with no tail-call elimination. The prelude’s map, filter, and fold are native and open no frames of their own, which is why a pipeline over a million elements runs where a recursive function of a million levels does not.

Building it

Nothing in this chapter adds to the evaluator. Every program above is lambdas, calls, tuples, and prelude functions, all of which the arms of the earlier chapters already evaluate. compose, bu, curry, and uncurry are user code, and the closure each one returns is built by E-Lam like any other. The milestone that closes this part tests the evaluator with programs of exactly this kind.

The design space

Purity

Languages with first-class functions differ in where they put effects, and so in how far the laws of this chapter can be trusted.

Haskell keeps them out of ordinary expressions altogether. As evaluating function calls described, an ordinary Haskell expression cannot perform an effect; a computation that does input or output has a type of its own, IO, and is combined with others through operations on that type. The Haskell 2010 Report (ch. 7) puts it as “The I/O system in Haskell is purely functional, yet has all of the expressive power found in conventional programming languages.” An ordinary Haskell function is therefore referentially transparent, and the laws hold without a side condition. The Glasgow Haskell Compiler puts them to work. Its User’s Guide gives map fusion as its first example of a rewrite rule, which a library states in its source and the compiler applies wherever the left side matches:

{-# RULES
 "map/map" forall f g xs. map f (map g xs) = map (f . g) xs
 #-}

Haskell writes application without parentheses and composition as .; the rule is the law of the previous section.

ML allows effects anywhere. Its bindings are immutable, and the OCaml manual (ch. 1) describes the alternative its standard library provides: “references, which are mutable indirection cells, with operators ! to fetch the current contents of the reference and := to assign the contents.” That is the model Bridger’s ref follows, and a function of type int -> int may print or update a cell as freely as Bridger’s g. ML programs are written mostly in the functional style, with mutation where it is convenient, and judging which laws apply is left to the programmer.

Rust records some of this in types. A Rust closure’s type says what it does to the variables it captured: one that only reads them implements the trait Fn, one that changes them FnMut, and one that consumes them FnOnce, and a function that takes a closure can demand the first. A closure that prints, or writes through a shared RefCell, is still an Fn, so the distinction tracks changes to captured variables, and printing falls outside it.

Bridger takes ML’s position for ordinary code and adds a checked judgment where the language’s own evaluation order is unspecified, in Part VII’s rule filters.

Intermediate lists

The pipeline version of total_withdrawn builds two lists that the program never names: the withdrawals, and their sizes. Bridger’s lists are values built in full as soon as they are computed, so a pipeline of stages over elements allocates up to intermediate lists of up to elements each, where the fused fold allocates none. Fusion by hand, as above, removes them, at the price of a combining function that does several things at once.

Other languages remove them without changing the program. Rust’s iterator adapters filter and map build nothing when they are called; they return an iterator that computes each element when the stage after it asks, so amounts.iter().filter(…).map(…).sum() runs as one loop. The Rust Programming Language states that “In Rust, iterators are lazy, meaning they have no effect until you call methods that consume the iterator to use it up,” and calls iterators “one of Rust’s zero-cost abstractions, by which we mean that using the abstraction imposes no additional runtime overhead.” Haskell’s lists are lazy, so an element of an intermediate list is computed only when something consumes it, and the compiler’s rewrite rules fuse many pipelines into loops before the program runs. Both depend on reordering the work of the stages: an element passes through every stage before the next element enters the first. The printing example above shows that reordering is exactly what an effect can observe. Rust’s iterators reorder anyway and leave the consequences to the programmer; Haskell reorders where nothing can observe it.

Modularity

John Hughes’s “Why Functional Programming Matters” (1989) makes the case for the style on different grounds from Backus’s. Its argument is about modularity. In the version of the paper on Hughes’s own site, “The ways in which one can divide up the original problem depend directly on the ways in which one can glue solutions together,” and functional languages “provide two new, very important kinds of glue”: higher-order functions and lazy evaluation. total_withdrawn as a pipeline is three parts, each of which can be written, tested, and replaced alone: a different predicate, a different measure, a different way of combining. The loop has the same three parts, written into one body and sharing one cell.

The fused fold is the loop again, in the form of a fold. The laws turn the modular version into the efficient one; a compiler that applies them, as GHC does, lets the programmer keep the first and run the second.

Further reading

Backus’s lecture: “Can Programming Be Liberated from the von Neumann Style?” (1978) contains the critique of von Neumann languages; the FP language, its functional forms, and programs for factorial, inner product, and matrix multiplication; the algebra of programs, with its list of laws and proofs that pairs of programs are equivalent; and, in Section 14, applicative state transition systems, a sketch of how the functional style could be made available in what Backus calls a “history-sensitive” system.

Modularity: Hughes’s “Why Functional Programming Matters” (1989), cited in evaluating function calls for its use of laziness, develops the higher-order half of its argument by separating a recursive list sum into a general recursion pattern and the parts specific to summing, then deriving a sequence of list and tree functions from the same pattern.

Laws in a compiler: Simon Peyton Jones, Andrew Tolmach, and Tony Hoare’s “Playing by the rules: rewriting as a practical optimisation technique in GHC” (Haskell Workshop, 2001) describes how a Haskell library states laws such as map fusion and how the compiler applies them.

Concept checks

The pipeline total_withdrawn has no cell. Where are the values the loop's total passed through?

In the arguments of the combining function. fold calls |t: Int, w: Int| t + w once per withdrawal, passing the running total as t and receiving the next one as the result: 0, 20, 25, 65. Each value is bound to t in a separate call’s environment, so no binding ever changes; the loop’s sequence of cell contents has become a sequence of bindings in different calls.

Write flip, which swaps the arguments of a two-argument function, and use it with bu to build the predicate |a: Int| a < 0 from a named function lt.
fn flip(f: fn(Int, Int) -> Bool) -> fn(Int, Int) -> Bool = |x: Int, y: Int| f(y, x);
fn lt(x: Int, y: Int) -> Bool = x < y;
fn bu(f: fn(Int, Int) -> Bool, x: Int) -> fn(Int) -> Bool = |y: Int| f(x, y);

bu(flip(lt), 0)          // |y| lt(y, 0), that is, y < 0

bu fixes the first argument, and 0 is <’s second, so the arguments are swapped first. bu(lt, 0) would be |y| 0 < y, the deposits. The types are written at Bool results here, so this bu is not the Int one in the chapter; Part VI’s polymorphism lets one definition serve both.

Does filter(map(xs, f), p) = map(filter(xs, compose(p, f)), f) hold for pure f and p? Which side calls f fewer times?

It holds. Follow one element x: the left keeps f(x) when p(f(x)) is true; the right keeps x when compose(p, f)(x), which is p(f(x)), is true, and then maps it to f(x). Both keep the same elements in the same order. The left calls f once per element of xs; the right calls it once per element in the test and again for each element kept. The left side calls it fewer times, and the law runs in whichever direction is cheaper.

A teammate fuses map(map(entries, parse), log_and_return) into a single map with compose. log_and_return prints its argument and returns it. What changes?

The returned list does not change; the output does. Before, every entry is parsed before any is logged, so a parse that gets stuck stops the program with nothing logged. After, each entry is logged as soon as it is parsed, so the log shows every entry up to the failing one. Map fusion assumes the order of the stages’ calls is unobservable, and a printing stage observes it. Whether the change is a regression or an improvement is a question about what the log is for; the law alone cannot say.

Haskell keeps effects out of ordinary expressions; ML allows them anywhere; Bridger allows them anywhere and checks purity only in rule filters. What would Bridger gain and lose by adopting Haskell's position?

It would gain the laws everywhere: every pipeline could be fused, reordered, or evaluated lazily with no side condition, by the programmer or by the interpreter. It would lose the programs of Part IV as written. make_counter, the account, every for loop that accumulates into a cell, and every println inside a function would need a type marking the effect and a way of sequencing effectful computations, which is a second language of types and operations to learn before printing a value from inside a function. Bridger’s choice keeps those programs and pays where the language itself needs the guarantee: rule filters, which its own evaluator runs in an order the program does not control.

Milestone M4

Functions, Closures, and Static Scope

Part V made functions values. A lambda evaluates to a closure that holds its parameters, its body, and the environment it was written in; a call runs that body in the captured environment extended with the arguments; and because the body looks names up where the function was defined, Bridger is statically scoped. This milestone puts the two forms into the evaluator.

It is also the first milestone at which a whole Bridger program runs. Execution starts by calling main, and a call is one of the arms you write here, so until now there has been no way to reach a program’s body. New on the provided side are the pieces around that call: the driver that sets up the program’s environment and calls main, the counter that bounds how deeply calls nest, and the prelude’s native primitives, which your calls dispatch to.

To-dos

grep -rn 'todo_m4!' src lists two arms left as holes after M3:

// src/interp/eval.rs — the two M4 arms of eval_expr
Expr::Lambda(..) => todo_m4!("E-Lam"),
Expr::Call(..)   => todo_m4!("E-App (dispatch Bridger vs. Native closure)"),

Lambda is short. Call carries most of this milestone: it is where a closure meets its arguments, where the call depth is counted, and where the return channel you built at M3 is finally caught.

Milestone specification

Each form this milestone adds, the rule that gives it meaning, and the cases that leave it stuck:

FormRuleStuck when
|x, y| e E-Lam —
f(e1, …, en) E-App f is not a function; the argument count differs from the parameter count; calls nest more than 100 000 deep

The rules are in First-class functions and Evaluating function calls; this table is the checklist your evaluator is measured against. A call of a prelude primitive is stuck in the ways the prelude lists for it, and those cases are provided.

Four contracts hold across the two arms:

  • A lambda evaluates nothing. Its body runs only when the closure is called, so a lambda whose body would be stuck is still a value.
  • A call evaluates in a fixed order: the operator first, then the arguments from left to right. An operator that is not a function is stuck before any argument is evaluated.
  • A body runs in the closure’s environment, extended with one binding per parameter. The environment of the caller plays no part; that is what makes the language statically scoped.
  • A return stops at its call. The Control::Return that M3’s return raises is caught by the nearest enclosing call and becomes that call’s value; a Control::Raise passes through calls unchanged.

Implementing Milestone M4

Destructure each arm’s .. into the fields you need:

VariantFieldsCovers
Expr::Lambda(Vec<LambdaParam>, Box<Expr>, Span)parameters, body
Expr::Call(Box<Expr>, Vec<Expr>, Span)operator, arguments, the whole call’s span

The value a lambda produces and the tools a call needs are all provided, and the content chapters show them: the Closure enum and LambdaParam in First-class functions, and Env::extend, the two error variants, enter_call, leave_call, and eval_prelude in Evaluating function calls. Those sections describe each arm step by step, following E-Lam’s and E-App’s premises in order. What follows adds what a test will hold you to.

  • Lambda builds a Closure::Bridger from the parameter names, a copy of the body, and the env the arm was given, and returns it as a Value::Closure. A parameter’s type annotation is for the type checker of Part VI and is dropped here.
  • Call with an operator that is not a Value::Closure is NotAFunction, with found filled in by type_of. With a Closure::Native, it hands the argument values to eval_prelude. With a Closure::Bridger, a count mismatch is ArityMismatch, expected being the parameter count and found the argument count. Both errors carry the span of the whole call.
  • The frame count must balance. enter_call comes before the body and leave_call after it, whatever the body’s outcome. A body that ends in Control::Return or Control::Raise arrives as an Err, and applying ? to the body’s result before calling leave_call would leave on exactly those paths without decrementing the count — and, for Return, without catching it. Take the body’s result as a value, call leave_call, and then decide what the result means.

map, filter, and fold are native, but the function they are handed usually is not. The prelude calls it by evaluating an ordinary call expression through your Call arm, so a pipeline such as map(xs, |x: Int| x * 10) exercises your arm once per element, with the same arity check and the same return handling as a call written in the program.

Running a program

From this milestone on, the interpreter runs programs from a file. cargo run builds the bridger binary and passes it the arguments after --:

$ cat fact.brg
fn fact(n: Int) -> Int = if n == 0 { 1 } else { n * fact(n - 1) };
fn main() { println(fact(5)); }

$ cargo run -q --features m4 -- fact.brg
120

The provided driver, eval_program, does the work between loading the file and running main. It binds every prelude primitive and every top-level fn into the program’s root environment, with each fn a closure over that root — the arrangement that lets top-level functions call themselves and one another, described in Recursion and higher-order functions. It then evaluates the global lets, each one after the globals it reads, and reports a cycle among them as an error. Last, it evaluates the call main() through your Call arm. No static checks run yet; the type checker joins at Milestone M5.

A program that uses closures and a prelude function prints what println wrote:

$ cat adders.brg
fn make_adder(k: Int) -> fn(Int) -> Int = |x: Int| x + k;

fn main() {
    let add5 = make_adder(5);
    println(add5(10));
    println(map([1, 2, 3], make_adder(100)));
}

$ cargo run -q --features m4 -- adders.brg
15
[101, 102, 103]

The program’s input is standard input, read whole when it does not come from a terminal, so a program that reads can be fed with a pipe:

$ cat sum.brg
fn main() {
    let a = read_int();
    let b = read_int();
    println(a + b);
}

$ echo "3 4" | cargo run -q --features m4 -- sum.brg
7

A program that gets stuck stops with a diagnostic: the error’s message, and the source with the expression that got stuck underlined. The exit status is 1, where a program that ran exits with 0. Here a call passes one argument to a function of two:

$ cat arity.brg
fn area(w: Int, h: Int) -> Int = w * h;

fn main() {
    println(area(3));
}

$ cargo run -q --features m4 -- arity.brg
  × takes 2 argument(s), given 1
   ╭─[arity.brg:4:13]
 3 │ fn main() {
 4 │     println(area(3));
   ·             ───┬───
   ·                ╰── here
 5 │ }
   ╰────

And here is the let-bound lambda from recursion and higher-order functions, which cannot see its own name:

$ cat selfref.brg
fn main() {
    let fact = |n: Int| if n == 0 { 1 } else { n * fact(n - 1) };
    println(fact(5));
}

$ cargo run -q --features m4 -- selfref.brg
  × unbound variable `fact`
   ╭─[selfref.brg:2:52]
 1 │ fn main() {
 2 │     let fact = |n: Int| if n == 0 { 1 } else { n * fact(n - 1) };
   ·                                                    ──┬─
   ·                                                      ╰── here
 3 │     println(fact(5));
   ╰────

The rendering is provided; what it shows comes from your arms. The message is the one for the RuntimeError variant your arm returned, and the underline is the span it attached: ArityMismatch from your Call arm with the span of the whole call, and UnboundVariable from the Var arm you wrote at M2, with the span of the name. A wrong span underlines the wrong code, which is why the tests check the span of every error along with its variant.

Worked examples

An expression, and the value it evaluates to:

(|x: Int| x + 1)(4)                         ⇓ 5

let y = 10;
let f = |x: Int| x + y;
f(5)                                        ⇓ 15   (y from the lambda's env)

(|f: fn(Int) -> Int| f(3))(|x: Int| x * 2)  ⇓ 6    (a function as an argument)

(|| { return 7; 9 })()                      ⇓ 7    (the call catches the return)

map([1, 2, 3], |x: Int| x * 10)             ⇓ [10, 20, 30]

Expressions with no value, which halt the run:

(|x: Int, y: Int| 0)(1)        ⇓ stuck  (ArityMismatch: expected 2, found 1)
5()                            ⇓ stuck  (NotAFunction: found Int)

And a whole program that gets stuck partway through, because each level of sum_to holds a frame until the level below it returns:

fn sum_to(n: Int) -> Int = if n == 0 { 0 } else { n + sum_to(n - 1) };
fn main() { println(sum_to(1000000)); }
                               ⇓ stuck  (StackOverflow at the call sum_to(n - 1))

How to know it works

cargo m4 runs the provided milestone M4 tests and, because the milestones are cumulative, the M3, M2, and M1 tests with them. The M4 tests come in two kinds. The first builds an Expr by hand, as the earlier milestones’ tests do, with the new builders lambda and call. The second loads a program from source and runs it through eval_program, checking the value main returns, the text it printed, or the error it got stuck on:

// (|x| x + 1)(4)  ⇓  5
let f = lambda(&["x"], binary(Add, var("x", 1), int(1, 2), 3), 0);
assert_eq!(value_of(&call(f, vec![int(4, 4)], 5)), Value::Int(5));

// a whole program: main's value, and what it printed
let (v, out) = run_program("fn main() -> () { print(42); }");

The whole-program tests reach parts of the driver as well as your arms: globals initialized in dependency order, a cycle among globals reported as an error, the read_* family consuming input. None of those needs code of yours beyond Lambda and Call, but none of them can pass until a call works, since every program starts with one.

Fill the arms, run cargo m4, and repeat until every M4 test — and every M3, M2, and M1 test — passes. Then run a program or two of your own with cargo run.

Seeing the contrast

The milestone asks for static scope only. To see what the other discipline would do, the static and dynamic scope chapter describes an optional experiment on a finished evaluator: run a call’s body in the caller’s environment, watch the closure examples diverge, and change it back.

Design problems

Bridger has no let rec. Suppose you added let rec f = |x| e, in whose right-hand side f is in scope. Why can your Block arm not implement it with Env::extend alone, and what are two ways it could be done?

The closure for |x| e has to capture an environment that already contains f, bound to that same closure. Env::extend returns a new scope containing a value you already have, so either order fails: extend first, and there is no closure yet to bind f to; build the closure first, and the environment it captured has no f, which is exactly why a let-bound lambda cannot call itself (environments). The binding has to be made before its value exists and filled in afterwards. One way is the one the provided driver uses for top-level functions: a scope whose bindings can be added after the scope is shared, so the closure captures the scope and the scope then receives the closure. Env provides that only for the program’s root, through add_function, which the driver calls and your arms do not. The other way needs nothing new from Env: treat let rec f = |x| e as the knot through the store from recursion and higher-order functions — allocate a cell, bind f to it, build the closure, write it into the cell — at the cost of reading f through deref, which a let rec would have to insert wherever f is used.

Suppose a Call arm by mistake extended the caller's environment with the parameters instead of the closure's. Most of the examples on this page still produce the same values. Write a short whole program whose output shows the mistake, and say which property of the program is responsible.
fn make_adder(k: Int) -> fn(Int) -> Int = |x: Int| x + k;

fn main() {
    let add5 = make_adder(5);
    println(add5(1));            // 6; with the caller's env, stuck: no k
}

The lambda’s body mentions k, a name bound only in the call of make_adder, and the lambda is called after that call has returned. With the closure’s environment, k is found where the lambda was written, and the program prints 6. With the caller’s environment, the body looks for k in main, which has no k, and the program is stuck with an unbound variable. A test that catches the mistake needs a function whose body uses a free name bound somewhere other than at the call site. In the examples above, the free names (y in f, the top-level functions) are visible from the caller as well, so both disciplines agree on them. Static and dynamic scope gives a program, twice(add_x, 10), where the two disciplines both produce a value and disagree on it.

Write compose_all, which takes a list of fn(Int) -> Int and returns their composition, using fold and no recursion of your own. What does it return for the empty list, and in which order are the functions applied?
fn id(x: Int) -> Int = x;
fn compose(f: fn(Int) -> Int, g: fn(Int) -> Int) -> fn(Int) -> Int =
    |x: Int| f(g(x));

fn compose_all(fs: [fn(Int) -> Int]) -> fn(Int) -> Int = fold(fs, id, compose);

compose_all([inc, dbl])(5)     // 11
compose_all([dbl, inc])(5)     // 12
compose_all([])(5)             // 5

with inc and dbl adding one and doubling. The accumulator of the fold is a function, starting at id; each step composes the function so far with the next element, so [inc, dbl] becomes compose(compose(id, inc), dbl), which applies dbl first: inc(dbl(5)) is 11. The function written last in the list runs first, as in . The empty list returns id, the function that composing with leaves unchanged, in the same role 0 plays for a sum. fold’s combining function is compose itself, a top-level fn passed by name.

With times(f, n) = fold(range(0, n), id, |acc, i| compose(f, acc)), the expression times(inc, 150000) evaluates to a function without trouble, and calling that function with 0 is a StackOverflow. Explain both halves.
fn times(f: fn(Int) -> Int, n: Int) -> fn(Int) -> Int =
    fold(range(0, n), id, |acc: fn(Int) -> Int, i: Int| compose(f, acc));

let big = times(inc, 150000);  // fine
big(0)                         // StackOverflow inside compose's lambda

Building big is a loop. fold is native and opens no frames of its own; it calls the combining lambda once per element, and each call returns, with a new closure, before the next begins. At no point are two of those calls in progress, so the depth never exceeds a few frames. The result is a chain of 150 000 closures, each holding f and the closure before it in its environment, and building it costs memory but no depth. Calling big(0) walks that chain. The outermost closure computes f(g(x)), so it must call g — the next closure in the chain — and wait for its result before calling f. Every closure in the chain is in progress at once, one frame each, and the count passes the limit of 100 000 well before the end. With n below the limit, as in times(inc, 10)(0), the call returns 10. The closures do what the recursive sum_to did, holding one pending operation per level, although the program that built them contains no recursive function.

Why Types? From Runtime Errors to Static Checking

Every program in Parts II through V has run with nothing between the parser and the evaluator. When an operation receives a value of the wrong kind, the evaluator finds out at that moment and stops with a RuntimeError. describe turns an account balance into a line of text. ++ joins two strings, and the prelude’s to_string converts a value to its text:

fn describe(n: Int) -> String =
    if n >= 0 { "balance " ++ to_string(n) }
    else { "overdrawn by " ++ (0 - n) };

fn main() {
    println(describe(120));
    println(describe(45));
    println(describe(-30));
}

The M4 evaluator prints two lines and then stops:

balance 120
balance 45
  × type error: expected String, found Int
   ╭─[describe.brg:3:12]
 2 │     if n >= 0 { "balance " ++ to_string(n) }
 3 │     else { "overdrawn by " ++ (0 - n) };
   ·            ─────────────┬────────────
   ·                         ╰── here
 4 │
   ╰────

(0 - n) is an Int where ++ needs a String. The mistake is in the else branch, which runs only for a negative balance, so a run that sees only positive balances never reaches it.

A second program has a mistake the evaluator does not report at all:

fn double(n: Int) -> Int = n ++ n;

fn main() {
    println(double("ab"));                              // abab
}

The grammar has required a type on every parameter of a top-level fn since Part I, and double says that n is an Int and that so is the result. Nothing reads those annotations. The evaluator binds n to the string "ab", ++ joins two strings, and the program prints abab.

The two programs raise these questions:

  • Can the mistake in describe be found without running the program? Nothing in the program says which balances it will be given, so a check made ahead of time cannot know which branch will run.
  • What would the annotations on double have to mean for double("ab") to be a mistake the language reports?
  • What does checking ahead of time cost? Which programs that would run without error does such a check turn away, and which runtime errors can it not prevent?

Types entered programming languages to tell a compiler how to store a value and which instructions to apply to it. Using them to find mistakes before a program runs came later.

From storage to checking

The IBM 704 did integer and floating-point arithmetic with different instructions, and its FORTRAN compiler had to know which one each + needed. The FORTRAN Programmer’s Reference Manual (1956) settled it by the spelling of a name: “Two types of variable are also permissible: fixed point (restricted to integral values) and floating point. Fixed point variables are distinguished by the fact that their first character is I, J, K, L, M, or N.” A variable’s type was a fact about how the machine should hold it, read off the first letter of its name.

The Revised Report on Algol 60 (1963) moved the type into a declaration and stated it as a restriction on values. §5.1.3: “Integer declared variables may only assume positive and negative integral values including zero. Boolean declared variables may only assume the values true and false.” Niklaus Wirth’s report on Pascal (1970) put the same idea in the terms this chapter uses: “The data type essentially defines the set of values which may be assumed by that variable.”

Other languages did without types. Dennis Ritchie’s “The Development of the C Language” (1993) describes the two that came before C: “Both languages are typeless, or rather have a single data type, the ‘word,’ or ‘cell,’ a fixed-length bit pattern. Memory in these languages consists of a linear array of such cells, and the meaning of the contents of a cell depends on the operation applied.” BCPL and B ran on word-addressed machines, where the cell was the machine’s word. The PDP-11 addressed individual bytes, which a language whose only value was the word had no way to name, and the floating-point hardware promised for it would need numbers larger than its 16-bit word. Ritchie gives the reasons C acquired types, and what was not among them: “For all these reasons, it seemed that a typing scheme was necessary to cope with characters and byte addressing, and to prepare for the coming floating-point hardware. Other issues, particularly type safety and interface checking, did not seem as important then as they became later.”

In 1973 Tony Hoare argued for the other use. His “Hints on Programming Language Design” (1973), §11, begins from the compiler’s need and arrives at the programmer’s:

The programmer declares the type of each variable, and the compiler can work out the type of each result; it therefore always knows what type of machine code instruction to generate. In cases where there is no meaningful operation (for example, the addition of an integer and a Boolean), the compiler can inform the programmer of his mistake, which is far better than having to chase its curious consequences after the program has run.

The same memo names the property a language has when the mistakes that cannot be caught in advance are still caught when the program runs. Such errors “must be cheaply detectable at run time; in no case can they be allowed to give rise to machine or implementation dependent effects, which are inexplicable in terms of the language itself. This is a criterion to which I give the name ‘security’.”

Robin Milner’s “A Theory of Type Polymorphism in Programming” (1978) gave the type discipline of the language ML. Its checker worked out the types of a program that declared none, and the paper proved what an accepted program was spared. Section 3.7 is titled “Well-Typed Expressions Do Not Go Wrong”, and §3.5 sets up its theorem “that well-typed expressions cannot “go wrong,”” where “wrong” is the result the paper’s semantics gives a meaningless operation, like Hoare’s integer plus Boolean. The guarantee is usually repeated as “well-typed programs cannot go wrong”.

A judgment about types

The rules of Program Semantics relate an expression to the value it produces, . A check made before anything runs needs a relation between an expression and the kind of value it would produce. The typing judgment

reads expression has type . A type names a set of values: Int the integers, Bool the two booleans, String the strings. Appendix D writes the judgment , where the typing context records the type of each variable in scope. The expressions of this chapter have no variables, so is left out here, as Program Semantics left out the environment. It arrives with variables in the next chapter.

Literals have the type of the values they denote:

As in the reference, stands for an arithmetic operator and for an ordering comparison:

Each rule has the shape of the evaluation rule for the same form, with types where the values were. evaluates both operands and requires them to be integers:

requires both operands to have type Int and evaluates nothing. Where concludes with a particular integer, concludes only that the sum will be an integer of some value. The interpreter follows the same division. The evaluator’s addition arm holds two Values and matches each against Value::Int while the program runs; the checker holds the Ty it derived for each operand and compares it with Int before anything runs.

Evaluation and typing have different numbers of rules for if. Evaluation has one rule for each value of the condition, and each mentions only the branch that runs:

Typing has one rule. It does not evaluate the condition, so it cannot know which branch will run, and it requires both branches to have the type the whole if is given:

(The rules write if e1 then e2 else e3 for Bridger’s braced if, as the reference does.)

A typing derivation stacks the rules as an evaluation derivation does. For if 1 < 2 { 3 } else { 4 + 5 }:

The derivation types 4 + 5, a branch that never runs; evaluation reaches 3 by and stops.

A program that runs and has no type

fn main() {
    println(if true { 1 } else { 1 + false });          // 1
}

On the M4 evaluator this prints 1. evaluates the condition and the first branch, and 1 + false is never evaluated. It has no typing derivation. needs a type for every branch, the only rule that can type a + is , and its premise is concluded by no rule.

An expression with no typing derivation is ill-typed. A checker built from these rules rejects it whether or not the ill-typed part would ever run. A program with a derivation is well-typed, and the well-typed programs are a strict subset of the programs that run without a type error: if true { 1 } else { 1 + false } is in the second set and not the first.

Why every checker rejects something

A more careful checker could accept that program by noticing that the condition is the literal true. No checker can accept exactly the programs that never stop on a type error. Take any function p with no type error in it, and the program

fn main() {
    p();
    println(1 + true);
}

The program stops on a type error exactly when p() returns. A checker that accepted precisely the programs that never stop on a type error would accept this one exactly when p() runs forever, and so it would decide, for every p, whether p halts. Building that checker would solve the halting problem: given a program, decide whether it eventually stops. Alan Turing showed in “On Computable Numbers” (1936) that no algorithm solves it for every program. H. G. Rice’s theorem (1953) extends the result from halting to every non-trivial question about the function a program computes.

A checker that always finishes is wrong about some programs, then, and its designer chooses the direction of the error: accept some programs that will get stuck, or reject some that would not. Bridger’s checker makes the second choice, as ML’s does. A program it accepts does not stop on any of the errors its types describe, which Soundness, informally states precisely. if true { 1 } else { 1 + false } is the price.

What the types describe

The stuck cases listed in Values and Runtime Errors fall on two sides of that guarantee. A TypeError, applying a value that is not a function (NotAFunction), a call with the wrong number of arguments (ArityMismatch), a field the value does not have (NoSuchField), == on a function (NotComparable), and an UnboundVariable are each predicted by the type of an expression or by the names in scope, and a well-typed program cannot reach them.

Division by zero is not. asks only that the divisor be an Int, and 0 is an Int, so println(10 / (5 - 5)) is well-typed and stops with division by zero with or without the checker. Nor can types predict the bound on call depth, the bound on the size of a range, or a failed read from input, since each depends on a value or on the world outside the program. Bridger’s prelude moves one such failure inside the types. head returns an Option, so head([]) is the value None, and a program that wants the element must match on the result.

Bridger’s checker

From Milestone M5, Bridger adds the check as a pass between parsing and evaluation. If every declaration of the program type-checks, the evaluator runs the program; otherwise the program does not start. The provided run method of the interpreter is the whole pipeline:

pub fn run(&mut self, src: &str) -> Result<Value, RunError> {
    self.load_program(src)?;
    self.check()?;
    Ok(self.eval_program()?)
}

Built for M4, check has no passes to run and returns at once. Built for M5, it runs the type checker, and describe produces this, and nothing before it:

  × type mismatch: expected String, found Int
   ╭─[describe.brg:3:32]
 2 │     if n >= 0 { "balance " ++ to_string(n) }
 3 │     else { "overdrawn by " ++ (0 - n) };
   ·                                ──┬──
   ·                                  ╰── here
 4 │
   ╰────

The lines balance 120 and balance 45 are not printed, because main never runs. The checker typed the else branch without knowing that the balance would be negative, by . double is rejected the same way, at the first n of n ++ n: the annotation n: Int is now the type the checker gives n inside the body, and the call double("ab") would be rejected too, for passing a String where the annotation says Int.

The checker reports a mismatch in terms the evaluator already used. Side by side, the variant the evaluator raises when an operation receives the wrong kind of value and the one the checker reports when an expression’s type is not the type its position needs:

// interp/error.rs: raised while the program runs
RuntimeError::TypeError { expected: Ty, found: Ty, span: Span }

// types/error.rs: reported before it runs
TyError::Mismatch { expected: Ty, found: Ty, span: Span }

The fields are the same. The evaluator’s found is read off a value it has in hand; the checker’s is derived by the rules from the program text.

The evaluator is unchanged. Values still carry their variant, and every arm that matches a Value::Int still has a branch that raises a TypeError for anything else. For a program the checker has accepted, those branches never run. The checker passes the evaluator no types; it answers yes or no and the evaluator runs the same program it would have run at M4, with the types discarded.

Bridger asks for types where a function meets its callers: each parameter of a top-level fn, and its result. A let and a lambda’s parameters may omit theirs, and the checker infers them by solving for the unknown types from how the values are used, which is part of what the next chapter builds. Writing the checker is Milestone M5. Its method check_expr is to the typing rules what eval_expr is to the evaluation rules: a recursive walk over the same Expr, one arm per rule, whose recursive calls on subexpressions are the premises of the rule. A typing derivation is the tree of those calls.

The design space

When types are checked

Luca Cardelli’s survey “Type Systems” separates two questions: whether a language has a type system that checks programs before they run, and whether it is safe. Safety is about what happens at an error. “It is useful to distinguish between two kinds of execution errors: the ones that cause the computation to stop immediately, and the ones that go unnoticed (for a while) and later cause arbitrary behavior. The former are called trapped errors, whereas the latter are untrapped errors.” A safe language is one in which no program fragment causes an untrapped error, the property Hoare called security. The two questions give four positions, and Cardelli places a language in each:

TypedUntyped
SafeML, JavaLISP
UnsafeCAssembler

The interpreter of Parts II through V sits in the untyped, safe cell: no check precedes the run, and every type-shaped mistake stops the program with an error at the operation that failed. From M5 Bridger is typed and safe. A language can also check types statically and still let a program get past them. In C, a cast or a union lets a program read the bytes of one type as another, and nothing checks the result at run time; the C standard calls what follows undefined behaviour, so the program’s output is not determined by the language at all.

Writing types down

Languages differ in how many types the programmer writes. Pascal and C ask for a type on every variable and every parameter. ML asks for almost none: Milner’s checker infers the type of every expression from how it is used. Bridger asks for them at function boundaries and infers the rest, so a signature documents what a caller may pass, and the types inside a body are worked out from it; Function types and polymorphism describes how far that inference goes.

A language can also record annotations and leave them unchecked. Python’s type hints, introduced by PEP 484 (2014), are kept with the program, but “no type checking happens at runtime. Instead, the proposal assumes the existence of a separate off-line type checker which users can run over their source code voluntarily.” Python’s annotations play the role double’s did before M5: the program runs as written whatever they say, and a separate tool such as mypy reports the mismatches it finds without stopping the program from running. TypeScript checks its annotations when it compiles and then removes them, producing JavaScript whose values carry no static types at all. Dynamic and gradual typing takes up languages that mix checked and unchecked code in one program.

What a type can say

A type system rules out the errors its types can express, and richer types express more. Rust’s compiler rejects a use of a variable after its value has been moved elsewhere, which rules out, before the program runs, reading memory that another part of the program now owns. Some languages let a type say that an integer is nonzero, or that a list is not empty. Division then requires a divisor whose type says it is nonzero, and the programmer writes the reasoning into the program for the checker to verify. Each extension moves another class of runtime error to the checker, and each asks more of the programmer who has to satisfy it.

Bridger’s checker grows within the book. Part VIII extends it to reject a match that does not cover every value of its scrutinee’s type, which moves NonExhaustiveMatch from the evaluator’s errors to the checker’s.

Further reading

Origins: Ritchie’s “The Development of the C Language” (1993) describes BCPL and B, the problems B met on the PDP-11, and how C’s types, pointers, and arrays grew out of them. Hoare’s “Hints on Programming Language Design” (1973) gives his principles of language design, security among them, with sections on types, procedures, and control structures.

Typing rules: Cardelli’s “Type Systems” (2004) sets out the trapped/untrapped distinction and then gives typing rules, in the judgment notation of this chapter, for a sequence of increasingly expressive languages. Benjamin Pierce’s Types and Programming Languages (MIT Press, 2002) types a language of booleans and numbers in chapter 8, with rules close to the ones above, and proves the soundness of that system.

Inference: Milner’s “A Theory of Type Polymorphism in Programming” (1978) gives the inference algorithm for ML and proves that a program it accepts is well-typed and that a well-typed one does not go wrong; the argument is carried in a formal semantics of the language.

Concept checks

Evaluation has two rules for if and typing has one. What would a typing rule have to know about the condition to split into two, and why does the checker not have it?

To split as and do, a typing rule would need the value of the condition, so that it could type only the branch that runs. The checker derives types from the program text before anything runs, and the condition’s value in general depends on inputs, calls, and the store, which exist only at run time. With one rule, the typing of an if holds whichever way the condition turns out, which is what lets the checker say something about every run at once. Requiring both branches to have type is the cost.

Which of these stop the M4 evaluator, and which of those does the M5 checker reject before the program runs: 1 + true, 10 / 0, 5(3), (1, 2).5, if 1 < 2 { "a" } else { 0 }?

All but the if stop the M4 evaluator: 1 + true with a TypeError, 10 / 0 with a division by zero, 5(3) because 5 is not a function, and (1, 2).5 because a pair has no component 5. The if prints a, since its else branch never runs. The checker rejects every one of them except 10 / 0, whose operands are both Int; it rejects the if because needs both branches at one type, and "a" and 0 have none in common. So the checker rejects one program the evaluator would have run to completion and lets through one the evaluator stops.

On describe, the evaluator's error underlines the whole "overdrawn by " ++ (0 - n) and the checker's underlines only (0 - n). Why does each point where it does?

The evaluator reports the operation that could not proceed. It evaluated both operands, then the ++ arm found a string and an integer and had no rule to apply, so the error carries the span of the ++ expression. The checker reports the premise that failed. needs , and the right operand has type Int, so the checker blames that operand. The evaluator sees values when an operation is attempted; the checker sees which subexpression fails to have the type its position needs.

A programmer argues that a checker rejecting if true { 1 } else { 1 + false } is simply wrong and should be fixed to accept it. Could it be? What happens if the fix is pushed as far as it will go?

That particular program could be accepted: a checker could notice the literal true and type only the first branch. Each such refinement makes the checker accept more programs that would run, but none reaches all of them. A checker that accepted exactly the programs that never stop on a type error would decide whether an arbitrary function halts, by the p(); println(1 + true) construction, and no algorithm does that. Every checker that always finishes therefore rejects some program that would have run, or accepts some program that will get stuck. The refinements also make the rules harder to state and to predict: a programmer reading the rule can tell what it will accept, which is harder to say of a checker that tries to evaluate parts of the program.

Python records annotations and its interpreter does not check them; Bridger from M5 refuses to run a program whose annotations are contradicted. What does each choice give the programmer, and what does it take away?

Python’s choice lets every program run, so a program can be tried, partly written or partly wrong, and checked by a separate tool when the programmer chooses. Its annotations can drift from the code without anything stopping the program, and the errors the tool would have reported reach the user at run time instead, as double’s mistake did on the M4 evaluator. Bridger’s choice guarantees that every program that runs agrees with its annotations and cannot stop on a type error, so an annotation is something a reader can rely on. In exchange, a program has to type-check everywhere before any part of it runs, including branches that would never run, like the else in if true { 1 } else { 1 + false }.

A Type Checker

The previous chapter typed expressions built from literals, operators, and if. A program also names things, and its functions take parameters whose types it may not write down. This one prints total: 80. price and label are bound by let; double is a lambda whose parameter n carries no type annotation; to_string is the prelude’s conversion of a value to its text:

fn main() {
    let price = 40;
    let label = "total: ";
    let double = |n| n * 2;
    println(label ++ to_string(double(price)));
}

Two shorter programs are rejected before they run, each with the same message:

fn main() {
    let xs = [];
    println(len(xs));     // the type of this expression cannot be determined
}

fn main() {
    let id = |x| x;
    println(1);           // the type of this expression cannot be determined
}

The programs raise these questions:

  • What type does price have where it is used? The rules so far type a literal and an operator. price is a name, and its type was settled two lines earlier.
  • What type does n have? Nothing annotates it. The body n * 2 says something about it, and the call double(price) says what is passed to it, but the lambda is checked before the call is reached.
  • When does a program not say enough? [] is a list of something, and |x| x returns whatever it is given. What would a checker need to know to give either one a type, and where would it look?

The rules of Appendix D answer the first question directly. The second and third are about how a program computes a type that no rule hands it, and the method came from automated theorem proving.

Solving for types

J. A. Robinson’s “A Machine-Oriented Logic Based on the Resolution Principle” (1965) is a method for proving theorems by machine, and at its centre is a procedure for making two logical expressions identical by substituting for their variables. §5.7 defines the idea: “θ is said to unify A, or to be a unifier of A, if Aθ is a singleton”, that is, if applying the substitution θ to every expression in the set A leaves only one expression. §5.8 gives “the Unification Algorithm”. It finds the first position at which the expressions disagree and, in its third step, substitutes for a variable there, with a condition: “If Vk is a variable, and does not occur in Uk, set σk+1 = σk{Uk/Vk}, add 1 to k, and return to step 2. Otherwise, terminate.” The clause “and does not occur in Uk” is what is now called the occurs check.

Robin Milner’s “A Theory of Type Polymorphism in Programming” (1978), whose guarantee the previous chapter quoted, brought the procedure to types. §4.1: “𝒲 is based on the unification algorithm of Robinson [14].” Milner’s algorithm 𝒲 gave each unknown type a variable and solved the equations that the program’s operations imposed on them. He credited the idea to the logician Roger Hindley, who had derived types for terms of combinatory logic: “Hindley appears to have been the first to notice that the Unification Algorithm of Robinson [14] is appropriate to this problem.”

Benjamin Pierce and David Turner’s “Local Type Inference” (2000) asked how much inference programmers need. Their answer was that “it is acceptable to require annotations on the bound variables of top-level function definitions (since these usually provide useful documentation)”, and they recovered the remaining types by passing them between neighbouring nodes of the syntax tree, in two modes, “synthesis mode, where typing information is propagated upward from subexpressions, and checking mode, where information is propagated downward from enclosing expressions.” Their inference was local by design, working “without long-distance constraints such as unification variables”. They did not claim the modes as their invention: “The basic idea of bidirectional checking is well known as folklore.”

Bridger’s checker draws on both. It has Pierce and Turner’s two modes and their annotated top-level signatures, and within a declaration it has Milner’s inference variables, solved by Robinson’s procedure.

Typing in a context

A variable’s type depends on where it appears, as its value did. The evaluator answered “what is x?” while the program ran, with the environment , which maps each name in scope to a value. The checker answers it before the program runs, with a typing context , which maps each name in scope to a type. The judgment is now the one Appendix D writes,

read in context , expression has type . The rules of the previous chapter carry through unchanged; a literal’s type does not depend on it. A variable has the type its binding gave it, and a let extends the context for the rest of its block, as extended the environment:

is with x bound to , shadowing any earlier x, so a block that rebinds x as a String gives the inner x that type and leaves the outer one an Int outside the block. In the opening program binds price : Int and label : String, and reads them back at their uses.

The checker builds from the program text, before anything runs, and it can do so because Bridger’s scope is lexical: the binding a name refers to is fixed by where the name is written. As Static vs. Dynamic Scope observed, a dynamically scoped name would get its binding from whichever caller was running, and no context built from the text could say what type it had.

also carries the enclosing function’s return type, , so that a return can be checked against it. Because control leaves at a return, the expression itself produces no value where it stands, and the rule lets it have any type at all; that is what lets return 0 sit in one branch of an if whose other branch is a String:

The reference cells and loops of Part IV have rules of the same kind: ref e has type when e has type , deref undoes it, an assignment and a loop have type (), and a for binds its variable at the list’s element type for its body. The reference gives each.

Function types

A function value has a type that records what it takes and what it gives back. A lambda is typed by typing its body in a context extended with its parameters, and a call is typed by matching its arguments against the parameters of its callee’s type:

(The rules write for Bridger’s |x1, …, xn| e, as the reference does, and the reference’s also sets to for a return inside the lambda.) The opening program’s double gets , and then accepts double(price) because price : Int.

The types in this chapter are monomorphic: each function type has particular types in it, and a lambda has one of them. The types a function written once can take at many types, and the top-level declarations whose signatures make them possible, are the subject of the next chapter.

From rules to an algorithm

does not say where comes from. Read as a rule, it says that if some makes the body well-typed, the lambda has a function type with in it, and it is silent about how to find one. For |n| n * 2 the right choice is Int, but the rule only confirms the choice once it has been made. A checker is a program and cannot guess. It has two ways to proceed without guessing: take the type from the surrounding context when the context has one, or give the unknown type a name and solve for it from what the rest of the declaration requires.

Checking and inferring

Some positions in a program already know what type they need. The body of fn f(c: Bool) -> Int needs an Int; the argument of a call to a function whose parameter is an Int needs an Int; the else branch of an if needs the type the then branch had. Other positions know nothing in advance: the right-hand side of an unannotated let, or a lambda whose type is about to be discovered.

A bidirectional checker splits the judgment into two modes, one for each kind of position. reads synthesizes : the checker works out a type from alone. reads checks against : the type is given, and the checker confirms that has it. A variable synthesizes the type gives it. A call synthesizes its type from its callee and checks each argument against the parameter type it is passed to. A lambda checks against a function type, taking its parameter types from it:

In the parameter types are inputs, read off the function type the lambda is checked against, and nothing is guessed. One more rule connects the modes. An expression that synthesizes a type checks against any type equal to it:

Bridger’s apply shows a lambda checked against a type its context supplies:

fn apply(f: fn(Int) -> Int, x: Int) -> Int = f(x);

fn main() {
    println(apply(|n| n + 1, 5));                       // 6
}

checks the argument |n| n + 1 against apply’s first parameter type, , and gives n the type Int from it.

Inference variables

The modes do not cover every case. let double = |n| n * 2; has no type for its right-hand side to check against, so the lambda must synthesize, and synthesis needs a type for n before the body can be typed. A checker that cannot guess n’s type can name it. It gives n a fresh inference variable , a placeholder that stands for a type not yet known, and carries on. Typing the body then produces requirements on :

  1. n * 2 is an arithmetic operation, so each operand is checked against Int.
  2. n synthesizes , and requires .
  3. The requirement is met by solving as Int. From then on, wherever appears, it means Int.
  4. The body synthesizes Int, so the lambda synthesizes , which is .

double("a") is then rejected at "a", with expected Int, found String: by the time the call is checked, has been solved, and checks the argument against Int.

The equation in the second step is what ’s premise becomes once types can contain variables. Satisfying it, by choosing types for the variables that make the two sides equal, is unification. It compares two types structurally:

  • Two base types unify if they are the same type: Int with Int, Bool with Bool.
  • Two types built by the same constructor unify if their parts do, pairwise: two lists if their element types unify; two function types if they take as many parameters, each parameter type unifies with the corresponding one, and the result types unify; tuples and reference types the same way.
  • A variable already solved unifies as its solution would.
  • An unsolved variable unifies with a type by being solved as .
  • Anything else, such as Int with String or a list with a function type, is a mismatch.

The solution unification produces assigns only what the requirements force. Solving as Int in double follows from n * 2; nothing else would make the body well-typed, and nothing more is assumed.

let xs = []; gives the empty list the type with a fresh , since no element says what it holds. In len(xs ++ [1]) the concatenation unifies with and solves as Int. In len(xs) nothing does, and when the checker finishes the declaration is still unsolved. The type of [] is then not determined by the program, and Bridger rejects it, asking for an annotation such as let xs: [Int] = [];. let id = |x| x; with no use is the same case: its type is , and nothing fixes .

The occurs check

Recursion and higher-order functions promised that this checker would reject self-application with the error cannot construct the infinite type. The smallest case is

fn main() {
    let w = |x| x(x);
    println(1);
}

x gets a fresh variable , and the body’s result a fresh . The body calls x, so must be a function type. The call passes x itself as the argument, so that function type’s parameter is , and unification is asked to solve

Solving as would make every inside that type stand for again, and spelling the solution out would substitute without end: no type written with finitely many fns satisfies the equation. Unification therefore adds one condition to its rule for an unsolved variable. Before solving as , it checks that does not occur in , looking through any variables already solved, and reports an infinite type if it does. This is the occurs check. The M4 evaluator runs the program, since w is never called; the checker rejects it, at x(x).

Bridger’s checker

Bridger’s checker is bidirectional, with inference variables for what neither mode supplies, and the inference it does stays inside one declaration. A function body is checked against the signature its declaration writes, a lambda’s parameter types come from an annotation, from the type it is checked against, or from a fresh variable, and every variable a declaration introduces must be solved by the time the declaration has been checked. No inference variable outlives its declaration, so a type left open in one declaration cannot be fixed by a use in another. The next chapter takes up what the signatures at those boundaries can say.

The checker leaves the program’s AST as the parser produced it. The type of each expression goes into a separate table, the TypeMap, keyed by the expression’s span; the determination rule reads it back, tests inspect it, and the evaluator never sees it.

Building it

The checker’s arms are the typing rules, one per form of Expr, as the evaluator’s arms were the evaluation rules. The provided method you fill in is

pub fn check_expr(
    &mut self,
    e: &Expr,
    ctx: &Ctx,
    expected: Option<&Ty>,
) -> Result<Ty, TyError>

and its expected argument is the two modes in one parameter. With Some(ty) the arm checks e against ty; with None it synthesizes. It returns e’s type either way, and records it at e’s span. An arm with no use for an expected type synthesizes one and then unifies it with the expected type, which is . An arm that has a use for it passes it on: a lambda reads its parameter types from an expected function type, and an if checks its else branch against the type its then branch has.

Ctx is , and it is built like the environment of Part III: immutable, with extend returning a new context and leaving the old one as it was.

impl Ctx {
    /// The type bound to `x`, searching outward through enclosing scopes.
    pub fn lookup(&self, x: &str) -> Option<Ty>;
    /// Bind `x : ty` in a fresh child scope and return it.
    pub fn extend(&self, x: Name, ty: Ty) -> Ctx;
    /// A lambda body: bindings stay visible, `return` targets `ret`
    /// (`self_ty` is for methods, Part IX).
    pub fn enter(&self, ret: Option<Ty>, self_ty: Option<Ty>) -> Ctx;
    /// The enclosing function's return type, if inside one.
    pub fn ret(&self) -> Option<Ty>;
}

The inference variables are provided too. fresh_meta makes one, solve records a solution, resolve replaces every solved variable in a type by its solution, and record puts a type in the TypeMap:

impl Checker {
    pub fn fresh_meta(&mut self) -> Ty;
    pub fn solve(&mut self, id: u32, ty: Ty);
    pub fn resolve(&self, ty: &Ty) -> Ty;
    pub fn record(&mut self, span: Span, ty: Ty);
}

The arms, and unify with its occurs check, are yours. In the starter each is a milestone hole naming the rules it implements:

// M5: one arm per typing rule, over the M1–M4 expressions
Expr::Var(..)    => todo_m5!("T-Var / T-Self"),
Expr::Block(..)  => todo_m5!("T-Block / T-Let / T-Seq"),
Expr::If(..)     => todo_m5!("T-If"),
// … and the rest, T-Lam and T-App among them

pub fn unify(&mut self, a: &Ty, b: &Ty, span: Span) -> Result<(), TyError> {
    todo_m5!(
        "unify (structural agreement; solves TyKind::Meta with an occurs check)"
    )
}

Where a rule needs two types to agree, the arm calls unify rather than comparing them with ==, since either may hold a variable that the comparison has to solve.

The provided driver walks the program’s declarations and calls check_expr on each body: a fn against its declared return type, in a context binding its parameters. After each declaration it asks that every type recorded for it be fully determined, which is where let xs = []; is rejected. Writing the arms and unify is Milestone M5.

Where errors are reported

The order in which a bidirectional checker visits a declaration decides where a mismatch is reported. In

fn f(c: Bool) -> Int = if c { 1 } else { "one" };

the body is checked against Int, the if passes the type on to its branches, and the error points at "one": expected Int, found String. With apply from above,

println(apply(|n| n ++ "!", 5));

is rejected at n, with expected String, found Int. gave n the type Int from apply’s parameter, and ++ then needed a String. A checker that synthesized the lambda’s type first would have found and reported the mismatch at the whole lambda, against apply’s parameter type.

The design space

Where the types go

Bridger’s checker answers yes or no and leaves its types in a side table. A checker can keep less: one that only validates computes each type, uses it to check the parent, and discards it, which is enough to accept or reject but gives later passes nothing to read. It can also keep more. It can write each type into the AST node it belongs to, so that a later pass reads a node’s type from the node, at the cost of an AST whose shape changes between the parser and the checker. Or it can elaborate the program into a separate typed representation, a new tree in which every node carries its type and constructs the types have resolved are made explicit. Compilers take the last route, and generate code from the typed representation; the types are discarded when the machine code is produced, which is why a compiled program carries no types at run time. Bridger’s evaluator needs nothing from the checker in this part, and the side table keeps the AST the evaluator walks the same before and after the checker arrives. Part IX is where the checker first hands the evaluator something it decided, a method chosen by type.

How much is inferred

Languages place the line between written and inferred types differently. Pascal and the C of the 1970s inferred nothing beyond the type of an operator’s result: every variable carried a declared type. ML infers the types of whole programs, top-level functions included. Bridger, like Rust and Pierce and Turner’s proposal, asks for types on top-level signatures and infers the types within a body. The written signatures are documentation a reader can rely on, and they keep each declaration’s inference small and its errors close to their cause; the cost is annotations that a whole-program inference would not need. The next chapter describes what Bridger’s choice leaves out.

Unification elsewhere

Unification is not specific to types. It solves equations between any terms built from constructors and variables, and Part VII uses it again, on the facts and rules of Bridger’s relations, where the variables stand for values instead of types.

Further reading

Bidirectional checking: David Christiansen’s “Bidirectional Typing Rules: A Tutorial” (2013) splits the typing rules of a small typed λ-calculus into checking and synthesis judgments and turns each rule into a case of a recursive function, which is the shape of check_expr. Jana Dunfield and Neel Krishnaswami’s survey “Bidirectional Typing” (2021) covers the design choices bidirectional systems make, for many type systems, and ends with historical notes on where the idea came from. Pierce and Turner’s “Local Type Inference” (2000) develops the two modes for a language with polymorphism and subtyping.

Unification: Robinson’s paper (1965) states the algorithm and proves that it finds a most general unifier, in the vocabulary of first-order logic. Pierce’s Types and Programming Languages (MIT Press, 2002) treats type inference by unification in chapter 22, where a program’s typing constraints are first collected and then solved.

Concept checks

The evaluator looks a variable up in ρ and the checker looks it up in Γ. Why can the checker build Γ before the program runs, and what in Bridger's design does it depend on?

follows the text: each let, parameter, and for adds a binding over a region of the program that is fixed by where it is written, and a variable’s binding is the nearest enclosing one. That is lexical scope, which Bridger has had since Part III. Under dynamic scope, a free variable in a function body would be bound by whichever caller was running when the body ran, and different callers could bind it at different types, so no context built from the function’s text would know its type. A checker for that discipline would have to record the dynamically bound names in the function’s type, as Haskell’s implicit parameters do.

Trace the inference variables in let xs = []; println(len(xs ++ [1]));. Why does the same program with len(xs) in place of len(xs ++ [1]) need an annotation?

[] has no element to type, so it gets for a fresh , and binds xs at . In xs ++ [1] the left operand synthesizes and the right ; list concatenation needs both at one type, so unification solves as Int. With len(xs), len accepts a list of any element type, so nothing constrains , and at the end of the declaration it is unsolved. Bridger requires every expression’s type to be determined, so the program is rejected and asks for let xs: [Int] = [];, or any element type.

Does let twice = |f| f(f(1)); trigger the occurs check? Work out the type of twice.

No. f gets . The inner call f(1) requires , which is solved. The outer call f(f(1)) checks its argument, of type , against f’s parameter type Int, solving as Int, and gives the body the type , which is Int. twice has type . f is applied to the result of applying f, but never to f itself, so no variable is ever asked to equal a type that contains it.

apply(|n| n ++ "!", 5) is reported at n. Where would a checker that always synthesized a lambda's type first report it, and which report is more useful?

Synthesizing first, n gets a fresh variable, n ++ "!" solves it as String, and the lambda synthesizes . Only then is it compared with apply’s parameter type, , and the mismatch is reported at the whole lambda, as two function types that differ. Checking first gives n the type Int from the context and reports the first place the body disagrees with it. Which is more useful depends on where the mistake is: if the lambda is wrong, pointing into it helps, and if the wrong function was passed, the whole-lambda report says so more directly.

Bridger's checker records types in a side table and the evaluator ignores them. Name something an evaluator could do with the types if it received them, and what Bridger would have to change to give them to it.

An evaluator that knew every addition’s operands were integers could skip the check that each operand is a Value::Int and add them directly; one that knew a call’s callee was a function could skip the check that it is a closure. More generally, a typed representation lets an implementation choose representations by type, storing an Int without a tag, which is what compilers do. To get there Bridger would have to pass the evaluator the TypeMap, or elaborate the program into a typed tree the evaluator walks instead of the AST, and the evaluator’s arms would change to rely on the types. As it stands, the evaluator keeps its checks, which never fire for a program the checker accepted, and one evaluator serves both the untyped milestones and the typed ones.

Function Types and Polymorphism

In the previous chapter, Bridger’s checker gave every function one fixed type. It rejects this program, which would run without error. id is a lambda that returns whatever it is given, and main calls it twice:

fn main() {
    let id = |x| x;
    println(id(1));
    println(id("one"));    // type mismatch: expected Int, found String
}

If both calls passed integers, as id(1) and id(2), the program would type-check and print 1 and 2. Part V’s compose, written at Int because Int was the only type its signature could name, has the same limit:

fn compose(f: fn(Int) -> Int, g: fn(Int) -> Int) -> fn(Int) -> Int =
    |x: Int| f(g(x));

fn main() {
    let len_plus = compose(|n| n + 1, |s: String| len(s));
    println(len_plus("four"));     // type mismatch: expected Int, found String
}

len is the prelude function that returns the length of a string or list. Neither id nor compose looks at the values it passes along, and each would run without error on the arguments the checker rejects.

The programs raise these questions:

  • What type does id have? describes the first call and the second, but the lambda is checked only once.
  • What would a signature for compose have to say? It works whenever g’s result is what f accepts, whatever the three types involved are.
  • Where can a type differ from one use to the next? A checker that let every name take a new type at every use would accept id, but it would also accept a reference cell read at a type other than the one written into it.

Bridger answers all three with its function signatures, drawing on work on the types of polymorphic functions that began in the 1960s.

Many types for one function

Christopher Strachey’s lecture notes “Fundamental Concepts in Programming Languages”, given in Copenhagen in 1967 and published in 2000, sorted functions that accept arguments of more than one type into kinds: “There seem to be two main classes, which can be called ad hoc polymorphism and parametric polymorphism.” An ad hoc polymorphic function, such as an arithmetic operator, behaves differently at each type it accepts, and no one rule relates the type of its result to the types of its arguments. A parametric one behaves the same way at every type. Strachey’s example was a function Map that applies a function f to each member of a list, whose type “could be written (α ⇒ β, α list) ⇒ β list where α and β stand for any types.” He left the problem of designing a language with such functions to others.

Languages took it up from two directions. In John Reynolds’s “Towards a Theory of Type Structure” (1974), types are passed to functions as parameters: the polymorphic identity function takes a type t and returns the identity function on t, and a program applies it to a type explicitly before applying the result to a value. CLU, described by Barbara Liskov and colleagues in “Abstraction Mechanisms in CLU” (1977), let a data abstraction take a type parameter. Its sorted_bag took the type of the items it collected as a parameter, and each use named that type, as in sorted_bag[string].

Robin Milner’s 1978 paper, whose unification algorithm the previous chapter described, took the other direction and wrote no types at all. Its first example is map, whose type Milner calls its “generic type”. Each occurrence of map gets its own copy of that type, with types substituted for and , so in map(sqroot, map(length, tokl)) the two occurrences are used at two different types. The rule stops at function parameters: “we do not allow different bound occurrences of a λ-bound variable to have different type.” Luis Damas and Milner’s “Principal Type-Schemes for Functional Programs” (1982) proved that the type the algorithm finds for a declaration is its most general one: every other type the declaration could be given is an instance of it.

Bridger’s generics combine the two directions. Its signatures name their type parameters, as Reynolds’s and CLU’s did, and its calls supply them silently, by Milner’s instances.

Signatures and the top-level context

A Bridger program is a set of declarations, and every fn declares its full type: each parameter carries an annotation, and the result type follows -> (an omitted result type is ()). The grammar enforces it. fn f(x) -> Int = 1; is a parse error at the ), where the parser expects a : and a type.

Because each signature is written, the checker knows every function’s type before it has checked any body. It builds the top-level context from the signatures alone, and then checks each body in extended with that function’s parameters. A body may therefore call any function in the program, itself included, wherever in the file that function is declared:

fn is_even(n: Int) -> Bool = if n == 0 { true } else { is_odd(n - 1) };

fn main() {
    println(is_even(10));                               // true
}

fn is_odd(n: Int) -> Bool = if n == 0 { false } else { is_even(n - 1) };

is the static counterpart of the program’s environment , which holds every top-level function as a closure capturing itself. needs no such knot: it holds types, and a type does not refer back to the context it is found in.

The signatures also bound each declaration’s inference. The inference variables of the previous chapter are made and solved while a single body is checked, and all of them must be solved before the checker moves on. A use of is_odd inside is_even reads the written type from and never waits on is_odd’s body, so the bodies can be checked in any order, and a mistake inside one is reported against its own signature.

A global let is added to at its type annotation, or, if it has none, at the type its initializer synthesizes. The determination rule applies to it as to any declaration. let g = |x| x; at the top level is rejected at the lambda: nothing in the declaration fixes the type of x, and a call to g in main cannot fix it, because it belongs to a different declaration.

Type parameters

A signature can name types it does not fix. A declaration may list type parameters in angle brackets after its name, and use them in its parameter and result types:

fn id<T>(x: T) -> T = x;

fn main() {
    println(id(1));                                     // 1
    println(id("one"));                                 // one
}

id’s signature says that for any type T, id takes a T and returns a T. Its type is a type scheme, written

and read for all T, a function from T to T. The fn types of the previous chapter are schemes with nothing quantified. A function whose type is a scheme is polymorphic, and since the one definition works the same way at every type, this kind of polymorphism is called parametric.

Part V’s compose gets the type it was promised:

fn compose<A, B, C>(f: fn(B) -> C, g: fn(A) -> B) -> fn(A) -> C =
    |x| f(g(x));

fn main() {
    let len_plus = compose(|n| n + 1, |s: String| len(s));
    println(len_plus("four"));                          // 5
    let shout = compose(|s| s ++ "!", |n: Int| to_string(n));
    println(shout(3));                                  // 3!
}

The signature says what the definition requires: g’s result type B must be f’s parameter type, while A and C may be any types. The lambda in the body no longer needs its annotation, since checks it against the declared result type and gives x the type A.

Checking a generic body

Inside its own declaration a type parameter is a type the body knows nothing about. It is equal to itself and to nothing else, and no operation is defined on it:

fn bad<T>(x: T) -> T = x + 1;      // type mismatch: expected Int, found T

+ checks each operand against Int, and unification has no way to make T equal to Int. The caller chooses T, and the body has to work for every choice; treating T as Int would make bad an Int function whose signature claimed otherwise. The error is reported at x. A body that passes the check uses its T values only in ways that work for every type. It may return them, pass them to a function that takes a T, store them in a list or a reference cell, or put them in a tuple, as swap does:

fn swap<A, B>(p: (A, B)) -> (B, A) = (p.1, p.0);
// swap((1, "one")) is ("one", 1)

Comparing two T values is also an operation, and it is not defined on every type: Bridger has no == on functions. fn eq<T>(a: T, b: T) -> Bool = a == b; is rejected with type T admits no equality. A type parameter can carry a requirement, such as T: Eq, that admits such operations for the types that meet it; Part IX introduces requirements of that kind. The type parameters in this part carry none.

The checker gets its rule for a generic body from . Each function’s scheme goes into , and its body is checked with its type parameters standing as types of their own:

is ’s list of type parameters. They appear in the second premise only inside and , where each is a base type distinct from every other type, and ’s scheme is the only place they are quantified. For a function with no type parameters the rule is Appendix D’s .

Instantiation

At a use of a polymorphic function, the scheme’s type parameters are replaced by types. The declarative rule chooses them:

is with each replaced by , and the rule allows any . id(1) is typed with and id("one") with , two uses of one rule with different choices.

leaves the choice unstated, as left the parameter types, and the checker resolves it the same way. At each use of a polymorphic name it replaces every type parameter by a fresh inference variable, and the rest of the expression solves them:

  1. At id(1), id synthesizes for a fresh .
  2. checks 1 against , and unification solves as Int. The call has type , which is Int.
  3. At id("one"), id synthesizes with another fresh variable, and "one" solves as String.

Each use has its own variables, so what one use solves cannot conflict with another. For len_plus, compose’s A, B, and C become , , and . The first argument |n| n + 1 is checked against , so n has type , and n + 1 solves and as Int. The second argument is then checked against , which is now , and its parameter annotation solves as String. The call has type , which is .

A polymorphic function is instantiated wherever its name is written, including where it is passed as a value. apply(id, 5), with apply from the previous chapter, instantiates id and checks it against ; map([1, 2, 3], id) and map(["a", "b"], id) instantiate it once each, at Int and at String. The instantiated type is an ordinary function type. A let that binds it binds that one type:

fn main() {
    let f = id;
    println(f(1));
    println(f("one"));     // type mismatch: expected Int, found String
}

id is written once, on the let’s right-hand side, so it is instantiated once, at , and f is bound to that type. f(1) solves as Int, and f("one") then finds an Int parameter.

An instantiation’s variables follow the determination rule like any other. fn nothing<T>() -> [T] = []; called as len(nothing()) leaves its unsolved, since nothing in the call says what the list holds, and the checker asks for an annotation; let xs: [Int] = nothing(); supplies one.

A let binds one type

The opening program fails for the same reason as let f = id;. binds id to the type the lambda synthesized, , an ordinary function type containing the unsolved inference variable . Because it has no , each use of id looks that type up and uses it as it stands, so both calls share the one . id(1) solves it as Int, and id("one") is checked against that. Two calls at Int agree with the solution, which is why the version with id(2) runs.

In Bridger the only schemes are the ones declarations write. A let, local or global, binds a type with nothing quantified, so every use of the name shares that one type. To use a function at two types, the program declares it at the top level with type parameters.

Generalization

ML, Milner’s language, handles the opening program differently. At a let, its checker turns the right-hand side’s unsolved variables into quantified ones, so let id = |x| x binds id to , and each use instantiates it afresh. This step is let-generalization, and with it the opening program type-checks. As a rule it reads

with a side condition: only variables that appear nowhere in the context may be quantified. In this declarative rule the are type variables that nothing in fixes; the algorithm finds them by taking the inference variables that remain unsolved after checking and do not appear in . A variable that does appear there is shared with a binding outside the let, and the rest of the enclosing declaration may still solve it. In |y| { let k = |z| y; … } the type of k is , where is y’s type, so may be quantified and may not: k ignores its argument, but what it returns is y, whose type is fixed once for the whole lambda.

gives each let the most general scheme its right-hand side has, and a whole program written without a single annotation can be typed. That is the system named for Hindley and Milner, and the design space below sets it beside the signatures Bridger asks for.

Bridger’s polymorphism

Bridger’s functions are polymorphic where a top-level declaration says so. A fn with type parameters has a scheme in ; inside its body the parameters are types of their own, equal only to themselves; at each use of its name, the checker instantiates the scheme with fresh inference variables and solves them as in the previous chapter. A let never generalizes, so the inference that local lets and lambdas get is the monomorphic inference of the previous chapter, and a type that varies from use to use is always one a signature wrote.

Nothing changes at run time. The evaluator has carried a tag on every value since Part II, and id’s body returns its argument whatever the tag, so the closure for id serves id(1) and id("one") alike. Type parameters exist only in the checker; the evaluator never sees them.

Building it

The declarations reach the checker as the AST of Part I gave them. A fn declaration carries its generics, its annotated parameters, its result type, and its body:

Fn(Generics, Vec<Param>, Ty, Box<Expr>),   // fn f<T: C>(x: T, …) -> T = e

struct Generics { params: Vec<TyParam> }   // <T, U: Show, …>; empty if absent

Inside a declaration’s signature and body, a type parameter T is the named type T, a TyKind::Named with no arguments. unify needs no new case for it: two named types agree when their names and arguments do, so T unifies with T and with nothing else, apart from an unsolved inference variable. Solving a variable as T is allowed: in let ys = []; ys ++ [x] with x : T, the empty list’s element type is solved as T. What cannot happen is the reverse: solving T itself as Int or any other type. Only inference variables are solved, and T is a named type. That is all it takes to make a type parameter mean “some type the body knows nothing about”, and it is where bad’s expected Int, found T comes from.

The provided driver builds and checks each body. It does not copy the top-level signatures into the Ctx it hands check_expr; a Ctx holds only the parameters and local bindings in scope. The program’s declarations are reached through the checker:

impl Checker {
    /// The declaration named `name`, if any.
    pub fn decl(&self, name: &str) -> Option<&Decl>;
}

So the variable arm of check_expr finds a name in one of two places. A name bound in the Ctx has the type bound there, an ordinary monotype, and is used as it is. A name not in the Ctx refers to a top-level declaration, and a fn declaration gives a type built from its signature; when the declaration has type parameters, the arm replaces each one throughout that type with a fresh inference variable from fresh_meta, a new set at every use. Ty::substitute, provided with the AST, replaces type parameters throughout a type, and Milestone M5 supplies the lookups for a global’s type and for the signatures of the prelude’s native functions. The call arm needs no change: by the time a call is checked, its callee’s type has no type parameters left in it, only variables the arguments solve. The variable arm and its instantiation are part of Milestone M5.

The design space

How much is written

ML programs carry almost no types. With let-generalization, the type of every top-level function is inferred, and Damas and Milner’s result guarantees that what is inferred is the most general type the function has. Haskell inherited the inference, and its programmers write top-level signatures by convention anyway, as documentation that the compiler checks. Bridger requires them. The cost is annotations that ML would infer. In return, every polymorphic function’s type is on the page where it is declared, each declaration is checked against its own signature, and an error inside one body is reported there, without ever being blamed on a use in another declaration.

Generalization and reference cells

Let-generalization meets trouble once a language has mutable references. Suppose let r = ref []; were generalized. ref [] has type , and generalizing gives r the scheme . Then r := [1] instantiates as Int, while deref r ++ ["one"] instantiates it as String. Both are accepted, and the program concatenates a list of integers with a list of strings that the checker treated as one list type. The checker has accepted a program that goes wrong. The instances of a scheme are independent only when nothing is shared between the uses, but every use of r reads and writes the same cell. Standard ML’s repair is to generalize a let only when its right-hand side is a lambda, a constant, or a variable, the forms that cannot allocate a cell. Bridger never generalizes, and let r = ref []; binds with one , solved by its first use, so the problem does not arise.

Who chooses the type

In id(1), the caller chooses T. A function cannot ask for an argument that is itself polymorphic, one the body may use at several types:

fn both<T>(f: fn(T) -> T) -> (Int, String) = (f(1), f("one"));
// type mismatch: expected T, found Int

both wants an f that works at Int and at String, but T is the caller’s choice, and inside the body it is a type of its own, so f(1) is rejected at 1. A type system can allow the quantifier inside a parameter’s type, so that both takes an argument of type . Reynolds’s system has such types, and so do the extensions of Haskell that admit them. The price is inference: for those types in general it is undecidable, as J. B. Wells proved in 1999, so a language with them asks for annotations wherever such a type is used. Bridger’s schemes, with every quantifier at the front of a top-level signature, need none beyond the signature.

How generic code runs

A polymorphic function must run on values of whatever types it is instantiated at, and implementations meet that requirement in two ways. The first is a uniform representation. Every value has the same shape in memory, typically a pointer, so a single compiled version of the function handles every instantiation. ML implementations do this, and so does Java, whose generics were added in 2004 and are checked by the compiler and then erased, leaving one class for every instantiation. The second is to compile a separate copy of the function for each instantiation the program uses, with the type parameters replaced by the actual types. C++ templates work this way, and so do Rust’s generics; each copy is specialized to its types, at the cost of a larger program. Bridger’s evaluator has a uniform representation by construction, since every runtime value is a tagged Value.

Further reading

Parametric polymorphism and inference: Milner’s “A Theory of Type Polymorphism in Programming” (1978) presents let-polymorphism with the algorithm 𝒲 and its soundness proof. Damas and Milner’s “Principal Type-Schemes for Functional Programs” (1982) states the inference system as rules and proves that every typable expression has a most general type. Pierce’s Types and Programming Languages (MIT Press, 2002) covers let-polymorphism in chapter 22 and explicit polymorphism, with quantifiers anywhere in a type, in chapter 23.

What a type says about its functions: Philip Wadler’s “Theorems for Free!” (1989) shows that a polymorphic function’s type alone implies equations that every function of that type satisfies; from the type it derives that such a function commutes with map.

Concept checks

let id = |x| x; called as id(1) and id(2) type-checks, and called as id(1) and id("one") does not. fn id<T>(x: T) -> T = x; accepts both. What is different about the two definitions?

The let binds id to the type the lambda synthesized, , one type with one inference variable. Every call of id checks against that type, so the first call solves and later calls must agree with the solution: id(2) does, id("one") does not. The declaration gives id the scheme , and each use of the name replaces T with a fresh variable, so id(1) and id("one") solve different variables.

Suppose a function is declared fn mystery<T>(x: T) -> T. Without seeing its body, what can a call mystery(5) do?

Inside the body T is a type equal only to itself, with no operations, and the result must be a T. The only T the body has is x, and nothing it can do to x produces a different T: it cannot add to it, compare it, or print it (println(x) is rejected with type T cannot be printed). So if the call returns a value at all, that value is 5. The body may still do other things along the way, such as printing a message or updating a reference cell, and it may never return, by looping forever or stopping with a runtime error such as a division by zero computed on the side. Wadler’s “Theorems for Free!” turns observations of this kind into a method.

With fn twice<T>(f: fn(T) -> T, x: T) -> T = f(f(x));, twice(|n| n * 3, 2) prints 18, but twice(|s| s ++ s, "ab") is rejected at s ++ s with "the type of this expression cannot be determined". Why, and what are two ways to fix it?

The call instantiates T as a fresh and checks the arguments left to right. The lambda is checked against , so s has type . In the first call n * 3 checks n against Int and solves . In the second, ++ works on strings and on lists, and after checking both operands it still has only , so it cannot tell which concatenation it is; "ab", which would solve , has not been checked yet. Annotating the parameter, |s: String| s ++ s, settles it inside the lambda. Declaring twice with x first, fn twice<T>(x: T, f: fn(T) -> T) -> T, lets the call twice("ab", |s| s ++ s) solve as String before the lambda is reached. The order in which a checker visits arguments is a design choice, and it decides which programs need annotations.

fn both<T>(f: fn(T) -> T) -> (Int, String) = (f(1), f("one")); is rejected. Who chooses T, and what would the type system need for both to be accepted?

The caller chooses T at each call of both, and inside the body T is an unknown type, so f(1) asks for Int to equal T and fails. For both to type-check, its parameter would need the type , a scheme inside a function type, so that the body could instantiate f once at Int and once at String. Bridger’s schemes appear only at the top of a declaration’s signature. Systems that allow quantifiers deeper in a type exist, and they pay for it by needing annotations where inference no longer works.

If Bridger generalized local lets, which programs would it newly accept, and why would it also need a rule about let r = ref [];?

It would accept every program that uses a let-bound lambda at more than one type, such as the opening id, and it would stop rejecting an unused let id = |x| x;, since a quantified variable does not need to be solved. Generalizing let r = ref []; would give r the scheme , and each use of r would choose its own : one assignment could store [1] and a later read could treat the contents as [String]. The checker would accept a program whose list operations meet values of the wrong type, so a language that generalizes must refuse to generalize expressions like ref []. Standard ML generalizes only right-hand sides that cannot allocate.

Soundness, Informally

The previous chapters built a checker and claimed that a program it accepts cannot stop on a type error. The claim is about the evaluator, and the evaluator never sees a type. In this program, make_adder takes an integer n and returns a lambda that adds n to its argument; main binds its own n to a string, makes the adder, and calls it:

fn make_adder(n: Int) -> fn(Int) -> Int = |x| x + n;

fn main() {
    let n = "five";
    let add5 = make_adder(5);
    println(add5(1));                                   // 6
}

The checker typed the body x + n once, inside make_adder, where n is the parameter and has type Int. When add5(1) runs, the evaluator’s addition arm receives two values and matches each against Value::Int, with a branch that raises a TypeError for anything else. At that moment main has a variable n holding "five".

The program raises these questions:

  • What exactly is promised? The evaluator can stop in many ways, a division by zero among them. Which of them does an accepted program avoid?
  • Why does a fact about the text hold for the run? The checker saw x + n once, before anything ran. The evaluator meets it with environments, closures, and a store that exist only at run time. What makes the n the body reads an integer?
  • What does a mistake in the checker look like? If one rule accepts something it should not, where does the program fail, and what does the failure point at?

The guarantee was first proved for ML. The proof began by giving every meaningless operation, such as applying a number as if it were a function, a result of its own, which no type describes.

Going wrong

The first chapter of this part quoted the title of §3.7 of Robin Milner’s “A Theory of Type Polymorphism in Programming” (1978): “Well-Typed Expressions Do Not Go Wrong”. Before proving anything about his small language, Milner defined what its programs mean: “We give an ordinary denotational semantics for Exp, in which we include a value “wrong,” which corresponds to the detection of a failure at run-time. In this small language, the only failures are the occurrence of a non-Boolean value as the condition of a conditional, and the occurrence of a nonfunctional value as the operator of an application.“ Where Bridger’s rules have no derivation for if 1 { … }, Milner’s semantics gives it the meaning wrong.

Milner then defined when a value possesses a type, written , and stated the reading that Pascal’s report had given for variables: “If we temporarily identify a type with the set of values which possess it”, each type is a set of values. Integers possess int; a function possesses a function type when it maps every value of the parameter type to a value of the result type. wrong possesses nothing: “In fact “wrong” has no type.“ His Theorem 1, Semantic Soundness, says that the meaning of a well-typed expression, in an environment whose values possess the types the checker assumed for them, possesses the expression’s type. The guarantee follows as a corollary, “since wrong has no type”: a meaning that has a type is not wrong.

The rest of this chapter follows the same argument for Bridger. It has two parts. The first gives types to values, which the evaluator produces, as well as to expressions, which the checker reads. The second proves that evaluation takes a well-typed expression only to a value of the same type. The guarantee then follows, because each failure is a value that has no type.

What the guarantee says

Bridger’s semantics has no wrong. Program Semantics made a stuck expression one with no derivation, and also observed that an expression that runs forever has none either, so “has a derivation” cannot separate the two. The interpreter can. Its evaluator, asked to evaluate a stuck expression, returns a RuntimeError, and those errors play the part of Milner’s wrong. Some of them are the failures types predict, and the guarantee is stated in terms of those:

Ruled out for an accepted programStill possible
TypeError: an operand, condition, or cell of the wrong kindDivByZero
NotAFunction, ArityMismatch: a bad callStackOverflow: calls nested too deep
UnboundVariableRangeTooLarge
NoSuchField: a projection the tuple does not haveInputError: a read_* that fails
NotComparable, NotPrintable: == or printing on a function
ReturnOutsideFunction

Stated informally, for a Bridger program built from the forms of Parts II through VI:

Soundness. If the checker accepts a program, then running it does one of three things: it finishes, it runs forever, or it stops with an error from the right-hand column. It never stops with an error from the left-hand column.

The guarantee says nothing about which of the three happens, or about whether the value is the one the programmer wanted. Running forever is allowed, and so is 10 / (5 - 5). What it rules out is the evaluator reaching an arm with a value of a kind the arm cannot use.

Why it holds

The checker and the evaluator work on different things. The checker has an expression, a context of types, and the typing rules. The evaluator has the same expression, an environment of values, a store of cells, and the evaluation rules. An argument for soundness has to say how the two sides correspond at every point of a run, and then show that each evaluation rule keeps the correspondence.

Values have types

Milner’s first step is to give types to values. For Bridger’s values:

and in the same way strings have type String, () has type (), and a list has type when every element has type . A closure carries no type; it holds a lambda and the environment the lambda captured. A closure has a function type when its body, checked in a context that describes the captured environment, has the result type:

reads agrees with : every name gives a type, binds to a value of that type. Formally, for each in , is defined and . (As in , the context for the body also sets to .)

A location gets its type from a store typing , which maps each allocated location to the type of what it holds. has type when , and the store agrees with the store typing, , when each location’s contents have the type gives it. Since a tuple, a list, or a closure’s environment can hold a location, the judgment depends on throughout; the rules above leave it out to keep the notation light. exists only in the argument. Nothing in the interpreter builds one; it records what the checker’s types say about each cell the program has made so far.

A value of type Int, by these rules, is an integer, since is the only rule whose conclusion has type Int. A value of a function type is a closure (or a native function of the prelude, typed by its signature) taking that many arguments. Each type’s rules describe the one shape of value an evaluation arm expecting that type will find.

The invariant

The argument proves a claim about every expression the evaluator is ever asked to evaluate, by induction over the evaluator’s run, which is the tree of its recursive calls. Each case assumes the claim for the calls the arm makes on subexpressions, its premises, and proves it for the arm itself:

Suppose , , and . If evaluating in and ends, it ends in one of two ways:

  • with an error from the right-hand column; or
  • with a result and a store . The store agrees with some that keeps the type of every location in and may add new locations, . The result is either with , or with .

The case is the return outcome that Appendix D’s judgment carries. A return leaves the function early, so its value has the function’s return type, which checked; the return expression itself may have any type where it stands.

Soundness follows. The checker accepted every declaration, so main’s body is well-typed in the top-level context ; the program’s environment agrees with , and the store starts empty. A left-hand-column error is not among the ways the claim lets a run end.

For the top-level functions the agreement is circular, as itself is. To type a closure in by , the argument needs , and that in turn needs every closure in to be typed. checked every function body in , so each closure’s body is well-typed in the context its environment is meant to agree with, and the sketch takes the agreement as given. A full proof has to define the agreement carefully so that the circle is well founded.

Cases from the evaluator

Each arm of the evaluator is one case of the induction. The arms below use, in turn, the rules for values, the closure’s environment, and the store typing.

Addition. gave the type Int because each operand has type Int. The evaluator’s arm evaluates . By the claim for that call, the result is either an error from the right-hand column, which the arm passes on, or a value with , and a value of type Int is an integer. The same holds for . The arm’s match on Value::Int therefore succeeds for both operands, and its TypeError branch is unreachable. The sum is an integer, of type Int. (A from an operand is passed on unchanged, and has by the claim.) Every operator arm goes this way.

A call. typed with of type and each of type . By the claim, evaluates to a value of that function type, which is a closure of parameters, so NotAFunction and ArityMismatch are unreachable. The arguments evaluate to values with . By there is a with , and the body has type in extended with the parameters. runs the body in , which agrees with that extended context, so the claim applies to the body, and its result has type whether it finishes normally or by return, since the body’s context set to .

In the opening program the closure is , and is , the context in which the checker typed x + n. The n the body reads comes from the captured environment, which agrees with the context the body was checked in. main’s n never enters it.

A cell. gives ref e the type when has type . allocates a fresh location holding ’s value; extend with and the store still agrees, and has type . requires the value written into a to have type , so an assignment leaves the store agreeing with the same , and deref reads back a value of type . A location’s type is fixed when the cell is made, and every write keeps to it.

Where the argument depends on the language

The case for a call used the closure’s own environment. Under the dynamic-scope rule , the body runs in the caller’s environment extended with the parameters. That environment agrees with the caller’s context, which may bind the body’s free names at types other than the ones the body was checked with, and the case fails: in the opening program, the body would read main’s n, a string, and the addition arm would raise a TypeError. Under lexical scope, the environment a body runs in always agrees with the context in which the checker typed that body.

The case for a cell used a fixed type per location. Function types and polymorphism described a checker that generalized let r = ref []; and let two uses of r instantiate its element type differently. The cell would then need two types in , and no single makes the store agree.

A generic function’s body was checked with its type parameters standing as types of their own, equal only to themselves. Its closure is used at a different type at each call, and the call case needs the body to have the instantiated type, fn(Int) -> Int for id(1). It does: a body that uses its T values only in ways that work for every type stays well-typed when T is replaced by any particular type. Showing that for every rule is one of the lemmas a full proof states and this sketch does not.

Bridger’s guarantee

From Milestone M5, the checker runs before the evaluator. The evaluator itself is unchanged from M4, and the checker passes it nothing, so a program the checker accepts runs exactly as it ran on the M4 evaluator. Soundness is a promise about that run: it never reaches a left-hand-column error.

The evaluator keeps its TypeError branches, as the first chapter of this part noted. They report a stuck program in the milestones without a checker, and from M5 they report any ill-typed program that a bug in the checker lets through.

What a soundness bug looks like

Suppose the arm for if checked the else branch but never required its type to agree with the then branch. This declaration would be accepted:

fn pick(c: Bool) -> Int = if c { 1 } else { "one" };

fn main() {
    println(pick(true) + 1);
    println(pick(false) + 1);
}

A correct checker reports expected Int, found String at "one". The flawed one accepts the program, which then runs as the M4 evaluator runs it:

2
  × type error: expected Int, found String
   ╭─[pick.brg:5:13]
 4 │     println(pick(true) + 1);
 5 │     println(pick(false) + 1);
   ·             ───────┬───────
   ·                    ╰── here
 6 │ }
   ╰────

The error points at the addition in main, where the string was finally used. The value of the wrong type was let through by the if in pick, and the two places can be much further apart. A checker that generalized let r = ref []; would accept this program:

fn main() {
    let r = ref [];
    r := [1];
    r := deref r ++ ["one"];
    println(deref r);
    println(len(deref r));
    for x in deref r { println(x + 1); }
}

When run, it would print [1, "one"] and 2, then 2 from the first pass of the loop, and would stop with a TypeError at x + 1 on the second. The list [1, "one"] has no type: no makes hold. Printing it and taking its length succeed, because neither looks at the elements, and the program fails only when an arm finally needs one element to be an integer.

A soundness bug therefore shows up as a left-hand-column error in a program the checker accepted, and the place to look for the cause is the rule that let a value of the wrong type in. That gives a way to test a checker that the conservativeness of the first chapter does not: run each program it accepts and confirm that the evaluator never raises one of those errors. A checker that rejects too much fails tests that expect a program to run; a checker that accepts too much fails this one.

The design space

Stating soundness one step at a time

Andrew Wright and Matthias Felleisen’s “A Syntactic Approach to Type Soundness” (1994) proved soundness without a value like wrong and without a denotational semantics. They gave the language a small-step semantics, in which a program rewrites one step at a time, and observed that “each intermediate state may be assigned the same type as the original program, i.e., reductions preserve type”, a property they called by its name from combinatory logic, subject reduction. A second observation completed it: “sound type systems do not assign a type to irreducible expressions that are the sources of type errors, such as 1 + true.” A well-typed program can then never step to a stuck expression. The method extended without much change to references, exceptions, and continuations, and they used it for the core of Standard ML. It became the usual way to prove a language sound. Textbooks now state the two halves as preservation, that a step keeps a well-typed expression’s type, and progress, that a well-typed expression is a value or can take a step.

The small-step statement separates what big-step cannot. A program that runs forever takes steps forever, and a stuck one stops at an expression that is neither a value nor able to step, so “never gets stuck” is a claim about the semantics alone. This chapter’s statement leans on the interpreter instead, on errors it reports, and its proof sketch follows the shape of the evaluator, one case per arm. Both say that types are kept as the program runs; they differ in what a run is.

Unsound by design

Some typed languages decline the guarantee. TypeScript’s design goals list among their non-goals: “Apply a sound or “provably correct” type system. Instead, strike a balance between correctness and productivity.“ Its checker accepts some programs that can go wrong, in order to type patterns common in existing JavaScript, and such a program then behaves as the JavaScript it compiles to. Dynamic and gradual typing takes up languages that mix typed and untyped code.

A language can also admit a known gap in its types and close it with a check at run time. In Java a String[] is a subtype of Object[], so an array of strings can be passed where an array of objects is expected, and the receiver can try to store an Integer in it. The Java Language Specification §10.5 handles it at the store: “an assignment to a component of the array is checked at run time”, and a wrong one throws an ArrayStoreException. Java programs stay safe, in the sense of Cardelli’s table, at the cost of a runtime error the types were meant to exclude. Dart, which shipped with optional, unsound types, made its type system sound in Dart 2.0 (2018); its documentation says that it “enforces that soundness using a combination of static checking (compile-time errors) and runtime checks.” Bertrand Meyer’s “Static Typing” (1995) discusses a gap in Eiffel: a subclass may narrow the type of a method’s argument, and a call through the parent class can then pass an argument the subclass’s method does not accept. He named such calls catcalls.

Unsound by accident

Soundness is a property of a set of rules, and the rules of a large language interact in ways their designers did not foresee. Adding reference cells to ML’s polymorphism gave a checker that accepted programs that go wrong, and several repairs were proposed before the one Standard ML adopted; Wright and Felleisen’s paper gives a counterexample and proves a repaired language sound. Nada Amin and Ross Tate’s “Java and Scala’s Type Systems are Unsound” (2016) states in its abstract: “We present short programs that demonstrate the unsoundness of Java and Scala’s current type systems.” Their programs combine generics with null to convert a value of any type to any other without a cast. The Java virtual machine checks types itself when the program runs, so the conversion is caught there.

For a language the size of Standard ML, a proof on paper is long enough that its own errors are a concern. Daniel Lee, Karl Crary, and Robert Harper’s “Towards a Mechanized Metatheory of Standard ML” (2007) checked a type-safety proof for the whole language by machine, in the Twelf proof assistant, by way of an internal language with the same expressive power.

Further reading

Proving soundness: Pierce’s Types and Programming Languages (MIT Press, 2002) proves progress and preservation for a language of booleans and numbers in chapter 8, for the simply typed λ-calculus in chapter 9, and adds references, with a store typing like , in chapter 13. The Programming Language Foundations volume of Software Foundations carries out the same proofs in the Coq proof assistant, with every step checked by machine. Wright and Felleisen’s “A Syntactic Approach to Type Soundness” (1994) gives the method in full, with the polymorphic-reference counterexample and the proofs for references, exceptions, and continuations.

Semantic soundness: Milner’s “A Theory of Type Polymorphism in Programming” (1978), §3, defines the semantics with wrong, gives each type its set of values, and proves Theorem 1; the argument assumes some familiarity with domain theory.

When soundness fails: Amin and Tate’s paper (2016) builds its counterexamples up from small programs and explains which feature contributes what.

Concept checks

A checker that rejects every program is sound. Why, and what does that say about what else a checker must be judged on?

Soundness says only that an accepted program does not stop on a left-hand-column error. A checker that accepts nothing makes no claims, so none of them is false. A checker also has to accept the programs its users want to write, and the first chapter of this part showed that no checker can accept exactly the programs that never go wrong. A designer trades how much a checker accepts against how complicated its rules are, and keeps soundness fixed. Bridger’s T-If gives up if true { 1 } else { 1 + false } to keep its rule simple; it never gives up soundness to accept a program.

In the case for a call, the body runs in the closure's captured environment. Which step of the argument fails under dynamic scope, and what does the evaluator do with the opening program?

The step that applies the claim to the body. The claim needs the environment the body runs in to agree with the context it was checked in. guarantees that for the captured environment . Under the body runs in the caller’s environment, which agrees with the caller’s context and may bind the body’s free names at other types. In the opening program x + n would read main’s n, the string "five", and the addition arm would raise a TypeError, in a program the checker accepted.

On the M4 evaluator, the generalized-ref program prints [1, "one"] and 2 before it stops at x + 1. Which part of the invariant fails first, and why does the program not stop there?

The store agreement fails at r := deref r ++ ["one"]. After it, the cell holds [1, "one"], which has no type, so no makes the store agree. Nothing stops there, because no arm has yet needed an element of the list to be one particular kind: ++ joins two lists without looking inside them, printing handles any value, and len counts. The evaluator checks a value’s kind only when an arm uses it, and the first arm that needs one of these elements to be an integer is the addition in the loop body.

DivByZero is in the right-hand column. What would a type system have to express to move it to the left, and what would a programmer have to write?

The divisor’s type would have to say that it is not zero: a type of nonzero integers, with / requiring its right operand to have it. Literals other than 0 could be given that type directly. For a computed divisor, such as len(xs), the programmer would have to show the checker that the value is nonzero, by testing it in a way the checker understands, such as an if whose branch knows the test passed, or by writing a proof the checker verifies. The guarantee grows, and so does the work of satisfying the checker, as What a type can say described. Bridger’s prelude moves a similar failure into the types more cheaply: head returns an Option, so the empty case is a value the program must handle.

Java checks every store into an array of references at run time; Bridger's evaluator keeps its TypeError branches though the checker makes them unreachable. Compare what each check is for.

Java’s check is part of the language’s design. Its type system accepts programs that store a value of the wrong type into a covariant array, and the check is what keeps those programs safe; it runs in correct programs, and every array store pays for it. Bridger’s checker accepts no program that reaches a TypeError branch, so for a correct checker the branches never run in an accepted program. They exist for the milestones without a checker and as a backstop if the checker has a bug, where they turn an unsound acceptance into a reported error. An implementation that trusted its checker could remove them; one that compiled to machine code typically does.

Dynamic and Gradual Typing

From Milestone M5, a Bridger program is checked as a whole before any of it runs. This one holds a list of named settings, each paired with a value of a different type, and prints them. to_string is the prelude’s conversion of a value to its text:

fn main() {
    let settings = [("width", 80), ("title", "Report"), ("verbose", true)];
    for s in settings {
        println(s.0 ++ " = " ++ to_string(s.1));
    }
}

The M4 evaluator prints width = 80, title = Report, and verbose = true. The M5 checker rejects the program at "Report", with expected Int, found String: a list’s elements share one type, and the first pair fixed it as (String, Int).

Before M5, a function’s signature declared types that nothing checked. In this second program, indent declares width: Int and returns that many spaces, and main passes it the second component of each setting:

fn indent(width: Int) -> String =
    if width > 0 { " " ++ indent(width - 1) } else { "" };

fn main() {
    let settings = [("width", 4), ("title", "Report")];
    for s in settings {
        println(indent(s.1) ++ s.0);
    }
}

On the M4 evaluator it prints width and then stops with expected Int, found String at width > 0, inside indent. The mistake is in main, which passed a string to a function whose signature asks for an Int.

The programs raise these questions:

  • Can a program be checked in part? The settings program could keep its checked functions and leave the list unchecked. What would a checker do with a value whose type it has not been told?
  • When an unchecked value reaches checked code, who checks it, and who is blamed? The M4 run reports the failure inside indent. What would it take to report it at the call in main, and what if the value crossing over is a function?
  • What is left of the types when the program runs? Bridger’s checker hands the evaluator nothing. A check made at run time can read only what the run time keeps.

Languages without static types go back at least to Lisp. Mixing checked and unchecked code in one program is more recent.

Tags and types

Bridger’s evaluator has been dynamically typed since Part II. Each value carries a tag, the variant of Value it was built as, and each arm checks the tag of the values it receives: addition requires two Value::Ints, a call requires a Value::Closure. A tag records less than a type. The tag of |n| n + 1 says that it is a function, and nothing about what it takes or returns; the interpreter’s diagnostics print a closure’s type as fn(…), since that is all the value records. Lisp, Smalltalk, and Python check tags in the same way, which puts them in the safe and untyped cell of Cardelli’s table.

Statically typed languages sometimes needed values whose type was not known until run time: data read from a file, or a message from another program. Martín Abadi, Luca Cardelli, Benjamin Pierce, and Gordon Plotkin’s “Dynamic Typing in a Statically Typed Language” (1991) gave such values a type of their own: “we propose to add a type Dynamic whose values are pairs of a value v and a type tag T where v has the type denoted by T. Instances of Dynamic are built with an explicit tagging construct and inspected with a type safe typecase construct.” A program had to inspect a Dynamic value’s tag explicitly before it could use the value at any other type.

Jeremy Siek and Walid Taha’s “Gradual Typing for Functional Languages” (2006) removed the explicit inspection. “The focus of this paper is languages that literally provide static and dynamic typing in the same program, with the programmer controlling the degree of static checking by annotating function parameters with types, or not.” A parameter without an annotation got an unknown type, and the checker let a value of that type flow wherever a typed value was expected, inserting a check at run time in place of a typecase. In the same year Sam Tobin-Hochstadt and Matthias Felleisen’s “Interlanguage Migration: From Scripts to Programs” (2006) addressed programmers whose untyped scripts had grown into programs they wanted to type: “Ideally, they should port one module at a time, always leaving the overall product intact and running.” Their Typed Scheme, now Typed Racket, made that migration possible a module at a time.

A type for the unknown

Siek and Taha write the unknown type ?. Bridger already uses ? as the error-propagation operator of Part VIII, so the programs below write the type ? as Dyn; the typing rules and the prose keep Siek and Taha’s ?. Bridger has no Dyn type. The programs in this section and the next are written in a hypothetical gradual Bridger, and their behaviour is worked out from the rules; none of them will run in the version of Bridger this book builds.

In the settings program, the list could be given the type [(String, Dyn)]: each element a pair of a name and a value of unknown type.

let settings: [(String, Dyn)] =
    [("width", 80), ("title", "Report"), ("verbose", true)];

The checker cannot require 80 and "Report" to have the same type as Dyn, since no type equals an unknown one. Siek and Taha replace equality with a weaker relation: “The intuition is that we should require the known portions of the two types to be equal and ignore the unknown parts.” Two types are consistent, written , by these rules:

with lists, tuples, and reference types consistent when their parts are, as with . Int is consistent with ?, and so is String, and is consistent with . Int and String are not consistent with each other, so the relation, in Siek and Taha’s words, is “reflexive and symmetric but not transitive”: and do not give .

The bidirectional checker of A type checker changes in one rule. Where required the synthesized type to equal the expected one, the gradual version requires consistency:

The other rules use consistency wherever they compared types. In the loop, s.1 has type ?, and indent(s.1) is accepted, since . indent("Report") is still rejected, because String and Int are both known and differ. A program with no Dyn in it is checked exactly as before, since without ? consistency is equality. A program that writes Dyn everywhere is accepted wherever M4 would have run it.

Checking at the boundary

The checker has accepted indent(s.1) without knowing that s.1 is an Int, and indent’s body was checked on the assumption that width is an Int. Something has to confirm the assumption when the value arrives. Wherever the checker used consistency between two different types, it rewrites the expression to carry a cast, a check to be made at run time. A cast is written , read from right to left, from the type the value has to the type it is needed at:

reads check that the value of s.1, of type ?, is an Int. Siek and Taha describe what the cast does: “When e evaluates to v, the cast will check that the type of v is consistent with τ and then produce a value based on v that has the type τ. If the type of v is inconsistent with τ, the cast produces a CastError.” The label records where the cast was inserted, here the argument of the call in main.

A cast to Int reads the value’s tag, the same check the evaluator’s addition arm makes. For the pair ("title", "Report"), the cast finds a Value::Str and fails before indent is entered. The error names , the call in main, whereas M4 reported the failure at the comparison inside indent. Errors of this kind join the right-hand column of the soundness table: a gradually typed program that the checker accepts can stop with a failed cast at a boundary, while its typed parts never stop on a type error of their own.

Functions

A cast to a function type cannot be checked by reading a tag. The hypothetical program below binds a lambda to a name of type Dyn and passes it to twice, whose parameter is :

fn twice(f: fn(Int) -> Int, x: Int) -> Int = f(f(x));

fn main() {
    let g: Dyn = |n| to_string(n);
    println(twice(g, 2));
}

The cast at the call is , and the value of g is a closure whose tag says only that it is a function. Whether it returns an Int for every Int it is given is a question about every call it could receive, and no inspection of the closure answers it in general. Robert Bruce Findler and Matthias Felleisen’s “Contracts for Higher-Order Functions” (2002) met the problem when checking contracts: “The key observation is that a contract checker cannot ensure that g’s argument meets its contract when g is called. Instead, it must wait until proc is applied.”

So the cast checks the tag, and then wraps the closure in a new function that checks each use:

Because g has type ?, the wrapper treats its parameter and result types as ?. It casts the incoming Int argument to ?, which always succeeds, and casts g’s result from ? to Int, which can fail. twice calls the wrapper with 2, g returns the string "2", and the result’s cast fails, naming : the call in main that supplied g. With the Dyn annotation removed, the M4 evaluator runs the same program to completion: the first call to g returns "2", the second passes "2" to to_string and gets "2" back, and main prints 2, a string returned by a function whose signature promised an Int.

Siek and Taha’s closures carried the types of their parameters, so their casts could reject a function at once when its declared type was inconsistent with the target. A closure built from |n| to_string(n) has no declared parameter type in Bridger, and its run-time value records none, so the wrapper is the only check available.

Blame

The label on a cast says which side of a boundary failed. Philip Wadler and Robert Bruce Findler’s “Well-Typed Programs Can’t Be Blamed” (2009) proved which side that can be: “when a program integrating less-typed and more-typed components goes wrong the blame must lie with the less-typed component.” In both examples the failure is blamed on main, where a Dyn value met a typed parameter. indent and twice are never blamed, because their types were checked. The title echoes Milner’s “well-typed programs cannot go wrong”, from Soundness, informally: a gradually typed program can go wrong, but only in the parts that are not statically typed.

Where types go after checking

A cast reads something the running program keeps. A check on Int needs a tag on every value, and a check on a function type needs a wrapper that remembers the target type. Languages differ in how much of a program’s types survive to run time, and the difference decides which of these checks a language can make.

Erased

Bridger’s checker passes the evaluator nothing, as the first chapter of this part described, and the evaluator runs an accepted program exactly as it would have run unchecked. Once checking is done, the types are gone; only the tags remain, and they were there before the checker existed. Part IX is the first place the checker gives the evaluator something it worked out from types.

TypeScript erases more. As its handbook puts it, “once TypeScript’s compiler is done with checking your code, it erases the types to produce the resulting “compiled” code. This means that once your code is compiled, the resulting plain JS code has no type information.“ Where untyped JavaScript passes a value to a TypeScript function, nothing checks it, since nothing in the compiled code records what the function’s parameter was declared to be. TypeScript is gradual in what its checker accepts, and it has a type any that is consistent with every type, but it inserts no casts. Its boundaries behave like the M4 run of indent: a wrong value travels into typed code and fails, if at all, wherever an operation finally rejects it.

Java erases part of its types. Generic classes were added in 2004 to a virtual machine that had none, and the compiler checks the type arguments and then removes them, as Function types and polymorphism described. The language specification states the consequence: “Because some type information is erased during compilation, not all types are available at run time. Types that are completely available at run time are known as reifiable types.” A List<String> and a List<Integer> are the same class when the program runs, so a cast to List<String> can check that a value is a list, but not what the list holds. The compiler warns that such a cast is unchecked.

Kept

A language can keep types where it will need them. C# keeps the type arguments of its generics at run time, so a List<int> and a List<string> remain distinguishable. Its dynamic type, added in C# 4.0 in 2010, is the reverse of Abadi and his colleagues’ typecase: “The dynamic type indicates that the variable and references to its members bypass compile-time type checking. Instead, these operations are resolved at run time.” Each operation on a dynamic value consults the value’s run-time type, which the virtual machine records for every object.

Compilers keep types longer than interpreters do, in the typed intermediate representations that A type checker described, because they choose representations and instructions by type. They usually discard them when they emit machine code. Rust and C++ compile a separate copy of a generic function for each type it is called with, so the types are fixed in the generated code and none need be recorded at run time.

What a boundary can check

Sound gradual typing needs at least Bridger’s amount of run-time information: tags for checking base types, and closures that can be wrapped. A language that erases its types and checks nothing at the boundary, as TypeScript does, can be gradual only in its checker. A cast inserted by the checker needs some part of the type to remain when the program runs.

Bridger’s types

Bridger has no Dyn. Through M4 it is untyped, and from M5 it is statically typed, with every declaration checked before main starts. It never mixes the two in one program. The evaluator keeps its tag checks throughout, which is what lets the same evaluator run the untyped milestones and backs up the checker in the typed ones.

For values of several types in one list, Bridger’s answer arrives in Part VIII. A sum type names the possibilities, and match takes a value apart according to which one it is:

type Setting = Num(Int) | Text(String) | Flag(Bool);

fn show(v: Setting) -> String = match v {
    Num(n) => to_string(n),
    Text(s) => s,
    Flag(b) => to_string(b),
};

fn main() {
    let settings = [("width", Num(80)), ("title", Text("Report")),
                    ("verbose", Flag(true))];
    for s in settings {
        println(s.0 ++ " = " ++ show(s.1));
    }
}

This prints the three lines the M4 evaluator printed for the original program, and the checker accepts it. Num, Text, and Flag are tags the program declares itself, and match inspects them, much as typecase inspects a Dynamic value’s tag. The sum type lists the types a setting may have. Dyn would allow any, and would leave the checking to the casts.

The design space

How much a boundary checks

Gradually typed languages differ in what happens where an unchecked value meets a type.

  • Nothing. TypeScript’s checker uses any the way the rules above use ?, and its compiled code contains no casts. Python’s annotations are checked by separate tools such as mypy, and, as PEP 484 says, not when the program runs. This is often called optional typing. A program runs at full speed, and an annotation is a claim nothing enforces at run time.
  • The value’s own tag, and no wrapper. Reticulated Python, described by Michael Vitousek, Andrew Kent, Jeremy Siek, and Jim Baker in “Design and Evaluation of Gradual Typing for Python” (2014), offers a transient semantics: “In the transient semantics, a cast checks that the value has a type consistent with the target type of the cast, but it does not wrap the value in a proxy.” A function is checked to be callable, and its arguments and results are checked where it is used. The checks are cheaper, and when one fails, the paper notes, the implementation “is unable to report the location of the original cast that caused the error to occur”.
  • Tags and wrappers, with blame. Typed Racket, the descendant of Tobin-Hochstadt and Felleisen’s Typed Scheme, checks every value that crosses from an untyped module into a typed one, wraps functions and other values whose types cannot be checked at once, and blames the module at fault. The guarantee is the one Wadler and Findler proved.

What sound boundaries cost

The wrappers run on every call that crosses a boundary, and a wrapped function passed back and forth gathers a wrapper at each crossing. Asumu Takikawa and colleagues’ “Is Sound Gradual Typing Dead?” (2016) measured Typed Racket programs with every combination of typed and untyped modules. Earlier publications, their abstract notes, gave few measurements: “Worse, a few report disastrous numbers.” Their own conclusion: “We find that Typed Racket’s cost of soundness is not tolerable.” The slow configurations were mixtures, in which values crossed many boundaries: the state a program is in while it is being migrated one module at a time. The transient design, which predates the paper, accepts weaker checks and less precise error reports in exchange for cheaper boundaries.

Dynamic values in a static language

Abadi and his colleagues’ Dynamic and C#’s dynamic start from a statically typed language and let some values be checked late. The first requires the program to inspect the tag before using the value; the second resolves each operation on the value at run time. Gradual typing starts from the other side, a dynamically typed program, and lets types be added to it in place. The rules for ? serve both directions, and the choice between them is about which programs a language’s users already have.

Further reading

Gradual typing: Siek and Taha’s “Gradual Typing for Functional Languages” (2006) defines ?, consistency, and the cast-inserting translation for a small functional language, and gives the run-time semantics of the casts. Tobin-Hochstadt and Felleisen’s “The Design and Implementation of Typed Scheme” (2008) describes a type system designed for programs first written without types, including the checks at module boundaries.

Contracts and blame: Findler and Felleisen’s “Contracts for Higher-Order Functions” (2002) introduces the wrappers and the assignment of blame, for contracts written by programmers. Wadler and Findler’s “Well-Typed Programs Can’t Be Blamed” (2009) connects them to casts between more and less precise types and proves the blame theorem.

Cost: Takikawa and colleagues’ “Is Sound Gradual Typing Dead?” (2016) gives the method for measuring every mixture of typed and untyped modules along with the Typed Racket results.

Concept checks

Consistency is not transitive. What would a gradual checker accept if it were?

Every type is consistent with ?, so a transitive relation would make every two types consistent: and would give . indent("Report") would be accepted, and so would 1 + true, in a program with no Dyn in it at all. Consistency stops at the unknown part of a type: it compares the parts both types know and ignores the rest, so two fully known types are consistent only when they are equal.

Why can a cast to Int be checked when the value arrives, but a cast to fn(Int) -> Int cannot? What does each one read?

A cast to Int reads the value’s tag, which records exactly what Int asks for: whether the value is an integer. A cast to a function type would have to know what the function returns for every integer it might be given, which is a question about its behaviour. Bridger’s closure tag records only that the value is a function. So the cast checks that much at once and wraps the function, deferring the rest to each call, where the argument and result are values whose tags can be read.

In the twice example, the failure is blamed on main. Why not on twice, whose call to f produced the bad value?

twice was checked against its signature: it calls f only with Ints and uses its results only as Ints, so it keeps every promise its types make. The promise that f returns an Int was made by the cast at the call in main, where a Dyn value was passed as a . The wrapper carries that cast’s label, so when the result check fails, the label names main. This is the case Wadler and Findler’s theorem covers: the less precisely typed side, here main’s Dyn, is the one blamed.

TypeScript accepts a program much as a gradual checker would, but it erases its types. What could it not do at a boundary, and what would it have to keep to do it?

It cannot check a value arriving from untyped JavaScript, because the compiled code has no record of the type the receiving function declared, and so no casts to run. A wrong value enters typed code and fails wherever some operation finally rejects it, which may be far from where it entered, or it may not fail at all. To check, TypeScript would have to keep, at each boundary, the declared type in the compiled code and insert a cast there: a tag test for base types, and a wrapper for functions and objects. That is what Typed Racket does, and the cost Takikawa and colleagues measured is the cost of those checks.

The settings list can be written with a Dyn element type or with the sum type Setting. Compare the two.

Setting lists the three kinds of value a setting may be, and the checker makes every use of one handle each kind: show has to match Num, Text, and Flag, and from Part VIII a match that missed one would be rejected. A new kind of setting is a change to the type, which the checker then follows to every match on it. Dyn lists nothing: any value is allowed in the list, each use decides for itself what to expect, and a wrong guess is found by a cast when the program runs. Dyn costs nothing to write and suits data whose shape is not known in advance; Setting has to be written out, and in return the checker knows every case.

Milestone M5

The Type Checker

Part VI described a checker that types a program before it runs. The checker has one rule per form, as the evaluator does. It checks or synthesizes depending on whether the context supplies a type, and it names what neither mode supplies with an inference variable that unification solves. At each use of a generic function, it instantiates the function’s type parameters afresh. This milestone puts that checker into src/types/check.rs. Nothing in the evaluator changes, and every M1–M4 test must still pass.

From this milestone on, the checker stands between the parser and the evaluator. The interpreter checks every declaration in the program and runs main only if all of them are accepted, so a program with a type error prints nothing at all. New on the provided side is the driver that walks the declarations and judges each one when its body has been checked. There is also a module of helpers, printed in the helper module below, which you paste into your own file.

To-dos

grep -rn 'todo_m5!' src lists the holes, all in src/types/check.rs: an arm of check_expr for each form of M1–M4, and the body of unify. Each hole names the rules it implements:

// src/types/check.rs — some of the M5 holes
Expr::Lit(..)    => todo_m5!("T-Int / T-Bool / T-Str / T-Unit"),
Expr::Var(..)    => todo_m5!("T-Var / T-Self"),
Expr::Block(..)  => todo_m5!("T-Block / T-Let / T-Seq"),
Expr::Return(..) => todo_m5!("T-Return (reads ctx.ret())"),
Expr::Call(..)   => todo_m5!("T-App (first-order matching of generics at the call)"),
// … and the rest of the M1–M4 forms

pub fn unify(&mut self, a: &Ty, b: &Ty, span: Span) -> Result<(), TyError> {
    todo_m5!(
        "unify (structural agreement; solves TyKind::Meta with an occurs check)"
    )
}

The stub’s let _ = (ctx, expected); keeps the compiler quiet until your arms read those parameters, and can be deleted once they do. The arms for relations, constructors, match, ?, structs, and methods stay holes until their milestones, and so does check_rule.

One requirement has no hole of its own: check_expr must stop recursing past a fixed depth. It is described with the arms below.

Milestone specification

Each form, the rules that type it, and the cases in which the checker rejects it:

FormRulesRejected when
5, true, "s", () T-Int, T-Bool, T-Str, T-Unit —
x T-Var, T-Inst no local binding, top-level declaration, or prelude function has the name
-e, not e T-Neg, T-Not the operand is not Int (for -) or Bool (for not)
e1 + e2, e1 < e2, … T-Arith, T-Ord an operand is not Int
e1 and e2, e1 or e2 T-And, T-Or an operand is not Bool
e1 == e2, e1 != e2 T-Eq the operands have different types, or their type admits no equality
e1 ++ e2 T-Concat-Str, T-Concat-List the operands are not two strings or two lists of one element type, or neither operand settles which
e1 :: e2 T-Cons e2 is not a list of e1's type
(e1, …, en), [e1, …, en] T-Tuple, T-List a list's elements have different types
e.i T-Proj e is not a tuple, i is past its end, or e's type is not yet known
{ let x: T = e; … } T-Let, T-Seq the annotation names no declared type, or e does not have it
if e1 { e2 } else { e3 } T-If the condition is not Bool; the branches have different types; a branch of an if without else has a value
while e1 { … }, for x in e1 { … } T-While, T-For the condition is not Bool; e1 is not a list; the body is not ()
ref e, deref e, e1 := e2 T-Ref, T-Deref, T-Assign what is read or assigned through is not a reference; the value assigned is not of the cell's type
return e T-Return no function encloses it; e is not of the function's result type
|x, y| e T-Lam the body is not of the result type
f(e1, …, en) T-App f is not a function; the argument count differs from the parameter count; an argument is not of its parameter's type; a bound on a type parameter is not met

Wherever two types must agree, unify decides, and it fails with a mismatch or, when a type would have to contain itself, an infinite type. The rules are collected in Appendix D’s typing rules, and the static errors by name gives the TyError variant a test expects for each case and the span it carries. A type checker and Function types and polymorphism turn the rules into the algorithm the arms follow; this table is the checklist your checker is measured against.

The provided driver does the checks that belong to a whole declaration. It builds the top-level context from the signatures, checks each global in initialization order and reports a cycle among them, and checks every other declaration in source order, so the first error in the file is the one reported. It validates every written signature, requires main to take no parameters, and turns a mismatch in a body whose fn omits -> T into a message saying the result type was left off. After each declaration it applies the determination rule to every type your arms recorded, and it judges the bounds your arms and the native functions’ types asked for: NoEquality, NotPrintable, and UnsatisfiedBound come from there.

These contracts hold across the arms:

  • Every arm ends the same way. When there is an expected type, the arm unifies the type it has built with it (), then resolves the type and records it at its own span with record. This holds for the arms that pass the expected type down to their parts as well. A list checked against Int, or a pair against a triple, gets nothing from the expected type, and the final unification is where its mismatch is found; where the parts did use it, the final unification succeeds. The determination rule reads only what was recorded, so a type that is never recorded is never judged.
  • unify(a, b, span) takes the type found first and the type required second. A failure is Mismatch { expected: b, found: a, span }, both resolved. Inside compound types the pair reported is the innermost one that disagrees: a [Int] passed where [String] is required is expected String, found Int, at the argument.
  • Premises are checked in the order the rule writes them, left to right: an operator’s left operand before its right, a call’s callee before its arguments, a condition before its branches. The first failure is the one reported, and the tests check which one it is.
  • A mismatch is reported where the type goes wrong. An arm that passes its expected type down to a subexpression lets the mismatch surface at the smallest expression whose type disagrees: (0 - n) in describe, "one" in an if whose other branch is 1.

Implementing Milestone M5

Paste the helper module into src/types/check.rs first; several arms call it. Then destructure each arm’s .. into the fields you need, as at M4:

VariantFields
Expr::Lit(Lit, Span)
Expr::Var(Name, Span)
Expr::Unary(UnOp, Box<Expr>, Span)
Expr::Binary(BinOp, Box<Expr>, Box<Expr>, Span)
Expr::Tuple, Expr::List(Vec<Expr>, Span)
Expr::Proj(Box<Expr>, u32, Span)
Expr::Block(Vec<Stmt>, Option<Box<Expr>>, Span)
Expr::If(Box<Expr>, Box<Expr>, Option<Box<Expr>>, Span)
Expr::While(Box<Expr>, Box<Expr>, Span)
Expr::For(Name, Box<Expr>, Box<Expr>, Span)
Expr::Assign(Box<Expr>, Box<Expr>, Span)
Expr::Return(Box<Expr>, Span)
Expr::Lambda(Vec<LambdaParam>, Box<Expr>, Span)
Expr::Call(Box<Expr>, Vec<Expr>, Span)

A block’s statements are Stmt::Let(Name, Option<Ty>, Expr, Span), the span covering the whole binding, and Stmt::Expr(Expr). Types are built with Ty’s constructors (Ty::int(), Ty::list(t), Ty::func(params, ret), Ty::reference(t), and the rest in ast.rs) and taken apart by matching on ty.kind, a TyKind. Match on a type only after resolve, since an inference variable that has been solved still looks like a TyKind::Meta until it is resolved.

The provided checker

A type checker shows Ctx and the inference variables (fresh_meta, solve, resolve, record), and Function types and polymorphism shows decl. The arms also use these, provided in src/types/mod.rs:

impl Checker {
    /// Record that `ty` must satisfy trait `trait_` (a bound met at a use site).
    pub fn require_bound(&mut self, trait_: Name, ty: Ty, span: Span);
    /// The same, for a bound with type arguments (`T: Conv<String>`).
    pub fn require_trait(&mut self, trait_: TraitRef, ty: Ty, span: Span);
    /// The expression at `span` never produces a value.
    pub fn record_diverging(&mut self, span: Span);
    pub fn is_diverging(&self, span: Span) -> bool;
    /// `whole` leaves because its part `sub` does: mark `whole` diverging
    /// and the code after `sub` within it as never running.
    pub fn dead_after(&mut self, sub: Span, whole: Span);
    /// T-Eq's side condition; and whether `ty` is a bare type parameter.
    pub(super) fn admits_eq(&self, ty: &Ty, visiting: &mut HashSet<Name>) -> bool;
    pub(super) fn is_type_param(&self, ty: &Ty) -> bool;
    // and the field `depth: usize`, the nesting of the expression being checked
}

/// Whether `ty` still contains an inference variable.
pub(super) fn contains_meta(ty: &Ty) -> bool;
pub const MAX_NESTING: usize = 150_000;

admits_eq takes a set it uses to stop on recursive types; pass it &mut HashSet::new().

The arms

What follows adds to the content chapters what a test will hold you to. A rule’s premises are checked in the order the rule writes them, and every arm ends as the first contract above says, with the final unification and record.

  • The nesting bound. When self.depth has reached MAX_NESTING, check_expr returns TooDeep { limit: MAX_NESTING, span } for the expression in hand instead of matching on it. Otherwise it increments depth, checks the expression, and decrements depth again whatever the outcome. This is the balance enter_call and leave_call kept at M4. Applying ? to the result before decrementing would leave depth incremented after every error, so take the result as a value, decrement, and then return it. Moving the match into a method of its own keeps the increment and the decrement in one place.
  • Var looks a name up in this order: the Ctx; a top-level fn, through decl; a global, through global_type; a native prelude function, through native_fn_type. A name found in none of them is UnboundVariable at the name. A fn declaration gives the type fn(params) -> result from its signature. If it declares type parameters, map each one to a fresh inference variable, a new map at every use, and apply the map with Ty::substitute, which ast.rs provides. For each parameter declared with a bound T: C, record the bound with require_trait on the variable T became, at the name’s span, with the bound’s own type arguments substituted in the same way.
  • Operators. Arithmetic and ordering check both operands against Int, and and or against Bool, - and not their operand likewise. Equality synthesizes the left operand and checks the right against its type. If that type, resolved, still contains a variable, require an Eq bound on it for the driver to judge. Otherwise, a type that does not admit equality is NoEquality { ty, is_param, span } at the whole expression, with is_param from is_type_param. Concatenation synthesizes the left operand. One whose resolved type is neither a string, a list, nor an unsolved variable is Mismatch { expected: String, … } at that operand. The right operand is then checked against the left’s type, and a type still unsolved after both is Ambiguous at the whole expression. Cons synthesizes the element and checks the list against a list of its type.
  • Tuples, lists, and projection. A tuple checked against a tuple type of the same width passes each component its counterpart; otherwise its components are synthesized. A list takes its element type from an expected list type, or else from a fresh variable, and checks every element against it in order. The empty list is that fresh variable and nothing more, for the context or the determination rule to settle. A projection synthesizes its operand and resolves it: a tuple wide enough gives the component, a narrower one is NoSuchComponent { index, arity }, a type not yet known goes to undetermined, and anything else is NotATuple { index, found }, all at the projection’s span.
  • References. ref e is a reference to whatever e synthesizes. deref e checks e against a reference to a fresh variable and has that variable as its type. e1 := e2 checks e1 against a reference to a fresh variable, checks e2 against the variable, and has type ().
  • Blocks thread a Ctx through their statements, each let extending it for the statements after. A let with an annotation passes it through annotation at the statement’s span, checks the initializer against the type annotation returns, and binds the name at that type. A let without an annotation binds the name at the type its initializer synthesizes. An expression statement is synthesized and its type discarded. The block’s tail is checked against the expected type, and a block with no tail has type ().
  • if, while, for. A condition is checked against Bool. With an else, the then branch is checked against the expected type and the else branch against the then branch’s type, which is the if’s type. Without one, the branch is checked with no expected type and must unify with (). A branch that does not is IfWithoutElse { found } at the branch, and the if has type (). A while body is checked against (). A for checks its iterable against a list of a fresh variable and its body against (), in the context extended with the loop variable at that variable. Both loops have type ().
  • return e reads the enclosing function’s result type with ctx.ret(). With none, it is ReturnOutsideFunction. Otherwise e is checked against it, the return is recorded as diverging, and its own type is a fresh variable, so it fits whatever its context requires.
  • Lambdas. Each parameter’s type is its annotation, through annotation at the parameter’s span; failing that, the matching parameter of an expected function type with the same number of parameters; failing that, a fresh variable. The result type is the expected function type’s, or a fresh variable. The body is checked against the result type in ctx.enter(Some(result), ctx.self_ty()) extended with the parameters, so a return inside the body targets the lambda. The lambda’s type is the function type of the parameters and result, each resolved.
  • Calls. check_callee synthesizes the callee and returns its type, resolved, with the range of bounds it recorded. A function type with a different number of parameters than there are arguments is ArityMismatch at the whole call, naming the callee if it is a variable and "this function" otherwise. Each argument is checked against its parameter’s type, left to right; then finish_call moves the callee’s bounds onto the arguments that supply them, and the call has the function type’s result type. When the callee’s type is still an unsolved variable, as in |f| f(1), the call infers a function type for it: synthesize the arguments, make a fresh result variable, and unify the callee’s type with the function type they form, at the call’s span. Any other callee type is NotAFunction { found } at the callee.
  • unify resolves both types first. An unsolved variable agrees with itself. An unsolved variable against any other type is solved as that type, unless it occurs in that type, which is InfiniteType at span. Int, Bool, String, (), and Self agree with themselves. Two tuples of the same width, two lists, two references, two function types with the same number of parameters (the parameters in order, then the results), and two named types with the same name and number of arguments agree when their parts unify pairwise, left to right. Any other pair is the mismatch.

Leaving and dead code

Three provided calls tell the driver what your arms know about control flow:

  • record_diverging(span) marks an expression that produces no value. A diverging expression’s type is exempt from the determination rule, which is why return 0 can stand in a branch whose type nothing else fixes. The return arm marks itself. A block whose tail diverges is marked too, and so is an if whose branches both diverge.
  • dead_after(sub, whole) marks whole as diverging because its part sub is, and the code after sub within whole as never running. A block calls it as soon as a statement’s expression, or a let initializer, diverges, with that statement’s span and the block’s, and before it checks the statements after it. Those are still checked, inside the region now marked dead, so the call has to come first for undetermined and the determination rule to treat them as dead. Only the first diverging statement calls it. A block that leaves this way and has no tail has a fresh variable as its type, where it would otherwise have (). An if or while calls it when its condition diverges, and a for when its iterable does.
  • note_callee(span) exempts a named callee from the determination rule, since whatever is unknown in its type is unknown in an argument or in the call, which are the places to report it. check_callee calls it.

Code that never runs needs no annotations, so the determination rule skips it, and undetermined returns a fresh variable for it where it would otherwise report Ambiguous. A mismatch in dead code is still an error. So

fn f() -> Int { return 1; let y = []; println(y); 2 }

checks, although nothing fixes the element type of y. In fn f() -> Int { return 1; 1 + true }, by contrast, 1 + true is still rejected.

Where the calls go

The two arms below show where the calls are made and when, with the typing left as comments for you to fill in. Each ends as every arm does: unify the type with the expected one, if there is one, then resolve the type, record it at the arm’s span, and return it. Those last lines are the same in every arm, and a method of your own can save writing them out each time.

Expr::Block(stmts, tail, span) => {
    let mut cx = ctx.clone();
    let mut leaves = false;
    for stmt in stmts {
        // T-Let / T-Seq: check the statement, extending `cx` for a `let`.
        // `e` is the statement's expression or the `let`'s initializer,
        // `at` is the statement's span.
        let (e, at) = /* … */;
        // The first statement that leaves makes the rest of the block dead;
        // the call comes before the remaining statements are checked.
        if !leaves && self.is_diverging(e.span()) {
            leaves = true;
            self.dead_after(at, *span);
        }
    }
    let ty = match tail {
        Some(e) => /* the tail, checked in `cx` against `expected` */,
        None if leaves => self.fresh_meta(), // a block that leaves fits anywhere
        None => Ty::unit(),
    };
    if tail.as_ref().is_some_and(|e| self.is_diverging(e.span())) {
        self.record_diverging(*span);
    }
    // Chk-Sub, then record: the end of every arm
    if let Some(exp) = expected {
        self.unify(&ty, exp, *span)?;
    }
    let ty = self.resolve(&ty);
    self.record(*span, ty.clone());
    Ok(ty)
}
Expr::If(cond, then, els, span) => {
    // the condition, checked against Bool
    if self.is_diverging(cond.span()) {
        self.dead_after(cond.span(), *span); // both branches are dead
    }
    // the `then` branch, checked against `expected` when there is an `else`
    // and against `None` when there is not; its type `t` is the `if`'s
    let t = /* … */;
    match els {
        Some(e) => {
            // the `else` branch, against `t`
            if self.is_diverging(then.span()) && self.is_diverging(e.span()) {
                self.record_diverging(*span); // every path leaves
            }
        }
        None => { /* `t` must unify with (), or it is IfWithoutElse */ }
    }
    // Chk-Sub, then record: the end of every arm
    if let Some(exp) = expected {
        self.unify(&t, exp, *span)?;
    }
    let t = self.resolve(&t);
    self.record(*span, t.clone());
    Ok(t)
}

The other forms follow the same pattern. A while calls dead_after(cond.span(), *span) when its condition diverges, a for makes the same call with its iterable’s span when the iterable diverges, and a return calls record_diverging(*span) on itself.

The helper module

The starter leaves the module below out. Paste it at the end of src/types/check.rs, after the closing brace of the impl Checker block. It is a module of its own with its own use lines, so it cannot clash with the imports your arms add. Its methods are public, so one you have not called yet raises no warning. It can read the Checker’s private fields because check.rs is part of the types module.

  • annotation(ty, ctx, span) checks a type written inside a body, a let’s or a lambda parameter’s, against the declared types and the type parameters in scope. It also replaces Self by the type an impl is for, which matters from Part IX on.
  • native_fn_type(name, span) is the type of a native prelude function, with fresh variables for its generic parts and the bounds of Appendix C recorded on them, or None if name is not a native function. The entries marked #[cfg(not(feature = "m8"))] drop out at M8, where those functions become Bridger code in the prelude that decl finds.
  • global_type(x) is the type of the global x: its annotation, or the type the driver recorded when it checked the initializer. It is None if x is not a global.
  • check_callee(f, ctx) and finish_call(bounds, param_tys, args, call_span) bracket a call. A bound the callee carries, such as Ord on min’s parameter, is recorded where the callee is named. finish_call moves it to the argument whose parameter mentions the bounded type, where the type that fails it is written, so let g = min(f, f); with f a function is reported at the first f.
  • undetermined(span) is for an operand whose type a rule needs and does not yet know: a fresh variable in dead code, Ambiguous at span elsewhere.
/// Provided helpers (Milestone M5): plumbing that no typing rule describes.
/// Paste this module at the end of the file, unchanged.
mod helpers {
    use crate::ast::{Decl, Expr, Name, Span, Ty, TyKind};
    use crate::types::{Checker, Ctx, TyError};
    use std::ops::Range;

    type Res<T> = Result<T, TyError>;
    type Bounds = Range<usize>;

    impl Checker {
        /// A type annotation written inside a body (`let x: T`, `|x: T|`): `Self`
        /// resolved to the enclosing `impl`'s type, and every name it uses checked
        /// to be a declared type or a type parameter in scope (`Self` outside an
        /// `impl` names no type).
        pub fn annotation(&self, ty: &Ty, ctx: &Ctx, span: Span) -> Res<Ty> {
            let ty = match ctx.self_ty() {
                Some(self_ty) => ty.with_self(&self_ty),
                None => ty.clone(),
            };
            self.validate_type(&ty, span)?;
            Ok(ty)
        }

        /// The type of a native prelude function (Appendix C, "Functions").
        /// `print` / `println` / `to_string` carry a `Print` bound and `contains`
        /// an `Eq` bound; until M8 `len` carries `Len` and `min` / `max` /
        /// `minimum` / `maximum` carry `Ord`, which the built-in types satisfy.
        /// `None` for a name that is not a native primitive.
        pub fn native_fn_type(&mut self, name: &str, span: Span) -> Option<Ty> {
            let opt = |t: Ty| Ty::named("Option".to_string(), vec![t]);
            let sig = match name {
                "print" | "println" => {
                    let t = self.fresh_meta();
                    self.require_bound("Print".to_string(), t.clone(), span);
                    Ty::func(vec![t], Ty::unit())
                }
                "to_string" => {
                    let t = self.fresh_meta();
                    self.require_bound("Print".to_string(), t.clone(), span);
                    Ty::func(vec![t], Ty::str())
                }
                "abs" => Ty::func(vec![Ty::int()], Ty::int()),
                "even" | "odd" => Ty::func(vec![Ty::int()], Ty::bool()),
                "range" => Ty::func(vec![Ty::int(), Ty::int()], Ty::list(Ty::int())),
                #[cfg(not(feature = "m8"))]
                "len" => {
                    let t = self.fresh_meta();
                    self.require_bound("Len".to_string(), t.clone(), span);
                    Ty::func(vec![t], Ty::int())
                }
                #[cfg(not(feature = "m8"))]
                "min" | "max" => {
                    let t = self.fresh_meta();
                    self.require_bound("Ord".to_string(), t.clone(), span);
                    Ty::func(vec![t.clone(), t.clone()], t)
                }
                #[cfg(not(feature = "m8"))]
                "minimum" | "maximum" => {
                    let t = self.fresh_meta();
                    self.require_bound("Ord".to_string(), t.clone(), span);
                    Ty::func(vec![Ty::list(t.clone())], opt(t))
                }
                "contains" => {
                    let t = self.fresh_meta();
                    self.require_bound("Eq".to_string(), t.clone(), span);
                    Ty::func(vec![Ty::list(t.clone()), t], Ty::bool())
                }
                "head" => {
                    let t = self.fresh_meta();
                    Ty::func(vec![Ty::list(t.clone())], opt(t))
                }
                "tail" => {
                    let t = self.fresh_meta();
                    Ty::func(vec![Ty::list(t.clone())], opt(Ty::list(t)))
                }
                "unwrap_or" => {
                    let t = self.fresh_meta();
                    Ty::func(vec![opt(t.clone()), t.clone()], t)
                }
                "map" => {
                    let (a, b) = (self.fresh_meta(), self.fresh_meta());
                    Ty::func(
                        vec![Ty::list(a.clone()), Ty::func(vec![a], b.clone())],
                        Ty::list(b),
                    )
                }
                "filter" => {
                    let a = self.fresh_meta();
                    let keep = Ty::func(vec![a.clone()], Ty::bool());
                    Ty::func(vec![Ty::list(a.clone()), keep], Ty::list(a))
                }
                "fold" => {
                    let (a, b) = (self.fresh_meta(), self.fresh_meta());
                    Ty::func(
                        vec![
                            Ty::list(a.clone()),
                            b.clone(),
                            Ty::func(vec![b.clone(), a], b.clone()),
                        ],
                        b,
                    )
                }
                "is_some" => {
                    let t = self.fresh_meta();
                    Ty::func(vec![opt(t)], Ty::bool())
                }
                "read_int" => Ty::func(vec![], Ty::int()),
                "read_bool" => Ty::func(vec![], Ty::bool()),
                "read_line" => Ty::func(vec![], Ty::str()),
                "read_int_opt" => Ty::func(vec![], opt(Ty::int())),
                "read_bool_opt" => Ty::func(vec![], opt(Ty::bool())),
                "read_line_opt" => Ty::func(vec![], opt(Ty::str())),
                _ => return None,
            };
            Some(sig)
        }

        /// The type of the top-level global `x`: its annotation, or the type
        /// the driver recorded for its initializer. `None` if `x` is not a
        /// global.
        pub fn global_type(&mut self, x: &Name) -> Option<Res<Ty>> {
            let (annot, init) = match self.decl(x) {
                Some(Decl::Global(annot, init)) => (annot.clone(), init.clone()),
                _ => return None,
            };
            Some(match (annot, self.global_types.get(x)) {
                (Some(ty), _) => Ok(ty),
                (None, Some(ty)) => Ok(ty.clone()),
                (None, None) => self.check_expr(&init, &Ctx::new(None, None), None),
            })
        }

        /// Synthesize the type of a call's callee, resolved, and note what the
        /// call's bookkeeping needs: a named callee is exempt from the
        /// determination rule, and the bounds the callee's type carries come
        /// back as a range to pass to `finish_call`.
        pub fn check_callee(&mut self, f: &Expr, ctx: &Ctx) -> Res<(Ty, Bounds)> {
            let start = self.obligations.len();
            let ty = self.check_expr(f, ctx, None)?;
            if matches!(f, Expr::Var(..)) {
                self.note_callee(f.span());
            }
            Ok((self.resolve(&ty), start..self.obligations.len()))
        }

        /// Call after checking a call's arguments against the parameter types
        /// `param_tys`: a bound the callee placed on a type parameter is
        /// reported at the argument whose parameter mentions it, or at the
        /// whole call if none does.
        pub fn finish_call(
            &mut self,
            bounds: Bounds,
            param_tys: &[Ty],
            args: &[Expr],
            call_span: Span,
        ) {
            for i in bounds {
                let want = &self.obligations[i].1;
                let blamed = param_tys.iter().position(|p| contains_ty(p, want));
                self.obligations[i].2 = match blamed {
                    Some(j) => args[j].span(),
                    None => call_span,
                };
            }
        }

        /// An operand whose type is still unknown where its rule needs it: in
        /// code that never runs nothing is required of it; elsewhere its type
        /// is undetermined.
        pub fn undetermined(&mut self, span: Span) -> Res<Ty> {
            if self.in_dead_code(span) {
                Ok(self.fresh_meta())
            } else {
                Err(TyError::Ambiguous { span })
            }
        }
    }

    /// Whether `needle` occurs in `hay` (structurally, spans aside).
    fn contains_ty(hay: &Ty, needle: &Ty) -> bool {
        if hay == needle {
            return true;
        }
        let any = |ts: &[Ty]| ts.iter().any(|t| contains_ty(t, needle));
        match &hay.kind {
            TyKind::Tuple(ts) | TyKind::Named(_, ts) => any(ts),
            TyKind::List(t) | TyKind::Ref(t) => contains_ty(t, needle),
            TyKind::Fn(ps, r) => any(ps) || contains_ty(r, needle),
            _ => false,
        }
    }
}

Running a program

From this milestone on, cargo run --features m5 checks a program before it runs it. A program the checker rejects stops with the error before main is called, and the exit status is 1. In late.brg, total is an ordinary variable, and := assigns only through a reference. The M4 interpreter runs the program until it reaches the assignment:

$ cat late.brg
fn main() {
    println("start");
    let total = 0;
    total := total + 1;
}

$ cargo run -q --features m4 -- late.brg
start
  × type error: expected ref<()>, found Int
   ╭─[late.brg:4:5]
 3 │     let total = 0;
 4 │     total := total + 1;
   ·     ─────────┬────────
   ·              ╰── here
 5 │ }
   ╰────

With M5’s checker, the program never starts:

$ cargo run -q --features m5 -- late.brg
  × type mismatch: expected ref<_>, found Int
   ╭─[late.brg:4:5]
 3 │     let total = 0;
 4 │     total := total + 1;
   ·     ──┬──
   ·       ╰── here
 5 │ }
   ╰────

The evaluator reports the assignment, the expression that got stuck. The checker reports total, the expression whose type disagrees with what := requires of its target: a reference to a fresh variable, still unsolved when the mismatch is found. An unsolved variable is printed as _.

Because the checker runs first, no program runs under --features m5 until the arms it reaches are written: the first todo_m5! it meets stops the run. cargo run --features m4 still runs the evaluator with no checker in front of it.

Worked examples

An expression in an empty context, and the type the checker gives it:

(1, "a").1                                     : String
[] ++ [true]                                   : [Bool]
|x| x + 1                                      : fn(Int) -> Int
ref [1]                                        : ref<[Int]>
{ let r = ref 0; r := deref r + 1; deref r }   : Int
map([1, 2], to_string)                         : [String]
fold([1, 2, 3], 0, |acc, x| acc + x)           : Int

Expressions the checker rejects:

1 + "a"                          Mismatch (expected Int, found String), at "a"
(true, 1).4                      NoSuchComponent (index 4, arity 2)
{ let f = |x| x(x); () }         InfiniteType, at x(x)
{ let f = |x: Int| x; f == f }   NoEquality (fn(Int) -> Int)
len(42)                          UnsatisfiedBound (no impl Len for Int), at 42
{ let xs = []; xs := [1]; () }   Mismatch (expected ref<_>, found [_]), at xs
abs(1, 2)                        ArityMismatch (`abs` takes 1, given 2)

How to know it works

cargo m5 runs the provided milestone M5 tests and, with them, the M4, M3, M2, and M1 tests. The M5 tests come in two kinds. The first builds an Expr by hand with the builders of the earlier milestones and types it in an empty context with infer, or against a type with check. The second loads a program from source and checks it with check_program, on top of the prelude:

// |x| x + 1  :  fn(Int) -> Int
let f = lambda(&["x"], binary(Add, var("x", 1), int(1, 2), 3), 0);
assert_eq!(infer(&f).unwrap(), Ty::func(vec![Ty::int()], Ty::int()));

// a whole program, checked declaration by declaration
let src = "fn double(x: Int) -> Int = x * 2; fn main() -> Int = double(21);";
assert!(check_program(src).is_ok());

A rejection is tested by its TyError variant and, where it matters, by its span and payload, never by the text of its message. Fill the arms and unify, run cargo m5, and repeat until every M5 test, and every earlier one, passes. Then run the programs you wrote for M4 with cargo run --features m5: a correct program that the checker rejects is either a bug in an arm or one of the conservative rejections Part VI describes, and the error’s span says which expression to look at.

Design problems

Your evaluator and your checker make claims about the same programs, and soundness says how the claims relate. Design a test that finds bugs in check.rs without a list of expected types, and say which bugs it cannot find. Try it on a unify that unifies two function types' parameters and forgets their results.

Run every program the checker accepts through the evaluator, and require that none of them stops with an error soundness rules out: TypeError, NotAFunction, ArityMismatch, UnboundVariable, or a projection a tuple does not have. A program for the test can come from anywhere, the examples in this book, the programs you wrote for M1–M4, or a generator, because the test needs to know only whether the checker accepted it. The forgetful unify accepts

fn apply(f: fn(Int) -> String) -> String = f(1);

fn main() {
    let g = |x: Int| x;
    println(apply(g) ++ "!");
}

since g’s type fn(Int) -> Int agrees with fn(Int) -> String once the results are skipped. When run, the program stops at apply(g) ++ "!" with type error: expected String, found Int, and the test reports it. A checker with this mistake passes every provided M5 test. The test cannot find a checker that rejects too much: a checker that rejects every program passes it. It cannot find a wrong variant or span on a correct rejection. And it finds an unsound acceptance only if a run reaches the code the mistake let through: with the call to apply in a branch that never runs, the program prints and exits normally. The provided tests check rejections, their variants and spans included; the soundness test checks what the checker accepts.

The checker rejects this program at ref [], although every use of log agrees that it holds strings. Moved into main as a local let, the same cell checks. Why does the checker treat the two differently, and what could go wrong if it accepted the first?
let log = ref [];

fn note(s: String) { log := s :: deref log; }

fn main() {
    note("start");
    note("stop");
    println(deref log);
}

A global is checked in a declaration of its own, before any function body, and that declaration must determine its type completely. ref [] leaves the element type open, and the uses that would settle it are in note and main, other declarations, which no inference variable outlives. As a local, the cell and its uses are one declaration, main’s body, and note("start") solves the variable there; the program prints ["stop", "start"].

There are two ways a checker could accept the global version. If each function solved the global’s variable for itself, a second function fn count(n: Int) { log := n :: deref log; } would solve it as Int, note as String, and both would be accepted. The M4 evaluator runs that program and prints [2, "start"]: one cell read and written at two types, the generalized reference cell of the soundness chapter in another form. If instead every declaration shared one variable for the global, the conflict would be caught, but the type of log would depend on which body the checker visited first. Bodies could no longer be checked in any order, each against its own signature, as the top-level context allows. The annotation let log: ref<[String]> = ref []; states the type in the one place every use can see.

Write make_stack, generic in the item type, which returns a pair of closures over one shared cell: one pushes an item, the other removes the top item and returns it as an Option. Use it at Int and at String in one main. Which annotations does the program need beyond the signature?
fn make_stack<T>() -> (fn(T) -> (), fn() -> Option<T>) {
    let items = ref [];
    let push = |x| items := x :: deref items;
    let pop = || {
        let top = head(deref items);
        items := unwrap_or(tail(deref items), []);
        top
    };
    (push, pop)
}

fn main() {
    let ints = make_stack();
    ints.0(1);
    ints.0(2);
    println(ints.1());          // Some(2)
    println(ints.1());          // Some(1)
    println(ints.1());          // None
    let words = make_stack();
    words.0("hi");
    println(words.1());         // Some("hi")
}

None. In the body, items starts as a reference to a list of and push’s parameter as , and x :: deref items makes equal to . The tail (push, pop) is checked against the declared result type, which hands fn(T) -> () to push and solves as T. In main, each call of make_stack instantiates T with a fresh variable, and ints.0(1) and words.0("hi") solve the two variables as Int and String, within main’s declaration. A ref [] is accepted here where the global log of the previous problem was not: each call of make_stack allocates its own cell at one instantiation of T, and every use of that cell is in the declaration that fixed it.

The checker stops at the first error. Many compilers report several. What would check_expr have to do to keep going after a mismatch, and what has to be true of the type it continues with?

check_expr returns a Result, and ? passes the first Err up through every enclosing arm and out of the driver, so the first error ends the check. To keep going, an arm would push the error onto a list and return a type in place of the Err. Which type it returns decides whether later reports are real. In

fn main() {
    let xs = [1, 2];
    let total = sum(xs);
    println(total);
}

sum is unbound. If the Var arm reported it and continued with a fresh variable, the call would infer fn([Int]) -> β for it, and total would have type , which nothing solves. The determination rule would then report total as undetermined: a second error that exists only because of the first. The type an arm continues with has to agree with every type in unify, be exempt from the determination rule and from bounds, and never appear in a later message. The Rust compiler gives such an expression an error type that behaves this way. Across declarations, Bridger needs nothing more. The driver could check the next declaration after a failed one, since no type crosses a declaration except through a signature, and a mistake inside one body cannot change what the checker concludes about another.

Declarative Programming

Part VI checked programs before running them, but how a Bridger program computes has not changed since Part V: a function receives arguments and works out a result from them, step by step. The program below answers a question about a family. The family is a list of pairs (p, c), each saying that p is a parent of c. parents_of(family, c) keeps the pairs whose second component is c and returns their first components. ancestors(family, who) collects who’s parents and, after each parent, that parent’s ancestors, in a list held in a ref cell:

fn parents_of(family: [(String, String)], child: String) -> [String] =
    map(filter(family, |p| p.1 == child), |p| p.0);

fn ancestors(family: [(String, String)], who: String) -> [String] {
    ref found = [];
    for p in parents_of(family, who) {
        found := deref found ++ [p] ++ ancestors(family, p);
    }
    deref found
}

fn main() {
    let family = [("tom", "bob"), ("tom", "liz"), ("bob", "ann"),
                  ("bob", "pat"), ("pat", "jim")];
    println(ancestors(family, "jim"));
}

It prints ["pat", "bob", "tom"]: jim’s parent, that parent’s parent, and so on up.

ancestors answers one question, who a given person’s ancestors are. Asking for tom’s descendants takes a second function, which filters on the first component and recurses on the second. Asking whether tom is an ancestor of jim takes a third, or a search through the first function’s result. The family is the same in all three, and so is what it means to be an ancestor: a parent is an ancestor, and so is a parent’s ancestor. Each function encodes that definition again, walking the pairs in its own direction.

Bridger’s relations let a program state the definition once. In the program below, relation declares a relation and the types of its columns, each add records one fact, and the two rules define ancestor:

relation parent   : (String, String);
relation ancestor : (String, String);

rule ancestor(x, y) :- parent(x, y);
rule ancestor(x, z) :- parent(x, y) and ancestor(y, z);

fn main() {
    add parent("tom", "bob");
    add parent("tom", "liz");
    add parent("bob", "ann");
    add parent("bob", "pat");
    add parent("pat", "jim");

    println(solutions ancestor(?a, "jim"));
    println(solutions ancestor("tom", ?d));
    println(ancestor("tom", "jim"));
}

The rules read as sentences: x is an ancestor of y if x is a parent of y; x is an ancestor of z if x is a parent of some y and y is an ancestor of z. A query names a relation and marks the place an answer goes with a hole, ?a or ?d. solutions lists the values that fill the holes, and a query with no holes is a yes-or-no question. The program prints:

["bob", "pat", "tom"]
["ann", "bob", "jim", "liz", "pat"]
true

One pair of rules answers all three questions. No line of the program chooses a person to visit first or a direction to walk the pairs in.

Replacing the functions with rules raises these questions:

  • What does a rule mean, if it does not say how to compute anything? The rules state when a fact holds. Which facts does that make true, and how is it decided?
  • Why are these five people tom’s descendants, and no others? A relation that named everyone as an ancestor of everyone would satisfy both rules too. What picks out the answer the program printed?
  • How do facts and rules fit inside a language of expressions and a store? In the program above, ordinary code in main adds the facts, and the answers come back as ordinary values.

Programming by stating what holds, and leaving a system to work out what follows, took shape in the early 1970s, where automated theorem proving met natural-language processing.

Logic as a program

The theorem provers of the 1960s took a set of logical statements and searched for a proof that some further statement followed from them. J. A. Robinson’s resolution principle (1965) gave them a single inference step to search with, together with the matching operation it depends on, the subject of the next chapter. A prover’s search was its own business: the statements said what was true, and the prover decided which inferences to try and in what order.

In 1974, Robert Kowalski argued that a restricted form of such statements could be read as a program. The restriction is to clauses with a single conclusion and a conjunction of conditions, B if A₁ and … and Aₙ. Alfred Horn had studied that form in 1951, and such clauses are called Horn clauses after him. Kowalski read the clause as a procedure: to establish B, establish A₁, then the rest. Over the same years Alain Colmerauer’s group in Marseille, building a system that answered questions put to it in French, wrote its reasoning as such clauses and built Prolog (1972) to run them. Kowalski’s later “Algorithm = Logic + Control” (1979) set out the division this made possible. A program’s logic states what is true, its control states how the search for consequences proceeds, and the two can be written and changed separately.

Databases arrived at relations from a different direction. Edgar Codd’s relational model (1970) stored data as relations, sets of rows of equal length, and asked queries by describing the rows wanted, leaving the system to find them. The query languages built on it could join, select, and project, but no query among them could compute an ancestor relation of unbounded depth. Alfred Aho and Jeffrey Ullman showed (1979) that the relational algebra cannot express transitive closure, the very construction ancestor performs, and proposed adding a fixpoint operator to supply it.

The two lines met in work on deductive databases, gathered in Hervé Gallaire and Jack Minker’s Logic and Data Bases (1978): stored relations as in Codd’s model, plus Horn-clause rules that derive further relations from them. The fragment of Prolog that suits this use keeps the rules but restricts their arguments to constants and variables, so a rule can relate values it is given and never constructs new ones. By the mid-1980s that fragment was called Datalog; Ceri, Gottlob, and Tanca’s survey (1989) collected what was known about it.

Relations, facts, and rules

A relation is a set of tuples, all with the same number of components. parent is a relation with two columns, both strings, and the program above gave it five tuples:

parentchild
tombob
tomliz
bobann
bobpat
patjim

Each tuple is a fact: parent("bob", "pat") says that bob is a parent of pat. Because a relation is a set, its facts have no order, and recording the same fact twice leaves the relation as it was. A query’s answer is a set as well: in a family where two lines of descent meet at one person, ancestors would list that person once for each line, and a query lists them once.

A rule derives facts from facts. It has a head, the atom to the left of :-, and a body, the conjunction of atoms to the right; read logically, the head holds whenever the body does. Each variable in a rule stands for every value at once, so the second ancestor rule says: for all x, y, and z, if parent(x, y) and ancestor(y, z) then ancestor(x, z). The variable y appears in the body and not in the head, and the reading is the same as “if x is a parent of some y, and that y is an ancestor of z”. A rule is a Horn clause, with one conclusion and a conjunction of conditions.

A rule has the shape of the inference rules used since Program semantics: premises above a horizontal bar, the conclusion below it. Written that way, the two ancestor rules are:

and a fact is a rule with no premises. A fact holds when it has a derivation, a tree of rule instances whose leaves are facts. This is the tree for ancestor("tom", "jim"):

Each step instantiates a rule: the bottom step is with x as tom, y as bob, and z as jim. An evaluation derivation in Part II showed that an expression has a value. This one shows that a fact follows from the facts recorded and the rules.

The facts a program means

The relational program’s meaning is the set of facts that have derivations. For the family, that is the five parent facts and nine ancestor facts: each of the five parent pairs, together with tom–ann, tom–pat, tom–jim, and bob–jim.

The same set can be described without derivations, and the description answers the question of why the answer is exactly this set. Call a set of facts closed under the rules when every rule whose body holds in the set has its head in the set too. Many sets are closed. The set that contains every parent fact and every possible ancestor pair of the six people is closed, since it has every head a rule could ask for. It also holds ancestor("jim", "tom"), which no derivation produces. The meaning is the least closed set: the one that contains the recorded facts, is closed under the rules, and is contained in every other set with those two properties.

The set of derived facts is that least closed set, and the argument has two halves. First, take any closed set that contains the recorded facts. A derivation starts from recorded facts, which are in the set, and each step applies a rule to facts already in the set, which a closed set answers with the rule’s head. So every derived fact is in every such set. Second, the set of derived facts is itself closed: if every premise of a rule instance has a derivation, placing the rule beneath those derivations gives one for its head. The derived facts form a closed set contained in every other, which makes them the least.

Writing for a program’s rules and for its recorded facts, this least set is written , as in the language reference. It is the meaning of the rules and facts together. Changing a rule or a fact can change it. The order of the rules, the order of the conditions in a body, and the order in which facts were recorded cannot. How an interpreter computes , and why that computation always ends, is the subject of Evaluating relations.

Queries

A query asks about . A query ancestor(?a, "jim") has one hole, and a solution is a value for the hole that makes the query a member of the set: ?a as bob, as pat, or as tom. ancestor("tom", ?d) puts the hole in the other column and finds five solutions. A query with no holes, ancestor("tom", "jim"), has one solution with nothing to fill in when the fact is in the set and none when it is not, which makes it a yes-or-no question. The holes go wherever the question needs them, because is a set of facts, and a set has no direction to walk it in.

Stored and derived relations

The family program splits its relations two ways. parent holds facts recorded one at a time; it is defined by listing its members, its extension, and the recorded facts are called the extensional database, or EDB. ancestor holds facts derived by rules; it is defined by a condition its members meet, its intension, and the relations defined by rules are called the intensional database, or IDB. The terms come from the deductive-database work above.

The rules for ancestor mention no person. Recording a different family’s parent facts and asking the same queries gives that family’s ancestors, with no change to the rules.

Bridger’s relations

Bridger takes Datalog’s restriction and embeds the result in the rest of the language. Relations and rules are top-level definitions; facts arrive from ordinary code with add; and the answers to queries come back as ordinary values. The grammar gives the forms.

Declaring relations

A declaration names a relation and the types of its columns:

relation parent : (String, String);

The types are checked like any other. add parent("tom", 3) is rejected before the program runs, with type mismatch: expected String, found Int at the 3. Answering a query compares column values for equality, so a column’s type must be one that admits equality, which rules out a function type. Rule bodies are typed by the same checker, and Rule bodies and safety gives their rules.

Rules and program facts

A rule is a top-level definition, so its position in the file does not matter, as for every other definition. Lowercase names in a rule are its logic variables; each term, an argument of an atom in a rule, is a logic variable or a literal and nothing else. A relation with several rules holds the facts derived by any of them, so two rules for one relation read as an “or”.

Because a term is only a variable or a literal, a rule cannot compute a new value. The grammar has no place for n + 1 as a term, and this rule is a parse error at the +:

rule nat(n + 1) :- nat(n);

A rule with no body is a fact that belongs to the program. These two give the program a family to start from, with no add needed:

rule parent("tom", "bob");
rule parent("bob", "ann");

Adding and clearing facts

The arguments of add are ordinary expressions, unlike a rule’s terms: add R(e₁, …, eₙ) evaluates each eᵢ, which may be n + 1 or a function call, and records the fact made of the resulting values. clear R removes the facts add recorded for R. A program’s own facts, from body-less rules, are part of , and clear leaves them. With the two program facts above, this main:

fn main() {
    add parent("ann", "sue");
    println(solutions ancestor(?a, "sue"));
    clear parent;
    println(solutions ancestor(?a, "sue"));
    println(solutions ancestor(?a, "ann"));
}

prints ["ann", "bob", "tom"], then [], then ["bob", "tom"]: once the added fact is gone, sue has no ancestors, and ann keeps the two that the program’s own facts give her.

The recorded facts are the of : the relation database, which add and clear change and every query reads. It becomes a third component of the evaluation judgment, beside the store, going in and coming out of every rule:

Every rule from earlier chapters passes through unchanged, threading it in evaluation order as it threads the store. add and clear are the rules that change it:

E-AddFact takes a union, so adding a fact already present changes nothing. Neither rule removes a single fact: between one clear and the next, only grows, and the facts a query can see are the facts recorded so far. A query reads and never changes it.

The relation database is kept apart from the store for that reason. A store cell holds one value at a time, and := replaces it, so what a cell held earlier is gone. The facts in accumulate, and the meaning of a query is a fixed set, , that the query’s own evaluation cannot alter.

Asking queries

The family program used a query in each of the three places one may appear. solutions q is a list of the hole values of every solution, a single value for each solution when the query has one hole and a tuple when it has more. A hole whose value is not needed can be left unnamed, as a bare ?: ancestor("bob", ?) asks whether bob has any descendant. A query where a Bool is expected is true when it has a solution. And for q { … } runs its body once for each solution with the holes bound as variables:

for ancestor(?a, "ann") {
    println(a ++ " is an ancestor of ann");
}

prints bob’s line and then tom’s. The solutions come in a fixed canonical order, sorted by their hole values, which is why the family program printed ["bob", "pat", "tom"] where the ancestors function printed ["pat", "bob", "tom"]. A query’s meaning is a set, and the canonical order gives that set one listing that does not depend on the order the facts were added or derived. The rules for the three forms are in Querying relations.

Building it

The query forms all need , which Evaluating relations computes. What can be built now is the relation database and the two forms that change it, add and clear. The provided runtime keeps as a RelationDb on the interpreter, beside the store. Like the store, it is mutated in place, so its two operations are the step from to :

impl RelationDb {
    fn add(&mut self, relation: &str, tuple: Tuple) -> bool; // E-AddFact
    fn clear(&mut self, relation: &str);                     // E-Clear
}

The relation forms reach eval_expr as the Relation variant of Expr, holding a Rel:

enum Rel {
    Add(Query),        // add R(a, …)
    Clear(Name),       // clear R
    Solutions(Query),  // solutions R(a, …)
    Query(Query),      // a bare query R(a, …)
}

struct Query { name: Name, args: Vec<QArg> }   // R(a, …)

enum QArg {
    Expr(Expr),          // a ground argument
    Hole(Option<Name>),  // a hole: ?x (named) or ? (anonymous)
}

A relation’s name is resolved like any other name. The initial environment binds each declared relation to a Value::Relation(name), so add and clear look the name up in the environment; a name bound to anything else is stuck, with NotARelation. add evaluates its arguments left to right as E-AddFact does. Because Add holds a Query, its arguments are QArgs, and one of them may be a hole. A hole has no value to record: the type checker rejects it before the program runs, and the evaluator, given one anyway, is stuck with HoleInAdd. When the number of arguments differs from the relation’s declared number of columns, the evaluator is stuck with ArityMismatch, the same error a function call with the wrong number of arguments gives. The Add and Clear cases each yield Value::Unit. The Solutions, Query, and ForQuery cases wait until can be computed.

The design space

Rules that build values

Prolog’s terms can be compound: a rule may build a structure from its variables, and a structure may appear inside a fact. Numbers in the style of Peano are two rules, one for zero and one for a successor:

nat(z).
nat(s(N)) :- nat(N).

The second rule derives nat(s(z)) from nat(z), then nat(s(s(z))), and so on without end, so the set of facts these rules mean is infinite. Lists, trees, and every other data structure in Prolog are compound terms of this kind, which is what makes Prolog a general-purpose language.

Datalog gives them up. Without compound terms, the only values a rule can put in a head are values already present in the facts, and finitely many facts contain finitely many values. A relation with two columns over six people can hold at most thirty-six facts, and so is finite for every program. Bridger makes the same choice by limiting a term to a variable or a literal, which is why rule nat(n + 1) :- nat(n); does not parse: it would derive a new integer from each one it had. A program that needs a new value, such as the result of arithmetic, computes it in ordinary Bridger code, which can loop over a query’s solutions and add facts holding the results.

Search order

Prolog runs a query by searching from the query backwards, trying the clauses for a relation in the order they are written and the conditions of a body from left to right. With the conditions of the second rule swapped, the logic is unchanged:

ancestor(X, Y) :- parent(X, Y).
ancestor(X, Z) :- ancestor(X, Y), parent(Y, Z).

Asked for tom’s descendants, Prolog finds each of them, and then, asked for one more, enters the second clause again, whose first condition is again an ancestor query with tom as its first argument. The search recurses until it runs out of stack. A query with no solution, such as whether jim is an ancestor of tom, never answers at all. In Kowalski’s terms, the logic is the same and the control has changed, and a Prolog programmer orders clauses and conditions so the search terminates.

The same swap in Bridger, rule ancestor(x, z) :- ancestor(x, y) and parent(y, z);, prints exactly what the original printed. A query’s answer is , which the order of rules and conditions does not affect, and Bridger computes the whole set, which is finite. The cost is that it computes all of it: to answer whether tom is an ancestor of jim, it derives all nine ancestor facts, where a search backwards from the query visits only the people on the way up from jim. Datalog systems recover much of that saving by rewriting a program’s rules, given a query, so that computing the whole set derives only the facts relevant to that query.

Where the facts come from

SQL, the query language of most relational databases, has been able since its 1999 standard to define a relation by recursion. This query, run against a parent table holding the family, returns bob, pat, and tom:

WITH RECURSIVE ancestor(anc, des) AS (
    SELECT parent, child FROM parent
  UNION
    SELECT p.parent, a.des
    FROM parent p JOIN ancestor a ON p.child = a.anc
)
SELECT anc FROM ancestor WHERE des = 'jim';

The UNION combines a base case and a recursive step, the two ancestor rules in another syntax. In SQL the facts live in a database, filled and changed by statements outside the query.

Standalone Datalog systems such as Soufflé read their facts from input files and write derived relations out, and they declare a type for each column, as Bridger does. One of their main uses is program analysis: the facts describe a program’s statements, and the rules derive facts about how its values flow. Prolog keeps facts and rules in one database that a program can change while it runs, adding clauses with assert and removing them with retract, in the middle of a search that reads them.

Bridger puts the facts in the hands of the host language. Imperative or functional code computes them and adds them; the rules are fixed when the program is written; and a query’s answer arrives as a list, a Bool, or a loop variable for the host code to keep working with. Facts accumulate between clears, so a query is always asked of a fixed set.

Further reading

Datalog: Ceri, Gottlob, and Tanca’s “What You Always Wanted to Know About Datalog (and Never Dared to Ask)” (1989) defines the language and its meaning and surveys the evaluation methods known at the time. Abiteboul, Hull, and Vianu’s Foundations of Databases (1995), which the authors make freely available, covers Datalog, its evaluation, and its extensions with negation in its chapters 12–15, alongside the relational model those chapters build on.

Datalog in use: Green, Huang, Loo, and Zhou’s “Datalog and Recursive Query Processing” (2013) covers evaluation techniques and the applications that revived Datalog after 2000, among them networking, program analysis, and security. Jordan, Scholz, and Subotić’s “Soufflé” (2016) describes a Datalog engine built for analysing large programs.

Logic programming: Sterling and Shapiro’s The Art of Prolog (2nd ed., MIT Press, 1994) teaches Prolog in full, with compound terms, its search order, and control constructs such as the cut.

The relational model: Codd’s “A Relational Model of Data for Large Shared Data Banks” (1970) introduces relations as the structure of a database and the operations on them.

Concept checks

Add ancestor("liz", "jim") to the fourteen facts the family program means. Is the new set closed under both rules? Why is it not the program's meaning?

It is closed. The only rule that could use the new fact is , with ancestor("liz", "jim") as its second premise, and it needs a parent of liz: tom. Its head, ancestor("tom", "jim"), is already in the set. So the set contains the recorded facts and is closed, but it is not the least such set: the set without ancestor("liz", "jim") is closed too and smaller. No derivation ends in ancestor("liz", "jim"), and the meaning holds only facts that have one.

Write a rule for grandparent(x, z) over parent. Which pairs does it give for the family, and is grandparent part of the EDB or the IDB?
rule grandparent(x, z) :- parent(x, y) and parent(y, z);

solutions grandparent(?g, ?c) is [("bob", "jim"), ("tom", "ann"), ("tom", "pat")], in canonical order. The rule is not recursive: a grandparent is exactly two parent steps up. grandparent is defined by a rule, so it is part of the IDB; parent, whose facts are added, is the EDB.

Swapping the conditions of the second ancestor rule changes nothing in Bridger and sends Prolog into unbounded recursion. What does each language take the order of conditions to mean?

To Prolog, the order is control: conditions are tried left to right, so ancestor(X, Y), parent(Y, Z) starts by asking a new ancestor query with the same first argument, which starts the same way, without end. To Bridger, the order means nothing: a body is a conjunction, a query’s answer is the least closed set , and a conjunction is the same in either order. Bridger pays for that by computing the whole of ; Prolog’s search, when it terminates, visits only what the query leads it to.

Bridger rejects rule nat(n + 1) :- nat(n); at parse time. What guarantee would accepting it give up?

That the values in the program’s facts and rules limit how many facts can be derived, and so how long computing takes. With rule nat(0); as a program fact, the rule would derive nat(1), then nat(2), and so on, a new fact each round. Bridger’s integers wrap at , so the rounds would end only after about of them, and solutions nat(?n) would not return in any practical sense. With terms limited to variables and literals, every value in a derived fact already appears in the program’s facts or rules, and a finite set of values can be arranged into only finitely many facts.

ancestors returned ["pat", "bob", "tom"], while solutions ancestor(?a, "jim") returned ["bob", "pat", "tom"]. Why does Bridger sort solutions rather than list them in the order they were found?

The order a function returns is the order its code visits the pairs. A query has no code of its own: its answer is the set , and the order in which facts are derived depends on how an interpreter computes that set, and on the order the facts were added, neither of which changes the set. Sorting by hole values gives every correct interpreter the same list for the same facts, so the order a program prints in, and anything it computes from that order, depends on the facts alone.

Unification

The previous chapter gave the meaning of a set of rules as the facts that have derivations. One step of the derivation for ancestor("tom", "jim") used the second ancestor rule,

rule ancestor(x, z) :- parent(x, y) and ancestor(y, z);

with x as tom, y as bob, and z as jim. The chapter wrote those values down without saying where they came from. An interpreter has only the rule and the facts it knows, and it has to find the values itself.

Suppose the facts known so far are the five parent facts of the family and the five ancestor facts that the first rule gives from them, one for each parent pair. Firing the second rule means finding every assignment of values to x, y, and z under which both atoms of the body are known facts. A few attempts show what is involved:

parent(x, y) againstgivesancestor(y, z) againstgives
parent("bob", "pat")x bob, y patancestor("pat", "jim")z jim
parent("bob", "pat")x bob, y patancestor("tom", "bob")fails: y is pat
parent("tom", "liz")x tom, y lizany ancestor factfails: none starts with liz

The first row succeeds, and the rule’s head with those values is a new fact, ancestor("bob", "jim").

The attempts raise these questions:

  • How is an atom with variables matched against a fact? In the first column, both of parent(x, y)’s variables take their values from the fact. In the third, y already has a value, and the fact has to agree with it.
  • What makes the two atoms of a body agree? y appears in both atoms of the body. The second row fails because the fact puts tom where y already stands for pat.
  • How are all the assignments found? One success gives one new fact. Firing the rule means finding every assignment, and the table above tried three.

The operation in each cell, making an atom with variables identical to a fact by choosing values for the variables, has a name from automated theorem proving. Part VI used it on types.

Unification in logic programming

A type checker introduced J. A. Robinson’s unification algorithm (1965): given expressions containing variables, find a substitution for the variables that makes them identical. Robinson built it for resolution, his single inference step for proving theorems, which combines two clauses when an atom of one and a negated atom of the other can be made identical. Unification finds the substitution that makes them so, and the combined clause carries that substitution forward. Milner later used the same algorithm to solve equations between types, which is how Part VI’s checker infers them.

Logic programming made unification the whole of its data handling. In Kowalski’s procedural reading of a clause, establishing a goal means unifying it with the head of a clause and then establishing the body. The unification does the work a function call’s parameter passing does elsewhere: values in the goal flow into the clause’s variables, and values the clause’s variables receive flow back out to the goal. A goal whose arguments are all values tests a fact. A goal with variables in some positions computes values for them. Which positions are inputs is settled by each goal and not fixed by the clause, and this is the source of the direction-free queries of the previous chapter.

Prolog unifies at every step of its search, between terms that may contain variables on both sides and may be built from other terms. To keep that step fast, Prolog systems have generally left out the occurs check Part VI’s checker needed, and accepted the consequences. Datalog’s evaluation proceeds the other way round, from known facts to new ones. The facts contain no variables, so there unification always meets a variable on one side only.

Substitutions

A substitution maps logic variables to values. Writing for one, the first row of the table found

Applying a substitution to an atom replaces each variable it binds with its value, written . For the second rule’s head, is . An atom with no variables left is ground. Every fact is ground, and a substitution that binds all of a rule’s variables makes every atom of the rule ground.

Matching an atom against a fact

Matching an atom against a fact, under a substitution that may already bind some of the atom’s variables, finds the substitution that extends and makes the atom equal the fact, or fails. The atom and the fact must belong to the same relation, so they have the same number of positions, and matching compares them position by position:

  • A constant in the atom must equal the fact’s value in that position.
  • A variable already bound by the substitution must have the fact’s value in that position.
  • A variable not yet bound becomes bound to the fact’s value in that position.

If every position passes, the result is extended by the new bindings. The first two cases test and change nothing. The third binds and cannot fail. A single position is one or the other depending on the substitution it is matched under, and the atom says nothing about which.

Matching parent(x, y) under the empty substitution binds both variables, so every parent fact matches and each gives a different substitution. The same atom matched under binds only x, and only the facts with pat in the second position match: parent("bob", "pat"), giving .

A constant in a rule tests in the same way. In

rule toms(c) :- parent("tom", c);

the first position of the body atom matches only facts whose parent is tom, so toms holds bob and liz.

The cases also apply within a single atom. A variable written twice is bound at its first position and tested at its second. Matching parent(x, x) against parent("tom", "bob") binds x to tom in the first position and fails in the second, where x is tom and the fact has bob. Nobody in the family is their own parent, so no parent fact matches.

Matching a rule body

A body’s atoms are matched one after another, each under the substitution the ones before it produced. That is how a variable shared between atoms comes to mean the same value in each. The first atom that contains it binds it, and every later atom tests it. In the table above, parent(x, y) bound y, and ancestor(y, z) then accepted only facts that agreed.

To find every assignment, the interpreter carries a set of substitutions through the body instead of a single one. It starts with the set holding only the empty substitution. Each atom then builds a new set: for each substitution in the old set and each fact the atom matches under it, the extended substitution goes into the new set. A substitution under which the atom matches no fact drops out, and one under which it matches several facts gives several extensions. After the last atom, the set holds every assignment that makes the whole body true, and applying each to the head gives the facts the rule derives.

For the second ancestor rule, with the five parent facts and the five ancestor facts the first rule gives, parent(x, y) turns the empty substitution into five:

xyfacts ancestor(y, z) matcheshead
tombobancestor("bob", "ann"), ancestor("bob", "pat")ancestor("tom", "ann"), ancestor("tom", "pat")
tomliznone
bobannnone
bobpatancestor("pat", "jim")ancestor("bob", "jim")
patjimnone

Two of the five substitutions are extended by the second atom, the first in two ways and the fourth in one. That gives three complete substitutions and three ancestor facts that were not known before. One more is missing from the meaning: ancestor("tom", "jim") needs ancestor("bob", "jim"), which this firing has just produced. Firing the rule again, with the new facts included, finds it. Evaluating relations repeats the firing until nothing new appears.

Carrying a set of substitutions through a conjunction of atoms is, in database terms, a join: the rows of one relation are combined with the rows of another wherever they agree on a shared column, here y. The body’s order changes the work done and never the result. Matching ancestor(y, z) first binds y and z from every ancestor fact, and parent(x, y) then tests y and binds x, which leaves the same three complete substitutions at the end. A conjunction is the same in either order, and so is the set of substitutions that satisfies it.

Queries

A query is matched in the same way, against the facts in . Its holes are variables, and its other arguments are evaluated to values and become constants. ancestor(?a, "jim") is the atom . Matched under the empty substitution against each ancestor fact, it binds a wherever the second position is jim, and each match is a solution. A hole written twice is one variable, so parent(?x, ?x) asks for the facts with the same value in both positions, and solutions parent(?x, ?x) is []. An anonymous hole ? is a variable that no other position shares and no solution reports. solutions parent(?p, ?) lists each parent once, ["bob", "pat", "tom"], though bob and tom have two children each.

Bridger’s matching

Bridger’s matching is the one-sided case. Every value a variable can be bound to comes from a fact, and a fact holds values, never variables: the facts add records are ground, and every fact a rule derives is ground because every head variable is bound by its body, the condition that Rule bodies and safety calls safety. So a variable is only ever bound to a value, and never to another variable or to a term containing one.

Set beside Part VI’s unify, the operation has the same cases with less in each:

types, in the checkeratoms, in the relations engine
the two sidestwo types, either may hold variablesan atom with variables, a ground fact
a constanta base type, or a constructor with partsa value, compared with ==
a bound variableunifies as its solution wouldmust equal the fact’s value
an unbound variablesolved as the other side, after the occurs checkbound to the fact’s value

Part VI’s checker needed the occurs check to stop from being solved as a type containing . A logic variable here is bound to a value, and a value contains no logic variables, so the binding can never refer back to the variable it binds. Bridger’s matching has no occurs check because nothing could fail it.

Matching compares values with ==. Every column type of a relation is one that admits equality, as Declaring relations required, and that requirement exists for this comparison.

Building it

The engine’s terms and substitutions are provided. A Term is a logic variable or a constant value, the form both a rule’s atoms and a query’s arguments take once they reach the engine:

enum Term {
    Var(Name),   // a logic variable: x in a rule, ?x in a query
    Val(Value),  // a constant: a literal, or an evaluated query argument
}

impl Subst {
    fn new() -> Self;                         // the empty substitution
    fn get(&self, x: &str) -> Option<&Value>; // x's value, if bound
    fn bind(&mut self, x: Name, v: Value);    // bind x (overwrites)
    fn apply(&self, t: &Term) -> Term;        // Aθ, one term at a time
}

Matching is yours, as one method on Subst:

pub fn unify(&self, pattern: &[Term], fact: &[Value]) -> Option<Subst> {
    todo_m6!("Subst::unify: match terms against a ground tuple, …")
}

pattern is an atom’s arguments and fact a tuple of the relation. The method returns the extended substitution, or None when some position disagrees or the two have different lengths. It leaves self unchanged either way, so the caller can try the same substitution against the next fact, which is how one substitution becomes several. Within one call, a variable bound at one position counts as bound at every later position of the same atom, so parent(x, x) against parent("tom", "bob") binds x at the first position and fails at the second. bind overwrites, so a variable that is already bound has to be checked with get before anything is bound.

Once a body has been matched, applying each complete substitution to the head’s terms, with apply, gives the tuple of the derived fact. A query’s anonymous holes reach the engine as variables too, each given a name that no other position uses, so it binds nothing that any other position tests and is left out of the solutions. The engine that carries the set of substitutions through a body and fires the rules is built in Evaluating relations, and it calls unify for every pairing of an atom and a fact.

The design space

Two-sided unification

Prolog unifies terms that have variables on both sides and that may be built from other terms. Its lists are compound terms, [H|T] for a list with head H and tail T, and appending two lists is two clauses:

append([], L, L).
append([H|T], L, [H|R]) :- append(T, L, R).

Asked append([1], [2], X), Prolog binds X to [1, 2]. Asked append(X, Y, [1, 2]), it runs the other way and gives every way of splitting the list: X = [] and Y = [1, 2], then X = [1] and Y = [2], then X = [1, 2] and Y = []. Each step unifies the goal with a clause head, binding variables to terms that themselves contain variables, such as [1|R], to be filled in by later steps. This takes the full algorithm of Part VI applied to terms instead of types: two-sided, recursive through compound terms, and in need of the occurs check.

When Bridger’s relations give up compound terms, they give up this power too. Without terms built from other terms, a variable meets only values, and matching is a loop over positions with a comparison in each. A list in Bridger is computed by functions, and a relation can hold one as a column value but cannot take it apart in a rule.

The occurs check

Unifying X with f(X) would bind X to a term containing X. The occurs check rejects this, at a cost: before binding a variable to a term, it looks through the whole term, so binding a variable to a large term costs time proportional to the term’s size. Most Prolog systems skip the check by default and provide a separate, checked unification for when it matters; without it, X = f(X) succeeds and builds a term that contains itself. Colmerauer’s later Prolog II took that outcome as intended and treated such terms as infinite trees, which its unification was designed to handle. Robinson’s algorithm, Part VI’s checker, and checked unification in Prolog all reject the binding. Bridger never meets the case.

General unification has a cost even with the check, since terms that share subterms can make a direct implementation of Robinson’s algorithm do far more work than the terms’ size suggests, and later algorithms, Martelli and Montanari’s among them, were designed to avoid it. One-sided matching against a fact costs one comparison per column.

Which atom first

Since the order of a body’s atoms does not change its substitutions, an engine may choose the order. The choice decides how many facts are tried. In the firing above, starting with parent(x, y) binds y, after which ancestor(y, z) need consider only the ancestor facts whose first position is that value. An engine that keeps each relation indexed by column finds those facts directly instead of trying every one. Database systems choose join orders and indexes for each query; Prolog leaves the order to the programmer, who writes it; Bridger’s meaning ignores the order, and its engine is free to pick one.

Further reading

Unification: Kevin Knight’s “Unification: A Multidisciplinary Survey” (1989) follows unification through theorem proving, logic programming, type inference, and natural-language processing, and covers the algorithms and their costs. Alberto Martelli and Ugo Montanari’s “An Efficient Unification Algorithm” (1982) presents unification as solving a set of equations by transformation rules and gives the efficient algorithm.

Unification without the occurs check: Alain Colmerauer’s “Prolog and Infinite Trees” (in Logic Programming, ed. Clark and Tärnlund, Academic Press, 1982) gives the theory behind treating the terms built by omitting the check as infinite trees.

Joins and their evaluation: Abiteboul, Hull, and Vianu’s Foundations of Databases, freely available from the authors, treats evaluating Datalog rules as joins in its chapter 13, “Evaluation of Datalog”.

Concept checks

Match parent(x, y) against parent("bob", "pat") under the empty substitution, then under {y ↦ ann}. What does each produce?

Under the empty substitution, both variables are unbound, so both positions bind: the result is . Under , the first position binds x to bob, and the second position tests y, which is bound to ann against the fact’s pat, and fails. The same atom and the same fact give a match or a failure depending on what the substitution already binds.

Fire rule grandparent(x, z) :- parent(x, y) and parent(y, z); against the five parent facts. Which complete substitutions does it produce?

parent(x, y) gives five substitutions, one per fact. parent(y, z) then needs a fact whose first position is the bound y: with y as bob it matches parent("bob", "ann") and parent("bob", "pat"); with y as pat it matches parent("pat", "jim"); with y as liz, ann, or jim it matches nothing. That gives three complete substitutions, which derive grandparent("tom", "ann"), grandparent("tom", "pat"), and grandparent("bob", "jim"): the three pairs solutions grandparent(?g, ?c) prints. Neither atom mentions grandparent, so one firing finds them all.

rule sibling(x, y) :- parent(p, x) and parent(p, y); derives sibling("ann", "ann"). Which substitution produces it, and why does matching allow it?

: the first atom matches parent("bob", "ann"), binding p and x, and the second atom matches the same fact, testing p and binding y. Nothing in the body says x and y differ, and two distinct variables may be bound to the same value. Ruling out the case takes a condition that is not a relation atom, x != y, which is the subject of Rule bodies and safety.

Why does Bridger's matching need no occurs check, when Part VI's unification did?

The occurs check stops a variable from being bound to something that contains it. In the type checker a variable could be solved as a type such as , which contains . In Bridger’s relations, a logic variable is only ever bound to a value taken from a ground fact, and a value contains no logic variables. A binding that refers back to its own variable cannot be formed, so there is nothing for the check to find.

Prolog's append runs backwards, splitting a list into every pair of parts. What would Bridger's relations need before a rule could do the same?

Compound terms in rules, so that a rule could name a list’s head and tail as [h|t], and two-sided unification, so that a variable could be bound to a term still containing variables, such as [1|r], and filled in later. Both bring back the occurs check. Compound terms also let a rule build values the facts did not contain, which ends the guarantee from Rules that build values that is finite.

Evaluating Relations: the Least Fixpoint

Unification fired the second ancestor rule once, against the five parent facts and the five ancestor facts the first rule gives, and derived three new facts: ancestor("tom", "ann"), ancestor("tom", "pat"), and ancestor("bob", "jim"). One fact the family’s meaning contains was still missing. ancestor("tom", "jim") needs ancestor("bob", "jim") as a premise, and that fact did not exist until the firing produced it. Firing the rule again, with the new facts included, derives it.

A family tree cannot show what rules do over a cycle, since no one is their own ancestor. A course catalogue can contain one by mistake, so this chapter adds a second example, a catalogue in which requires(c, p) records that course c requires course p, and before(p, c) holds when p must be taken at some point before c:

relation requires : (String, String);
relation before   : (String, String);

rule before(p, c) :- requires(c, p);
rule before(p, c) :- requires(c, q) and before(p, q);

fn main() {
    add requires("compilers", "pl");
    add requires("pl", "data_structures");
    add requires("data_structures", "intro");
    println(solutions before(?p, "compilers"));
    println(solutions before(?c, ?c));
}

The first rule says a direct requirement comes before; the second says that whatever comes before one of c’s requirements comes before c. The program prints ["data_structures", "intro", "pl"], the courses to take before compilers, and []: no course has to be taken before itself. A catalogue in which some course did would be a mistake, and the second query is a check for one.

Suppose someone records that the introductory course requires compilers, add requires("intro", "compilers");. The requirements now run in a circle, and solutions before(?c, ?c) becomes ["compilers", "data_structures", "intro", "pl"]. Each course comes before itself, by way of the other three.

The two examples raise these questions:

  • When does firing stop? Each firing can produce facts that let some rule fire again. What says the process ends, and that it ends at and not short of it or past it?
  • What happens on a cycle? After the mistaken entry, every before fact can be followed around the circle to derive more. Why does evaluation not go around forever?
  • How much work is repeated? A second firing of the ancestor rule re-derives the three facts the first one found, along with the new one. Can that be avoided without losing any fact?

The answers come from a result about logic programs from the mid-1970s, and from database research a decade later.

Fixpoints and logic programs

Maarten van Emden and Robert Kowalski’s “The Semantics of Predicate Logic as a Programming Language” (1976) gave Horn-clause programs three meanings and showed that they agree. The first is the one Declarative programming used: the least set of facts closed under the rules. The second is operational: the facts that can be proved from the program. The third describes the program as a function from sets of facts to sets of facts, the one-step consequences of the rules, and takes the meaning to be the least set this function maps to itself. That function has a least fixed point by a theorem of lattice theory published by Alfred Tarski in 1955, which guarantees one to every function of its kind with a property called monotonicity, defined below.

The third meaning is also a way to compute the first: apply the function repeatedly, starting from the recorded facts, until nothing changes. When deductive databases began evaluating Datalog over stored relations in the 1980s, this loop was the starting point, and the work went into doing it without redundant effort. François Bancilhon and Raghu Ramakrishnan’s “An Amateur’s Introduction to Recursive Query Processing Strategies” (1986) compares the strategies then known, among them the plain loop and the refinement this chapter builds.

The loop itself appeared in this book at its start. The paradigm branches wrote reachability functionally as a function step, which adds every node one edge away, and a function grow, which applies step until the set stops changing: until it reaches a fixed point.

One round of consequences

Write for the rules and for the recorded facts. The immediate-consequence operator takes a set of facts and returns together with every fact a single rule step derives from :

The substitutions are the ones unification finds by carrying a set of substitutions through a body; fires every rule once against and collects the heads. A rule with no body, a program fact, has : the condition on its body holds vacuously, so yields its fact from every .

Closure has a short statement in these terms. A set containing is closed under the rules exactly when : firing every rule against produces nothing outside . A fixed point is a set with , and the least closed set is the least fixed point of .

is monotone: if , then . A rule’s body only asks for facts to be present, and a substitution that finds them all in finds them all in the larger , so every fact derived from is derived from too. A body that asked for a fact to be absent would break this, since adding a fact to could then remove a consequence; that case is set aside until Rule bodies and safety.

Rounds

Evaluation applies over and over. It starts from , the recorded facts together with the program’s own facts, which every application of yields anyway, and computes , one round per application, until a round adds nothing:

Each contains : contains , since always yields and the program’s facts, and monotonicity carries each inclusion one round further. For the family, starting from the five parent facts:

roundnew ancestor factshow
1tom–bob, tom–liz, bob–ann, bob–pat, pat–jimfirst rule, one per parent fact
2tom–ann, tom–pat, bob–jimsecond rule, the firing of the previous chapter
3tom–jimsecond rule, using bob–jim from round 2
4nonestop

Round 4 derives only facts already present, so and is a fixed point, with the nine ancestor facts the declarative reading gave.

The fixed point where the rounds stop is the least one, . Every lies inside any closed set that contains : does, since puts the program’s facts in alongside , and if then , by monotonicity and then closure. So the last set, , is inside every closed set containing , and it is closed itself, since . It is the least closed set. The rounds compute exactly the facts with derivations: a fact enters in round when its shortest derivation needs rule steps above the recorded and program facts.

Why the rounds stop

The rounds stop because there are only so many facts to add. A rule’s terms are variables and literals, so every value in a derived fact is a value that appears in or in the program. There are finitely many such values, and a relation with columns over values has at most facts. Every round before the last adds at least one new fact, so the number of rounds is at most the number of possible facts, plus the final round that adds none.

The catalogue with the mistaken entry has four courses, so before holds at most facts. Its rounds go around the cycle one step at a time:

roundnew before facts
14: each direct requirement, such as pl–compilers
24: two steps around the cycle, such as data_structures–compilers
34: three steps, such as intro–compilers
44: four steps, back to the start, such as compilers–compilers
5none

After round 4, before holds all sixteen pairs, and len(solutions before(?p, ?c)) is 16. A fifth step around the circle arrives at pairs already present. The cycle lets derivations grow without bound, but it cannot make the set of facts grow without bound, and evaluation ends as soon as the set stops growing.

Naive and semi-naive evaluation

Computing in full re-fires every rule against every fact, including the facts that produced nothing new last time. This is naive evaluation, and it re-derives what it already knows. Counting the rule instances it finds for the family, one per complete substitution:

roundfirst rulesecond ruletotalnew
15055
25383
35491
45490

Thirty-one rule instances derive nine facts. The first rule finds the same five instances in every round, and the second re-derives every instance it found in earlier rounds each time it runs.

A rule instance that derives a fact new in round must use at least one premise that was new in round . If every premise had been known before round , the instance would have been found in round already, and its fact would not be new. So each round needs only the instances with at least one premise from the previous round’s new facts, called the delta. Semi-naive evaluation fires each rule once for each relation atom in its body, with that atom matched against the delta and the others against all the facts. The first round’s delta is all of .

For the family, the first rule’s only atom reads parent, whose delta is empty after round 1, so the rule finds nothing after its first round. The second rule has two atoms, parent(x, y) and ancestor(y, z). No rule derives parent facts, so after round 1 the firing with parent(x, y) reading the delta finds nothing, and only the firing with ancestor(y, z) reading the delta can match: 3 instances in round 2, 1 in round 3, and 0 in round 4, which stops it. The total is 5 + 3 + 1 + 0, nine instances for nine facts. Both strategies compute the same rounds and the same ; semi-naive evaluation skips instances that can contribute nothing.

The counts depend on the strategy and not only on the program. Counting the steps of an evaluation is the subject of Cost semantics, and for relations the count is a property of the engine that computed the answer as well as of the query.

Bridger’s evaluation

A Bridger query reads for the in force when it is asked. The engine starts from the facts add recorded and the program’s body-less rules, fires the rules with bodies round by round until a round adds nothing, and matches the query against the result. The rules apply to whatever facts are present at the time, so the same rules answer for a different family or a different catalogue, and for the catalogue before and after the mistaken entry.

Nothing between two changes to changes , so an engine may compute it once and answer every query until the next add or clear from that one computation. The language reference allows this explicitly. A rule body that could observe how often it ran would make the reuse visible, which is part of why the bodies are restricted, a restriction Rule bodies and safety spells out.

Building it

The engine is one method, solutions, which the query forms of eval_expr call:

pub fn solutions(&mut self, q: &Query, span: Span, env: &Env)
    -> Result<Vec<Subst>, Control>
{
    todo_m6!("solutions: least fixpoint of the rules over base + dynamic facts, …")
}

It returns every distinct assignment to the query’s named holes under which the query holds, in canonical order. The env and span are for the query itself, which is handled as add was in Building it: the relation’s name is resolved in env, where anything other than a relation is stuck with NotARelation, each QArg::Expr argument is evaluated in env to the constant it contributes, and an argument count other than the relation’s column count is stuck with ArityMismatch at span. The pieces come from the previous chapters and the provided runtime:

self.program().rules                 // every rule; a body-less one is a base fact
self.db.facts(relation) -> &[Tuple]  // Δ: the facts add recorded
self.db.derived()                    // a closure stored since the last add or clear
self.db.set_derived(closure)         // store one
Subst::unify(&self, pattern, fact)   // yours, from Unification

The work is to collect the facts in and the program’s base facts, then run rounds: fire each rule with a body by carrying substitutions through its relation atoms with unify, apply each complete substitution to the head, and add what is new, stopping when a round adds nothing. The tables above collect all of a round’s heads before adding any, so each round fires against throughout; adding heads as they appear also reaches , in rounds that no longer match the tables. Naive rounds are enough: the starter’s tests pass with them, and semi-naive rounds compute the same facts in fewer instances. Storing the closure with set_derived is optional; add and clear discard a stored closure themselves, and an engine that recomputes for every query is correct, only slower. The query is then matched against its relation’s facts, as in Unification. Each match is cut down to the query’s named holes, which drops the variables standing for anonymous holes, and duplicates are removed, so that solutions parent(?p, ?) has three solutions and not five. The provided compare, the canonical order on values, then sorts the solutions by their hole values, first hole first. A rule body may also contain conditions that are not relation atoms; Rule bodies and safety adds them to the rounds.

The design space

Searching from the query

Prolog evaluates from the query downward. It unifies the query with a clause head, then tries to establish the body’s atoms in order, depth first, backing up to try the next clause when an atom fails. On the catalogue with the cycle, asked whether compilers comes before itself, the search follows the second rule from compilers to pl, then to data_structures and intro, where it finds requires("intro", "compilers") and answers yes. Asked whether art, a course the catalogue does not mention, comes before compilers, the search follows the same rule from compilers to pl, data_structures, intro, back to compilers, and on around the circle without end. Nothing tells it that it has been there before, and so it can never conclude that the answer is no. The same rules, evaluated in rounds, stop after five, and before("art", "compilers") is false.

The search from the query has an advantage the rounds lack: it explores only what the query leads to. Asking whether pl comes before compilers in the catalogue without the cycle touches one fact, where the rounds compute all of before. Some Prolog systems combine the two by recording each subgoal they meet together with the answers found for it; a subgoal met again reuses the record instead of being searched again, and with that a cycle no longer loops. Datalog systems approach the same combination from the other side, rewriting the rules for a given query so that bottom-up rounds derive only the facts the query can reach.

Adding and clearing

Since is monotone, adding facts to can only enlarge : everything derived before is still derived. An engine could therefore answer the query after an add by continuing from the stored closure, with the added facts as the delta of one more semi-naive round, instead of starting over. clear gives no such shortcut. Removing a fact can remove facts derived from it, but a derived fact may have a second derivation that does not use the removed fact, and telling the two cases apart takes more than the closure records. Database systems that keep derived relations up to date as their inputs change do this bookkeeping. The reference engine recomputes after either change, which is correct for both.

Fixpoints elsewhere

Least fixpoints are not particular to relations. Part I’s grow computed one over sets of nodes. The language reference defines which Bridger functions are pure as a least fixpoint over the call graph, the analysis Rule bodies and safety depends on. Semantics that gives a recursive function’s definition a meaning as a mathematical function takes the least fixed point of the definition, read as a function from approximations to better approximations. The theory behind all of these is the subject of the further reading below.

Further reading

The semantics of logic programs: van Emden and Kowalski’s “The Semantics of Predicate Logic as a Programming Language” (1976) defines the immediate-consequence operator and proves that its least fixed point is the least model of a Horn-clause program. The argument is short and mostly set-theoretic.

Evaluating recursive queries: Bancilhon and Ramakrishnan’s “An Amateur’s Introduction to Recursive Query Processing Strategies” (1986) compares naive, semi-naive, and query-directed strategies on common examples. Abiteboul, Hull, and Vianu’s Foundations of Databases, freely available from the authors, develops naive and semi-naive evaluation in its chapter 13, “Evaluation of Datalog”.

Fixed points in semantics: Tarski’s “A Lattice-Theoretical Fixpoint Theorem and its Applications” (1955) is the theorem in its general form; the argument is carried in the algebra of lattices. Glynn Winskel’s The Formal Semantics of Programming Languages (MIT Press, 1993) builds the same idea into the meaning of recursive programs, with the mathematics introduced along the way.

Concept checks

In the catalogue without the mistaken entry, which before facts does each round add, and how many rounds run?

Round 1 adds the three direct requirements: pl–compilers, data_structures–pl, and intro–data_structures. Round 2 adds the two-step facts, data_structures–compilers and intro–pl. Round 3 adds intro–compilers. Round 4 adds nothing, so four rounds run and before holds six facts. solutions before(?p, "compilers") reads three of them, and before(?c, ?c) finds none, since no chain of requirements returns to its start.

Semi-naive evaluation never fires a rule with all of its premises drawn from facts older than the previous round. Why can it not miss a fact?

Take a fact that is first derived in round , by some rule instance. If all of that instance’s premises had been present before round , the same instance would have fired in round , and the fact would have been derived then, not in round . So at least one premise was new in round , which means it is in the delta, and semi-naive evaluation fires the rule with that atom reading the delta. Every instance it skips derives only facts that were already known.

A catalogue has n courses. What bounds the number of rounds evaluating before can take?

before has two columns over at most courses, so it can hold at most facts. Each round before the last adds at least one, so there are at most rounds that add something, and one final round that adds nothing. The bound comes from counting possible facts and holds for any rules over those facts, cycles or not. On a chain of requirements with a cycle through all courses, the rounds actually take steps around the circle and one more to confirm nothing new.

After an add, an engine can continue from the closure it already has. Why can it not do the same after a clear?

is monotone, so more facts in give a larger least fixed point that contains the old one: every old derivation still works, and the new facts only start new ones, which a semi-naive round from the stored closure finds. Removing facts can invalidate derived facts, but only those with no remaining derivation. The closure records which facts hold, not how many ways each was derived, so it cannot say which derived facts survive. The safe course is to recompute from the remaining .

On the catalogue with the cycle, asking whether art comes before compilers makes Prolog's search run forever and Bridger's evaluation stop after five rounds. What does each one keep track of that the other does not?

Bridger’s rounds keep the set of facts derived so far, and a round that adds nothing to the set is the signal to stop. Going around the cycle again produces facts already in the set. Prolog’s depth-first search keeps only the current chain of subgoals and the clauses left to try at each, not the facts it has already established, so meeting the same subgoal a second time starts the same search again. What it does keep, the current path, is what lets it answer one query without computing everything.

Rule Bodies and Safety

Unification ended with a sibling rule that listed ann as ann’s own sibling. Nothing in the rule said that its two people had to differ, and a relation atom has no way to say it. The program below adds that condition. age(p, a) records that person p is a years old, and each rule ends with a condition that is an ordinary Bridger expression:

relation parent  : (String, String);
relation age     : (String, Int);
relation sibling : (String, String);
relation adult   : (String);

rule sibling(x, y) :- parent(p, x) and parent(p, y) and x != y;
rule adult(p)      :- age(p, a) and a >= 18;

fn main() {
    add parent("tom", "bob");  add parent("tom", "liz");
    add parent("bob", "ann");  add parent("bob", "pat");
    add parent("pat", "jim");
    add age("tom", 71);  add age("bob", 44);  add age("liz", 40);
    add age("pat", 19);  add age("ann", 17);  add age("jim", 1);

    println(solutions sibling(?x, ?y));
    println(solutions adult(?p));
}

It prints:

[("ann", "pat"), ("bob", "liz"), ("liz", "bob"), ("pat", "ann")]
["bob", "liz", "pat", "tom"]

If a body can hold any expression, other rules come to mind. note below prints a name and returns true. With next_age, related, noted, and childless declared as relations, these rules all parse, but Bridger rejects each of them before main runs:

fn note(p: String) -> Bool { println(p); true }

rule next_age(p, b)  :- age(p, a) and b == a + 1;
rule related(x, y)   :- parent(x, z);
rule noted(p)        :- age(p, a) and note(p);
rule childless(p)    :- age(p, a) and not parent(p, ?);

next_age is rejected because logic variable b “is not bound by any generator in this rule”, and related with the same message for y. noted and childless are rejected with “this filter is not pure”. These rejections raise the following questions:

  • What gives a variable its values? x != y uses values that parent(p, x) and parent(p, y) found. b == a + 1 looks as if it could supply a value for b, yet it is rejected.
  • What would a rule mean if a head variable had no source of values? related says nothing about y, so which facts would it derive?
  • What may a condition do? A condition can run many times, in an order and a number of rounds that the engine chooses. What would a program see if a condition printed or read a ref cell?
  • Why can a body ask for a fact to be present and never for one to be absent? Whether someone is childless looks like a simple question to ask of parent.

The problem with related first surfaced in logic-based database query languages, before Datalog-style rules were introduced.

Queries with no finite answer

A query written in the predicate calculus describes the values it wants. Nothing in the notation limits what that description may be, so it can ask for every that is not a parent:

If ranges over all strings, the answer is infinite. If ranges over the values in the database, the answer is finite but depends on facts unrelated to the question: recording the age of a new person adds that person to the answer. Abiteboul, Hull, and Vianu’s Foundations of Databases trace this concern in the database literature back at least to Kuhns’s work of 1967, which studied the formulas whose answers depend only on the stored relations.

No test can pick out exactly those formulas. Di Paola proved in 1969 that the question is undecidable. The response was a series of syntactic conditions, each accepting only formulas with the property and each turning away some others that have it too. Codd’s relational calculus of 1972 restricted its variables to ranges over stored relations. Jeffrey Ullman’s Principles of Database Systems defined safe formulas. Jean-Marie Nicolas’s range-restricted formulas (1982) require each variable to be tied to a relation in a form that can be read off the formula. Allen Van Gelder and Rodney Topor compared several of these classes in 1991 and gave translations from the formulas they accept into the relational algebra.

Datalog’s definition carries the same idea. Each variable in a rule’s head must also appear in its body, so a rule with no body must have a ground head. The word safety has stayed attached to conditions of this kind.

Generators and filters

A conjunct in a rule body is one of two things. A generator is a relation atom, such as parent(p, x). Matching it against the facts binds its variables, as in Unification. A filter is any other expression, such as x != y. It is evaluated under a substitution and yields true or false, which keeps or drops that substitution. A filter binds no variable, whatever its shape, and that is why b == a + 1 leaves b without a value. Here == is the comparison it is everywhere else in the language: it needs both sides to have values already.

Firing a rule therefore works as it did in the last two chapters, with one extra step. Carry the substitutions through the generators, then keep the ones under which every filter is true. The operator from Evaluating relations gains one condition. Writing a rule’s generators as and its filters as :

is the program’s top-level environment extended by the substitution. For sibling, the generators produce nine substitutions, ann paired with ann among them, and x != y keeps the four whose two people differ. Applying each to the head gives the four facts the program printed.

The filter does not disturb the argument for monotonicity. If , a substitution that finds its generators’ facts in finds them in . A filter’s result depends on and nothing else, so a filter that accepted for accepts it for . Every fact derived from is derived from , and is still monotone. This argument assumes that a filter’s result depends only on , a requirement that Purity enforces.

Binding belongs to the body as a whole, and the position of a filter does not limit what it can see. This rule has the filter first and derives the same four facts:

rule sibling(x, y) :- x != y and parent(p, x) and parent(p, y);

Wherever x != y sits in the body, the generators supply the substitutions before any filter runs. The order of filters among themselves can still matter when one of them can get stuck, as Conjunct order shows.

Safety

A rule is range-restricted when every variable in its head and every variable in its filters is bound by a generator of that rule. A filter may also refer to top-level constants and functions, which are visible everywhere. Writing for the variables a rule’s generators mention, and for the top-level names, the conditions are:

A rule that meets them is safe. related fails the first condition: y appears in the head but in no generator. rule big(n) :- n > 100; fails it too, and so does a rule with no body and a variable in its head, rule everyone(x);.

What an unsafe rule would mean

Reading related(x, y) :- parent(x, z) logically gives: for all x, y, and z, if x is a parent of z then related(x, y). Taken at its word, related("tom", y) holds for every string y, so the relation would have infinitely many facts, which solutions cannot list.

Limiting y to the strings in the database makes the set finite, but at the price the calculus query paid: unrelated facts change the answer. add age("sue", 3) would then add related("tom", "sue"), related("bob", "sue"), and related("pat", "sue"), from a fact about someone’s age in a rule that mentions neither ages nor sue.

An engine computing has a more immediate problem. Applying a substitution to related(x, y) leaves y in place, but Bridger’s matching depends on every fact being ground.

What safety guarantees

Take a safe rule and any substitution that satisfies its body. Every variable in the head is bound by a generator, so it has a value taken from a fact. Every other term in the head is a literal. So the derived fact is ground, and each value in it was already in a fact or written in the program. By induction over the rounds, every value in appears in or in the head of a rule.

That is the premise of the count in Why the rounds stop: finitely many values, so finitely many possible facts, so finitely many rounds. Filters add nothing to the count, since they contribute no values. The bound comes from the rule’s shape, and it holds for any facts the program may add while it runs.

Purity

The meaning of a query is a set determined by and . The engine is free to compute it in naive or semi-naive rounds, to choose the order of a body’s generators, and to reuse a closure until the next add or clear. Each of those freedoms relies on filters having no effect and depending on nothing except the substitution.

Suppose this rule were accepted, with limit a top-level ref:

ref limit = 18;
rule adult(p) :- age(p, a) and a >= deref limit;

After limit := 65, is unchanged. An engine that reuses its closure would give the old answer, while one that recomputes for every query would give a new one. Since the filter’s result depends on the store, is no longer a function of its set of facts. A filter that printed would print once for each substitution the strategy happened to try. The previous chapter counted 31 rule instances for naive rounds and 9 for semi-naive ones on the same rules, and an effect in a filter would turn that difference into visible output.

A filter must therefore be pure: free of effects, and a deterministic function of the variables the generators bind.

The purity judgment

The judgment reads: given the set of functions known to be pure, is pure. Literals and variables are pure, an operator is pure when its operands are, and a call is pure when it names a function in and its arguments are pure:

The other effect-free forms, such as if, match, tuples, and field access, are pure when their parts are. Some forms have no purity rule, so any expression containing one is impure: ref, deref, :=, while, for, add, clear, solutions, and a query. Calls to input and output primitives are impure as well, and so is to_string, because converting a ref cell to a string reads its current contents. A call through a parameter, a local, or a computed closure has no instance, because the analysis cannot tell what it will run. The language reference has the complete list.

Which functions are pure

comes from the program. Let be the smallest set of function names such that whenever f’s body contains a form with no purity rule, or calls a function in ; is every function outside it. This is another least fixpoint: start from the empty set, add what the condition requires, and stop when nothing changes. Take these functions:

fn plain(a: Int) -> Bool = a > 1;
fn note(p: String) -> Bool { println(p); true }
fn check(p: String) -> Bool = note(p);
fn both(a: Int, p: String) -> Bool = plain(a) and check(p);
stepadded to because
1noteits body calls println
2checkit calls note
3bothit calls check
4nonestop

plain stays out, so holds only plain, and a > 1 and plain(a) are acceptable filters. A rule whose filter is both(a, x) is rejected, and the error follows the calls that put both in :

the effect is reached through `both` → `check` → `note` → `println`

The steps in the table are the rounds of a relational program. has_effect records the functions whose bodies directly contain an impure form, as note’s contains println, and calls records the call graph. The rules have the same shape as ancestor:

relation calls      : (String, String);
relation has_effect : (String);
relation impure     : (String);

rule impure(f) :- has_effect(f);
rule impure(f) :- calls(f, g) and impure(g);

fn main() {
    add has_effect("note");
    add calls("check", "note");
    add calls("both", "plain");
    add calls("both", "check");
    println(solutions impure(?f));
}

It prints ["both", "check", "note"], the set in the table. Datalog’s use in program analysis rests on this correspondence. The checker you build computes the same set directly and also keeps the path for the error.

Filters must finish

A pure function can still run forever. fn spin(n: Int) -> Bool = spin(n); has no effects, but calling it in a filter would hang the engine, whose rounds rely on every filter returning. So no function a filter reaches may recurse, directly or through others, and Bridger rejects this filter with is recursive, so a filter calling it might not terminate:

fn is_even(n: Int) -> Bool = if n == 0 { true } else { is_odd(n - 1) };
fn is_odd(n: Int) -> Bool = if n == 0 { false } else { is_even(n - 1) };

rule even_aged(x) :- age(x, a) and is_even(a);

Like the conditions in the calculus, this check accepts only some of the programs that would work. On the ages above, is_even always returns, but on a negative argument it counts away from zero. The check looks at the call graph and never at the arguments. a % 2 == 0 tests the same thing with no recursion. For the same reason, a filter may not reach a function that takes a function-typed parameter, since the analysis cannot follow what such a function will call. The prelude’s own map, filter, fold, and the functions written over them are trusted to finish.

A filter can still get stuck: 100 / (a - 1) > 0 divides by zero when a is 1. Termination and reaching a value are separate properties, and these checks establish only the first. A filter that gets stuck makes the query, and the program, stuck.

Bridger’s rule bodies

Telling generators from filters

parent(p, x) and is_adult(a) have the same syntax, so the parser cannot sort them. The distinction comes from name resolution: a conjunct is a generator when it is a whole call whose callee names a declared relation, and every other conjunct is a filter. Bridger’s single namespace for top-level names guarantees that no name is both a relation and a function.

A body is its and-chain however it is bracketed, so age(x, a) and (parent(x, y) and a > 18) has three conjuncts. A generator’s arguments must be variables or literals, as a head’s must, so age(p, a - 1) is rejected with a relation's argument in a rule body must be a variable or a literal.

A relation applied anywhere else is a query, and a query is impure. So a body reads the database only through generators standing as whole conjuncts. (a < 18 or a >= 65) is a good filter, and with the facts above it selects ann, jim, and tom. parent(x, y) or parent(y, x) is a single conjunct and therefore a filter, with no generator to bind x. Two rules say the same thing and are accepted, since a relation with several rules reads as an “or”:

rule related(x, y) :- parent(x, y);
rule related(x, y) :- parent(y, x);

What a filter can see

A rule is a top-level definition and closes over the top-level environment, as a fn body does. It sees the variables its generators bind and the program’s top-level names, but never the locals where a query is asked:

let voting_age = 18;
rule adult(p) :- age(p, a) and a >= voting_age;

Moving voting_age into main as a let makes the rule an error, since no generator binds voting_age. If a rule could read a query’s locals, one set of facts and rules could have a different meaning at each query site. A generator-bound variable of the same name as a top-level definition shadows it inside the rule.

Typing a rule

Rules are checked by the same type checker as the rest of the program. A generator gives each of its variables the type of that column. Where a variable appears in several columns, those types must agree, so age(x, a) and parent(x, a) is rejected at the second a with type mismatch: expected String, found Int. The column’s declared type is the expected type, and the type a already has in the rule’s context is the found type. Head terms are checked against the head relation’s columns. Each filter is checked against Bool with the rule’s variables in the context, so a > "ten" and a filter of a + 1 are both type errors.

Conjunct order

Generators and filters can be written in any order without changing which facts a body derives. Filters evaluate left to right, as operands do everywhere else in the language: for each substitution the generators produce, the language reference runs a rule’s filters in the order written and stops at the first that yields false. Since a substitution that fails one filter derives nothing, this order makes no difference when no filter gets stuck. It does matter when one can. With jim at age 1, this rule finds its answer, because a != 1 removes jim before the division runs:

rule r(x) :- age(x, a) and a != 1 and 100 / (a - 1) > 0;

With the two filters swapped, the query is stuck with division by zero.

Building it

The checks are yours, in relations/check.rs. The provided driver calls them once per rule when the program is loaded, before main:

struct Rule { head: Atom, body: Vec<Expr>, span: Span }

pub fn check_generators(program: &Program, rule: &Rule)
    -> Result<Vec<bool>, RuleError>;
pub fn check_range_restriction(program: &Program, rule: &Rule, generators: &[bool])
    -> Result<(), RuleError>;
pub fn check_pure(program: &Program, rule: &Rule, generators: &[bool])
    -> Result<(), RuleError>;

body holds every conjunct as an ordinary Expr, so a generator is an Expr::Call that has not yet been recognised as one. check_generators classifies the conjuncts, answering with one bool per conjunct, and checks that the head and each generator name a declared relation with the right number of arguments and that each generator’s arguments are terms. The driver hands that answer to the other two checks, so neither has to resolve relation names again. check_range_restriction collects the variables the generators bind and compares the head’s and the filters’ variables against them. check_pure computes the impure set over the program’s call graph, then judges each filter, and also rejects a filter that reaches a recursive function or one with a function-typed parameter. The failures are the RuleError variants in the language reference. Checker::check_rule, in types/check.rs, gives the rule its types as in Typing a rule.

The engine from Evaluating relations gains the filter step. After the generators have been joined, the filters are evaluated by eval_expr left to right for each remaining substitution, in the program’s root environment (env.root()) extended with that substitution’s bindings. true moves on to the next filter, and false drops the substitution without evaluating the filters after it. A rule with filters and no generators starts from the one empty substitution, so rule r(1) :- 2 > 1; derives its fact once; a join that began with no substitutions at all would derive nothing. Filters read no facts, so semi-naive rounds choose a delta among a rule’s generators alone.

The design space

Absence in a body

Bridger’s rejected childless rule asks for a fact to be absent. To see what that does to the rounds, suppose person holds recorded facts for the six people, and suppose this version were accepted:

rule has_child(x) :- parent(x, y);
rule childless(x) :- person(x) and not has_child(x);

Run the rounds of the previous chapter on the family:

roundnew has_child factsnew childless facts
1tom, bob, patall six: has_child is still empty when the body reads it
2nonenone

The rounds stop with childless("tom") in the set beside has_child("tom"). Rounds only add, so nothing withdraws the fact derived too early. has lost its monotonicity: a larger set of facts can give fewer consequences, and the chain no longer leads to the intended meaning.

Some programs lose the meaning itself. With a person named tom, consider:

rule stays(x)  :- person(x) and not leaves(x);
rule leaves(x) :- person(x) and not stays(x);

The set holding person("tom") and stays("tom") is closed: tom stays, so the second rule does not fire. The set holding person("tom") and leaves("tom") is closed for the mirror-image reason. Neither contains the other, and the only set inside both, person("tom") alone, is not closed, since it fires both rules. With two minimal closed sets and nothing closed inside both, these rules have no least closed set to serve as .

Stratification separates the two programs. Give each relation a layer number such that a rule’s head is in a layer at least as high as each relation it uses positively, and strictly higher than each relation it negates. childless can go above has_child. stays would have to be above leaves and leaves above stays, so no numbering exists for them. A program with a numbering is stratified: a cycle among its relations never passes through a negation. Evaluation computes one layer at a time, each to its own least fixpoint, so a negated relation is complete before anything reads its absence. Several researchers proposed this independently in the 1980s, among them Chandra and Harel and Apt, Blair, and Walker, who showed that the meaning does not depend on which numbering is chosen. Negated atoms also need safety: x must be bound by a positive generator, or not has_child(x) asks for every string that has no child, the calculus query from the start of this chapter.

Reading absence as falsehood was itself a decision. Keith Clark’s negation as failure and Raymond Reiter’s closed-world assumption, both published in 1978, examine when a system may conclude that what it cannot derive is false. Prolog’s \+ succeeds when the search for its goal fails. Datalog systems such as Soufflé offer stratified negation and reject a program with no numbering. SQL restricts recursion in a similar direction: SQLite, for example, rejects a recursive query that refers to itself inside NOT IN. Other semantics assign a meaning to programs like stays and leaves, at the cost of a meaning that is no longer a plain least fixpoint.

Bridger leaves negation over relations out of rules, but a host program can do the layering itself. A query in ordinary code is free to be negated, because the closure it reads is complete by the time it is asked:

relation person    : (String);
relation has_child : (String);
relation childless : (String);

rule person(x)    :- age(x, a);
rule has_child(x) :- parent(x, y);

fn find_childless() {
    clear childless;
    for person(?x) {
        if not has_child(x) { add childless(x); }
    }
}

After find_childless(), solutions childless(?x) is ["ann", "jim", "liz"]. Host code’s statement order is exactly the order between layers. The result is a snapshot: after add parent("jim", "sue") the query still lists jim, until find_childless() runs again and gives ["ann", "liz"]. A stratified rule would stay current by itself. The host version keeps every rule’s meaning a least fixpoint and places the sequencing where a program already has sequence.

Computing values in a body

next_age asks a body to compute a value. Datalog systems such as Soufflé read something like b == a + 1 as a binding, with = giving a new variable the result of the arithmetic. That reading gives up the bound on values. From the single fact person("tom"), this rule, were it allowed, would derive "tomtom", then "tomtomtomtom", doubling the string each round until memory ran out:

rule person(x) :- person(y) and x == y ++ y;

Arithmetic gives a slower version of the same problem: b == a + 1 would add a new integer each round, stopping only when the 64-bit integers wrapped around. A filter that limited the string’s length would make the rule stop, but telling those two apart is a harder problem than checking that a variable appears in a generator. Ramakrishnan, Bancilhon, and Silberschatz (1987) studied conditions under which recursive rules over such computed relations are safe. Prolog’s is evaluates arithmetic during the search and leaves termination to the programmer, as with its other control.

Bridger’s terms remain variables and literals, and the computation happens in host code:

for age(?p, ?a) { add next_age(p, a + 1); }

That for runs once over a fixed set of solutions and adds six facts. A host loop around it could still run forever, but that keeps non-termination in the part of the language where it already exists. The rules themselves keep the guarantee.

Aggregates

A rule that counted a person’s descendants would run into both of the preceding problems. Suppose a rule could hold count in its head, and watch tom’s descendants arrive in the rounds of the previous chapter: two after round 1, four after round 2, five after round 3. Facts are never withdrawn, so descendants("tom", 2), descendants("tom", 4), and descendants("tom", 5) would all hold. A count of a set that can grow is not monotone unless the count waits for the set to be complete. The counts are also values no fact contains, so they fall outside the finite set of values safety establishes.

Systems that allow aggregates usually place them under the layering discipline, so that a counted relation is complete first. SQL’s GROUP BY works over a complete input, and SQLite rejects an aggregate over a query’s own recursive reference with recursive aggregate queries not supported. Other work keeps recursion through an aggregate by asking which aggregates can be treated as growing. Kenneth Ross and Yehoshua Sagiv’s monotonic aggregation (1992) gave such an account, and Flix lets rules compute over ordered values that climb toward a fixed point, alongside sets of facts. Datafun records monotonicity in its types, so that a checker can confirm a recursive definition grows.

Bridger declines aggregates in rules and computes them in host code over a finished answer: len(solutions ancestor("tom", ?d)) is 5. Querying relations covers that style.

When order decides safety

Prolog has no static check of this kind, and the order of a clause’s conditions determines whether they have values to work with. These two clauses differ only in order:

sibling(X, Y) :- parent(P, X), parent(P, Y), X \= Y.
sibling(X, Y) :- X \= Y, parent(P, X), parent(P, Y).

The first behaves like Bridger’s rule. In the second, X \= Y runs first, on two unbound variables, and it succeeds only when they cannot be unified. Unbound variables can, so the clause finds nothing and reports no error. N is M + 1 with M unbound raises an error at run time. Prolog programmers learn which conditions need which variables bound.

Bridger’s check is made once, before the program runs, and asks only whether a generator somewhere binds the variable. In return, a query that finishes derives the same facts wherever its filters are written; reordering them can only change whether a stuck filter is reached. The check also rejects more than Prolog would: a program cannot use == to bind.

Tracking effects

Prolog’s clause bodies may call write, and a condition re-run on backtracking prints again. Programmers use this for tracing, and accept that the output describes the search. Bridger’s analysis takes the opposite position for rule bodies, and its form is a choice too. Function types record no effects, so the checker works out purity from the whole program’s call graph, for the one place the semantics needs it. Languages such as Haskell record effects in types, which makes purity visible at every call and requires every signature in a chain to say so. Bridger’s needs no annotations and reports a witness path, though a programmer reading a signature cannot tell from it whether the function may be used in a filter.

Further reading

Safety in query languages: Abiteboul, Hull, and Vianu’s Foundations of Databases, freely available from the authors, treats the problem and its syntactic conditions in chapter 5, “Adding Negation: Algebra and Calculus”, whose exercises work through several of the classes. Van Gelder and Topor’s “Safety and Translation of Relational Calculus” (1991) compares the conditions and translates accepted queries into the algebra.

Negation: chapter 15 of Foundations of Databases develops stratified semantics and the well-founded semantics, which gives a meaning to programs with no stratification, using three truth values. Clark’s “Negation as Failure” and Reiter’s “On Closed World Data Bases” both appear in Gallaire and Minker’s Logic and Data Bases (1978).

Beyond sets of facts: Madsen, Yee, and Lhoták’s “From Datalog to Flix” (2016) defines rules over lattices and applies them to program analysis. Arntzenius and Krishnaswami’s “Datafun: A Functional Datalog” (2016) combines a typed functional language with least fixed points, tracking monotonicity in the type system.

Concept checks

In rule elder_sibling(x, y) :- parent(p, x) and parent(p, y) and age(x, a) and age(y, b) and a > b;, which conjuncts bind which variables, and what does the rule derive for the family?

The four relation atoms are generators: they bind p, x, y, a, and b. a > b is a filter, which binds nothing but reads two variables the generators bound. The head’s x and y are bound, so the rule is safe. It derives ("bob", "liz"), since 44 is greater than 40, and ("pat", "ann"), since 19 is greater than 17. The filter also removes each person paired with themselves, so no x != y is needed.

Bridger rejects rule copy(x) :- parent(y, z) and x == y;, even though x could only ever equal y. What could be accepted instead, and what would it take to accept this form?

rule copy(y) :- parent(y, z); says the same thing and is safe. To accept the original, the check would have to treat an equality with one bound side as a binding. For a plain variable that is sound, since the value still comes from a fact, and some Datalog systems do it. The same reading for x == y ++ y, however, lets a rule produce values no fact contains, so a language that adopts it must decide where to draw the line and how to keep the guarantee that is finite. Bridger keeps one reading of ==, as a test.

In a program, f calls g, g calls f and println, and h calls nothing. Compute I. Which error would each be in a filter?

Step one adds g, whose body calls println. Step two adds f, which calls g. The next step adds nothing, so and holds h. A filter calling f or g is reported as not pure, with a witness path ending at println, while h is accepted. Had g not called println, the same two functions would be outside , since the least fixpoint adds only what an effect forces in, and the filter would then be rejected for recursion. Purity and termination are separate conditions on a filter.

Can the relations person, has_child, childless be given layer numbers? Can stays and leaves? What does the answer say about the existence of a meaning?

The first can: person and has_child in layer 0, childless in layer 1, since it negates has_child. Computing layer 0 to its fixpoint and then layer 1 gives one answer. stays negates leaves and leaves negates stays, so each would have to be strictly above the other, and no numbering exists. Those rules have two minimal closed sets, neither contained in the other, so there is no least one. Stratification picks out programs for which a layer-by-layer meaning exists. A language that accepts the others needs a semantics of a different kind.

find_childless gives a stale answer after an add, and a stratified rule would not. What does Bridger get in exchange for making programmers do the layering themselves?

Every rule set keeps a single least-fixpoint meaning, which the rounds compute with no analysis of dependencies between relations, and stays monotone, which is what lets an engine continue from a closure after an add. The non-monotone step is visible in the program as a not in ordinary code, in a sequence the programmer wrote. The cost is that the programmer must rerun that step whenever its inputs change, which a stratified engine would do by itself, and that the answer is held as stored data, apart from any rule that defines it.

Querying Relations from the Host Language

Rule bodies and safety left three jobs to ordinary code: computing new values, testing for absence, and aggregating. To do any of them, that code has to ask a query and read its answer. The program below extends the course catalogue from Evaluating relations. requires(c, p) and before(p, c) mean what they meant there. credits(c, n) records that course c is worth n credits, and cost(c, p, n) holds when p, worth n credits, must be taken before c. load takes a list of (course, credits, required courses) tuples and adds the matching credits and requires facts. credits_needed totals the credits that must come before a course:

relation requires : (String, String);
relation credits  : (String, Int);
relation before   : (String, String);
relation cost     : (String, String, Int);

rule before(p, c)  :- requires(c, p);
rule before(p, c)  :- requires(c, q) and before(p, q);
rule cost(c, p, n) :- before(p, c) and credits(p, n);

fn load(catalogue: [(String, Int, [String])]) {
    for entry in catalogue {
        add credits(entry.0, entry.1);
        for p in entry.2 { add requires(entry.0, p); }
    }
}

fn credits_needed(course: String) -> Int =
    fold(solutions cost(course, ?p, ?n), 0, |total, row| total + row.1);

fn main() {
    load([("intro", 4, []),
          ("discrete", 3, []),
          ("data_structures", 4, ["intro"]),
          ("pl", 3, ["data_structures", "discrete"]),
          ("compilers", 3, ["pl"])]);

    println(before("discrete", "compilers"));
    println(solutions before(?p, "compilers"));
    for requires("pl", ?p) { println("pl requires " ++ p); }

    println(credits_needed("compilers"));
    println(solutions cost("compilers", ?, ?n));
    println(fold(solutions cost("compilers", ?, ?n), 0, |total, n| total + n));
}

It prints:

true
["data_structures", "discrete", "intro", "pl"]
pl requires data_structures
pl requires discrete
14
[3, 4]
7

credits_needed and the last two lines of main both ask about the courses before compilers. Their queries differ only in ?p and ?, but the first total is 14 and the second is 7.

The answers raise these questions:

  • How does a local value reach a query? A rule cannot see a query’s locals, yet credits_needed uses its parameter course inside a query.
  • What type does a query have? before appears once as a Bool and once inside a list of strings, and requires supplies a loop variable. How does the type checker know which is which?
  • Why did the total change? What did replacing ?p with ? remove from the answer?
  • What if the facts change while a program is using an answer? A for body can add to the relation it walks, and a list from solutions can outlive the facts it was computed from.

Moving answers between a query language and a programming language became a practical problem as soon as relational databases had application programs to serve.

Two languages in one program

A relational query describes a whole set of rows, but the programming languages of the 1970s handled data one record at a time. IBM’s System R project connected the two by letting a PL/I or Cobol program contain SQL statements. A preprocessor took the statements out and compiled them before the host program was compiled. The connection between a statement and its host was the cursor: a program opened a query, then fetched its rows one at a time into host variables, so that a set-valued answer became a loop.

Joachim Schmidt’s Pascal/R (1977) put relations inside the language as a data type. Its selections combined Pascal’s sets with the quantifiers of logic, and this one from a later report by Matthias Jarke and Schmidt reads as a set definition:

enames := [<e.ename> OF EACH e IN employees: (e.estatus = professor) AND …]

The friction between the two kinds of language acquired a name, impedance mismatch, usually credited to a 1984 paper by George Copeland and David Maier. William Cook and Ali Ibrahim’s later survey lists what the term covers, including imperative programs against declarative queries and data structures against relations and indexes. The approaches they compare include queries passed to a database as text at run time, queries embedded in a host program and handled by a preprocessor, and persistent objects. Erik Meijer, Brian Beckman, and Gavin Bierman’s LINQ (2006) gave .NET languages a query syntax that compiles into calls to standard operators, so that the compiler can type-check a query as it does other code.

Bridger’s relations and host code share one parser, one type checker, and one set of values. The mismatch that remains is between a query’s answer, a set that exists all at once, and a program that runs one step at a time.

A query as an expression

A query form evaluates its bound arguments first. Each argument that is not a hole evaluates to a value, left to right, threading the store and as a call’s arguments do, and those values become constants in the query. The query then reads , using the left by evaluating those arguments.

A solution assigns a value to each of the query’s named holes (?x). An assignment is a solution when some choice of values for the anonymous holes (?) puts the complete fact in . An anonymous hole therefore requires a value to exist, but the solution does not record which value it was. Writing for the set of solutions to in , and using the catalogue above:

query hassolutions of it
before("intro", "pl")the empty assignment[()]
before("pl", "intro")nothing[]
before(?p, "pl")three assignments to p["data_structures", "discrete", "intro"]
requires(?c, ?)three assignments to c["compilers", "data_structures", "pl"]
requires("pl", ?p)two assignments to p["data_structures", "discrete"]
before(?c, ?c)nothing[]

A hole named twice is one variable, so before(?c, ?c) asks for a course that comes before itself. requires(?c, ?) has four matching facts but three solutions, because pl requires two courses and the assignment c = "pl" is counted once.

solutions turns the set into a list in canonical order, and below is the value that solution contributes to the list, whose shape depends on the number of named holes, as the table below sets out:

A bare query yields whether the set is empty:

A relational for reads the set once, from the it starts with, then runs its body once per solution with the holes bound. Everything the body does to the store and to passes on to the next turn:

A return in the body abandons the remaining solutions, by the same convention as a for over a list.

The form of a query fixes its type, and the relation’s declaration supplies the rest. A bound argument is checked against its column, and a hole takes its column’s type. T-Solutions and T-For-Query take T-Query as a premise, so all three look up the relation and check bound arguments the same way:

In these rules, are the query’s distinct named holes, in order of first appearance. solutions before(?c, ?c) has one named hole, so its type is [String]. A hole named twice takes the type of both columns, so they must agree: r(?x, ?x) on a relation of (Int, String) is a type error.

The number of named holes fixes the shape of each element in E-Solutions and T-Solutions:

named holestype of solutions q
none()[()]
onethe hole’s value[τ]
two or morea tuple of the values[(τ₁, …, τₖ)]

solutions cost(course, ?p, ?n) therefore has type [(String, Int)], which is why the lambda in credits_needed can read row.1 as an Int. Only T-For-Query extends the context. In the other two forms a hole’s name goes nowhere.

Bridger’s query forms

From the host into a query

An argument is the route from a local into a query. cost(course, ?p, ?n) evaluates course where the query is asked and looks for facts with that value in the first column. A rule sees only top-level names, because its meaning has to be the same at every query site. The argument is evaluated before is read, and that value is the only thing the query takes from its surroundings, so one rule set can answer credits_needed for any course.

A for evaluates its arguments once, however many solutions follow. In this program, next prints a line and returns one more than it did the last time, starting from 1:

relation e : (Int);

ref calls = 0;

fn next() -> Int {
    calls := deref calls + 1;
    println("next");
    deref calls
}

fn main() {
    add e(1);
    add e(2);
    add e(3);
    for e(?x) { if x == next() { println(x); } }
    for e(next()) { println("hit"); }
}

The first loop calls next once per turn. next returns 1, 2, and 3, each matching x, so the program prints next and the value three times. The second loop calls next once, printing next a fourth time. Its query is e(4), which has no solution, so its body never runs.

A hole always introduces a new name. In the following code, the second argument is the local c and the first is a new variable of the same name:

let c = "pl";
println(solutions before(?c, c));
for before(?c, c) { println(c); }
println(c);

This prints the three courses before pl as a list, then the same three on separate lines, then pl. Inside the body, the hole’s c shadows the local, and after the body the local is visible again.

A hole-free query looks exactly like a function call, and the parser treats it as one. It becomes a query when its callee evaluates to a relation, which adds a case to E-App. Name resolution works as it does for any other name. If a local shadows a relation, as let before = |a: Int, b: Int| a + b; does, then before(1, 2) is an ordinary call inside that scope, and solutions before(?x, 2) is rejected because before no longer names a relation. A relation’s name is not otherwise a value, so let r = before; is rejected.

Holes bind only in a for

A bare query answers yes or no, and its holes bind nothing, so this program is rejected at println(c), where c is an unbound variable:

if before(?c, ?c) { println(c); }

A query can have any number of solutions, so a binding here would have to pick one, and nothing in the else branch or under a not could use it. A program that wants one of the solutions asks for the list. The first in canonical order is head(solutions before(?c, ?c)), an Option that unwrap_or opens for now and pattern matching opens in Part VIII.

Walking a fixed set

E-For-Query computes the solutions once, before the first turn, and the body’s changes to do not alter that list. Queries inside the body read the current , however. friend below starts with two facts, and the body adds each one’s reverse:

relation friend : (Int, Int);

fn main() {
    add friend(1, 2);
    add friend(2, 3);
    for friend(?a, ?b) {
        add friend(b, a);
        println(len(solutions friend(?x, ?y)));
    }
    println(solutions friend(?a, ?b));
}

This prints 3, 4, and then [(1, 2), (2, 1), (2, 3), (3, 2)]. The walk made two turns, for the two facts it started with. The reversed facts it added were visible to the inner query but did not join the walk. A clear in the body behaves the same way: with e holding 1, 2, and 3, for e(?x) { clear e; println(x); } prints all three values and leaves e empty.

Here add cannot make a loop run longer. With n declared as relation n : (Int);, this loop adds a larger number for each fact, a step a rule may not take, and it ends after two turns, printing [1, 2, 11, 12]:

add n(1);
add n(2);
for n(?x) { add n(x + 10); }
println(solutions n(?x));

The value of solutions is an ordinary list, so it holds the answer from the moment of the query. In let found = solutions e(?x); add e(4);, found keeps its old contents, and a new query sees the new fact.

From a query into the host

The answer to solutions can be handed to any function that takes a list, so a count is len, a sum is fold, and a largest value is maximum. Asking about each course in turn gives the effect of a SQL GROUP BY:

map(solutions credits(?c, ?), |c| (c, credits_needed(c)))

which is

[("compilers", 14), ("data_structures", 4), ("discrete", 0),
 ("intro", 0), ("pl", 11)]

The two totals in the opening program come from what the holes keep. cost("compilers", ?p, ?n) has four solutions, since each assignment names a course:

[("data_structures", 4), ("discrete", 3), ("intro", 4), ("pl", 3)]

cost("compilers", ?, ?n) names only n, and there are only two distinct assignments to it, 3 and 4. Dropping ?p removed the values that made the four rows different, so the fold over [3, 4] computes 7. Both programs type-check, and both run without error.

Working across the boundary

A degree plan needs a value no rule can compute. A course’s earliest semester is 1 when it requires nothing, and otherwise one more than the latest semester among the courses it requires. semester(c, n) holds the result, ready(c) is true when c has no semester yet and all that it requires have one, and each pass of schedule’s while assigns one semester’s courses:

relation semester : (String, Int);

fn ready(c: String) -> Bool =
    not semester(c, ?)
        and filter(solutions requires(c, ?p), |p| not semester(p, ?)) == [];

fn schedule() {
    clear semester;
    ref n = 1;
    while len(solutions semester(?c, ?)) < len(solutions credits(?c, ?)) {
        for c in filter(solutions credits(?c, ?), ready) {
            add semester(c, deref n);
        }
        n := deref n + 1;
    }
}

fn main() {
    load([("intro", 4, []),
          ("discrete", 3, []),
          ("data_structures", 4, ["intro"]),
          ("pl", 3, ["data_structures", "discrete"]),
          ("compilers", 3, ["pl"])]);

    if before(?c, ?c) {
        println("the catalogue has a cycle");
    } else {
        schedule();
        println(solutions semester(?c, ?t));
    }
}

It prints:

[("compilers", 4), ("data_structures", 2), ("discrete", 1), ("intro", 1), ("pl", 3)]

Rules compute before to any depth and always stop, even over a cycle. not tests for absence, which a rule may not do, and deref n puts a computed integer into a fact, which a rule may not do either. schedule is also a loop to a fixpoint, like the rounds of Evaluating relations, but written by hand, and none of the guarantees about rules apply to it. Without the check in main, add requires("intro", "compilers") would leave compilers, data_structures, intro, and pl never ready. The while would run forever, while before(?c, ?c) still answers at once.

ready asks queries, so it is impure, and a rule’s filter could not call it, but host code can. The facts in semester are a snapshot as well: an add requires(…) after schedule() leaves semester as it was, until schedule() runs again.

Building it

The remaining cases of eval_expr rest on the solutions method from Evaluating relations. The arms call it and read the substitutions it returns, and it evaluates a query’s arguments itself, so an arm must not evaluate them as well.

The Rel::Query case of the Relation arm implements E-Query-True and E-Query-False. The Solutions case implements E-Solutions. It works out the query’s named holes, each distinct name once, in order of first appearance, then builds one value per substitution: () for no named holes, the bound value for one, and a tuple in hole order for more. The substitutions are already in canonical order.

The ForQuery arm is the second walk promised in Conditionals and loops, over this node from the AST:

Expr::ForQuery(Query, Box<Expr>, Span)

It takes the list of substitutions once. For each one, it extends the environment at the for with the substitution’s bindings, using Subst::iter, and runs the body, discarding the value. A Control::Return from the body passes straight out. The arm yields Value::Unit.

The Call arm from Evaluating function calls gains the case that section promised. Evaluate the callee first, as before. If the result is a Value::Relation, wrap the unevaluated arguments as QArg::Exprs and treat the call as a hole-free query with that name.

The type checker needs the matching arms, in types/check.rs. T-Query, T-Solutions, and T-For-Query look up the relation’s column types, check each bound argument against its column, and give each hole its column’s type. The language reference lists the stuck states.

The design space

How a query reaches the database

A call-level interface such as Java’s JDBC passes a query as a string:

String sql = "SELECT anc FROM ancestor WHERE des = '" + name + "'";
ResultSet rows = statement.executeQuery(sql);
while (rows.next()) { System.out.println(rows.getString("anc")); }

The Java compiler sees two strings and a variable. A misspelled column surfaces when the line runs, and getString picks a result by a name and a type that nothing has checked. Because name is pasted into the query’s text, a name containing a quote can change what the query says, which is the mechanism of SQL injection. Parameterized queries address that by passing values separately from the text, as Bridger’s arguments do.

A program can also build a string while it runs. A search form with optional criteria can add a WHERE clause for each box the user filled in. Bridger’s query has its shape fixed in the source: which relation it reads and which columns are holes cannot depend on a value, so a program with several shapes writes each one. Cook and Ibrahim conjecture that the lack of such dynamic queries is the main reason most forms of embedded SQL were abandoned. LINQ’s query operators are ordinary method calls, so a program can decide at run time whether to apply one, and the result is still type-checked.

Relations as values

let r = before; is an error in Bridger, and every query reads the one global . A function taking no parameters can therefore return a different answer after an add, as it could after a write to a global ref. Two families at once need an extra column saying which family a fact belongs to, and clear resets a relation for every part of the program that uses it.

Flix makes Datalog programs values. Madsen and Lhoták’s paper shows a function returning sets of facts and rules, which the program combines with <+>, passes to solve, and then narrows with project:

let facts = getParents() <+> getAdoptions();
let model = solve(facts <+> heritage(withAdoptions));
project AncestorOf model

A program can hold two sets of facts at once, and it can choose its rules at run time, as heritage does here with a Bool. The price is in the type system, which uses row polymorphism to describe a fragment of a program that mentions only some relations. Combining fragments at run time can also produce recursion through negation, which their design has to detect at compile time. Bridger’s rules are fixed when the program is written, which lets the checks on rule bodies run once before main, and leaves one closure to reuse between changes to .

Changing facts during a walk

In the 1995 reunion of the System R team, Pat Selinger and Don Chamberlin recalled a bug from 1976 or 1977. An update meant to give a raise to low-paid employees was scanning those employees through an index on salary. Each raise moved a row further along the index, where the scan met it again. By Chamberlin’s account, a ten percent raise for everyone under 25,000 dollars finished without error with everyone earning 25,000. The team discussed it at a meeting that happened to fall on Halloween, and it has been the Halloween problem ever since. Database systems now keep an update’s rows apart from the scan that finds them, which is the effect E-For-Query specifies.

Other languages decide this differently. Python detects the change and stops: adding a key while iterating over a dictionary raises RuntimeError with dictionary changed size during iteration. Prolog systems settled on what Lindholm and O’Keefe called the logical view of a changing database, in which a goal already running keeps seeing the clauses that existed when it started, which is close to Bridger’s rule. If a Bridger walk included new facts, for n(?x) { add n(x + 10); } would never end. A program that wants a fixpoint in host code writes a while, as schedule does, and the while is where the program can fail to stop.

Computing all of it

A Bridger query computes its whole answer. before("discrete", "compilers") could stop at its first derivation, and first_big below could stop at the first large value. Instead, each computes the closure and sorts the full list before the first turn or the first true:

fn first_big(limit: Int) -> Int {
    for e(?x) { if x > limit { return x; } }
    -1
}

A SQL cursor can deliver rows as the engine finds them, without waiting for the rest of the answer. Prolog finds one solution at a time and looks for the next on backtracking, and once stops after the first. Both make an early exit cheaper, though SQL’s rows have no guaranteed order without ORDER BY, and Prolog’s solutions come in the order of its search. A sorted list that does not depend on evaluation strategy has to exist in full before the first element is known. Cost semantics counts what that costs.

Sets or bags

Relations in Codd’s model are sets, but SQL tables and query results can hold duplicate rows. The same four credit values in a SQLite table give different answers depending on what the query asks for: SELECT SUM(x) gives 14, and SELECT SUM(DISTINCT x) gives 7. SQL’s default keeps a row per match, so a sum needs no key column, and a query that lists values can repeat them until DISTINCT removes them. Prolog offers both: findall keeps one answer per proof, and setof sorts its answers and removes duplicates.

Bridger’s answers are always sets. The meaning of a query stays a set of facts, and a list of solutions never reflects how many derivations a fact had or the order they were found. The cost appears in the opening program: a sum across the four courses has to keep the course among its named holes, or courses with the same credits collapse into one solution.

Further reading

The mismatch: Cook and Ibrahim’s “Integrating Programming Languages & Databases: What’s the Problem?” sets out criteria for a solution and evaluates persistent languages, object-relational mappers, call-level interfaces, and embedded queries against them.

System R: Chamberlin and colleagues’ “A History and Evaluation of System R” (1981) covers its phases, including the interface to host languages. The 1995 reunion transcript is the team’s own conversation about the project, the Halloween problem among much else.

Queries inside a language: Jarke and Schmidt’s working paper (1982) describes Pascal/R’s selections and how to evaluate them efficiently. Meijer, Beckman, and Bierman’s “LINQ” (2006) is a short summary of the .NET design.

Datalog as a value: Madsen and Lhoták’s “Fixpoints for the Masses” (2020) gives a lambda calculus with first-class Datalog constraints, its type system, and a soundness proof. A copy is on the author’s page.

Concept checks

In the catalogue, what do solutions cost(?c, "intro", ?n), solutions cost(?c, "intro", ?), and cost("intro", ?, ?) evaluate to, and what is the type of each?

The first is [("compilers", 4), ("data_structures", 4), ("pl", 4)], of type [(String, Int)]: the three courses that come after intro, each with intro’s credits. The second names only c, so it is ["compilers", "data_structures", "pl"], of type [String]. The third is a bare query, of type Bool, and is false, because nothing comes before intro.

Replace the for in schedule with for credits(?c, ?) { if ready(c) { add semester(c, deref n); } }. The walk's set is still fixed. Which courses change semesters, and why?

pl moves from semester 3 to 2, and compilers from 4 to 3. The set of courses is fixed, but ready queries semester afresh on each turn, so it sees the facts earlier turns added. In semester 2, the walk reaches data_structures before pl in canonical order, adds it, and then finds pl ready in the same semester. The original computes the whole filter before adding anything, so each semester is decided from the ones before it. This variant’s result also depends on the alphabetical order of the courses’ names.

A fold over solutions credits(?, ?n) gives 7 for the catalogue. What does the query compute, and how would you get the total credits of all five courses?

It computes the distinct credit values, [3, 4], and sums those. The courses’ names were dropped from the solutions, and with them the difference between the courses that share a value. Naming the hole, solutions credits(?c, ?n), keeps one solution per course, and fold adds row.1 to give 17. Which holes are named decides what counts as one answer.

Suppose if before(?c, ?c) { println(c); } were accepted, binding c in the branch. What would the language have to decide, and what would it give up or gain?

It would have to decide which solution binds c when there are several, presumably the first in canonical order, and what c means under not, in the else branch, and in before(?c, ?c) or other(?c), where the hole might not be bound at all. The gain is a shorter way to ask for a witness, and the form resembles a Prolog goal, which does bind its variables. Bridger’s version separates the two questions: a bare query only tests, and getting values takes a for or solutions, whose types say how many answers there are.

Suppose a relational for walked facts as they were added, so its body's additions joined the walk. Which programs in this chapter would behave differently, and what property would the language lose?

The friend loop would still end, since its added facts only reverse pairs over the same values, though it would make four turns where it now makes two. for n(?x) { add n(x + 10); } would never end, as each turn adds a fact for a later turn, which is the shape of the Halloween problem. With a fixed set, a relational for always stops once its body does, as a for over a list does, and only while and recursion can run without bound. A live walk would also make the answer depend on the order of turns, which a fixed canonical list keeps out.

Milestone M6

Relations


Under Construction


Sum and Product Types


Under Construction


Pattern Matching


Under Construction


Exhaustiveness Checking


Under Construction


Milestone M7

Algebraic Data Types and Pattern Matching


Under Construction


Structs with Methods


Under Construction


Dispatch and Encapsulation


Under Construction


Traits vs. Inheritance


Under Construction


Milestone M8

Structs, Methods, and Traits


Under Construction


Cost Semantics


Under Construction


The Paradigm Challenge


Under Construction


Synthesis: The Branches Recombined


Under Construction


Appendix A — Rust for Interpreter Writers

You do not need prior Rust to use this book. Each construct is introduced where the book first needs it, and the AST chapter sets the pattern with enum and match. This appendix is the alternative for a reader who would rather learn the required basics in one go. It covers the parts of Rust the book actually uses.

Milestone M0 is a warm-up exercise that goes with this chapter: a small program (a coin purse) that puts everything below to work. Read a section here, then proceed to the exercise.

Every example below has a play button: run it, change it, run it again to help you learn.

enum and match

An enum is a value that is one of a fixed set of variants, and each variant can carry its own data. It is the single most important type in this book: a grammar production becomes an enum variant, and a runtime value’s shape becomes one too.

enum Shape {
    Circle(f64),        // a radius
    Rect(f64, f64),     // a width and a height
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => 3.14159 * r * r,
        Shape::Rect(w, h) => w * h,
    }
}

fn main() {
    let s = Shape::Rect(3.0, 4.0);
    println!("{}", area(&s));   // 12
}

match takes an enum apart: each arm names a variant and binds the data that variant carries (r, or w and h), and the arm’s body is what that case evaluates to. A match satisfies two properties:

  • It is an expression: it produces a value, letting you compute a value as let x = match … { … }.
  • It is exhaustive: the arms must cover every variant, or the program does not compile. Delete the Rect arm above and run it: the compiler names the case you left out. When a new variant is added to an enum, the compiler lists every match that now has a gap, which is what makes a program built on enums safe to grow.

A pattern can go deeper than one level, match several values at once by pairing them in a tuple, and fall through with _, the wildcard that matches anything:

fn describe(pair: (i64, i64)) -> &'static str {
    match pair {
        (0, 0) => "origin",
        (x, 0) => if x > 0 { "east" } else { "west" },
        (0, _) => "on the y-axis",
        _      => "somewhere else",
    }
}

fn main() {
    println!("{}", describe((3, 0)));   // east
    println!("{}", describe((1, 1)));   // somewhere else
}

An arm may also carry a guard, an if that further restricts when it fires:

fn sign(n: i64) -> &'static str {
    match n {
        0 => "zero",
        n if n > 0 => "positive",
        _ => "negative",
    }
}

fn main() { println!("{}", sign(-4)); }   // negative

A fieldless enum — variants that carry no data — is the natural way to name a small fixed set of options. To compare, copy, print, or use one as a hash-map key, derive the traits it needs:

#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum Coin { Penny, Nickel, Dime, Quarter }

fn cents(c: Coin) -> u64 {
    match c {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {
    println!("{}", cents(Coin::Quarter));   // 25
    println!("{:?}", Coin::Dime);           // Dime  (from Debug)
}

#[derive(…)] asks the compiler to write the obvious implementation of a trait for you: Debug for {:?} printing, PartialEq/Eq for ==, Hash to use the type as a map key, Clone/Copy to duplicate it freely. Coin is the enum M0 is built on.

struct

Where an enum is a value that is one of several shapes, a struct is a value that bundles several fields together, each with its own name. Methods live in an impl block:

struct Purse {
    pennies: u64,
    nickels: u64,
}

impl Purse {
    fn new() -> Purse {             // an associated function: Purse::new()
        Purse { pennies: 0, nickels: 0 }
    }

    fn value(&self) -> u64 {        // a method: p.value()
        self.pennies * 1 + self.nickels * 5
    }
}

fn main() {
    let mut p = Purse::new();
    p.pennies = 3;
    p.nickels = 2;
    println!("{}", p.value());      // 13
}

An impl block collects the functions that work on a type. One taking &self (or &mut self) is a method, called with dot syntax (p.value()); one without a self parameter is an associated function, called through the type (Purse::new()) and used the way other languages use a constructor. A fixed struct like this is one of the representations M0 offers for its purse.

Collections: Vec and HashMap

Where a value holds a variable number of parts, it reaches for a collection. A Vec<T> is a growable array, taken apart by iterating rather than by a fixed pattern:

fn sum(xs: &Vec<i64>) -> i64 {
    let mut total = 0;
    for x in xs {
        total = total + x;
    }
    total
}

fn main() { println!("{}", sum(&vec![1, 2, 3, 4])); }   // 10

A HashMap<K, V> maps keys to values. entry(k).or_insert(d) hands back a mutable slot for key k, inserting the default d first if the key was absent — the idiom for tallying:

use std::collections::HashMap;

fn main() {
    let coins = ["penny", "dime", "penny", "penny", "dime"];
    let mut tally: HashMap<&str, u64> = HashMap::new();
    for c in coins {
        *tally.entry(c).or_insert(0) += 1;
    }
    println!("{}", tally["penny"]);   // 3
    println!("{}", tally["dime"]);    // 2
}

A Vec<Coin> records every coin individually; a HashMap<Coin, u64> records each kind with a count. Both, and the fixed struct above, are representations M0 lets you choose between.

Ownership, borrowing, and mutability

Rust has no garbage collector and no manual free. Instead every value has a single owner, and when the owner goes out of scope the value is released. This is the part of Rust that most often surprises a newcomer, and getting a feel for it is much of what M0 is for.

Assigning or passing a value moves it: ownership transfers, and the old name can no longer be used. For the small Copy types — i64, bool, char, and simple enums that derive Copy — the value is copied instead, so both names stay valid. To use a value without taking ownership, you borrow it with a reference:

  • &T is a shared reference — read-only, and you may hold many at once.
  • &mut T is an exclusive reference — you may write through it, and while it exists no other reference to the same value may.
fn total(xs: &Vec<i64>) -> i64 {       // borrows: caller keeps the Vec
    xs.iter().sum()
}

fn push_zero(xs: &mut Vec<i64>) {      // borrows exclusively: may mutate
    xs.push(0);
}

fn main() {
    let mut v = vec![1, 2, 3];
    println!("{}", total(&v));   // 6   — shared borrow
    push_zero(&mut v);           //     — exclusive borrow
    println!("{:?}", v);         // [1, 2, 3, 0]
}

Two rules do most of the work. A binding is immutable unless you write let mut, so a value can be changed only where mutation is asked for by name. And a value may have many readers or one writer, never both at once — the compiler enforces it, ruling out a whole class of aliasing bugs before the program runs. A lifetime — occasionally written 'a — is the compiler’s name for how long a borrow stays valid; it exists to guarantee a reference never outlives the value it points at, and in the code this book asks you to write it rarely has to be named.

M0’s combine leans on the move rule directly: taking its two purses by value consumes them, so Rust then stops you from reusing a purse you have already emptied into another. An operation that changes the purse borrows it as &mut; one that only reads it borrows as &.

Shared ownership with Rc

Single ownership fits a value with one clear home. Some structures instead need a value reachable from several places at once, with no one holder entitled to free it while another still points at it. Rc<T> — a reference-counted pointer — is the standard library’s answer. Rc::new(v) places v on the heap behind a shared handle; Rc::clone(&h) hands back another handle to the same v without copying it and adds one to a count of live holders, and v is released only when the last handle is dropped and the count reaches zero. An Rc<T> gives shared, read-only access (&T) to what it holds, so it does not by itself allow mutation — the many-readers-or-one-writer rule stays intact.

use std::rc::Rc;

fn main() {
    let a = Rc::new(vec![1, 2, 3]);
    let b = Rc::clone(&a);                 // same Vec, one more holder
    println!("{} {}", a.len(), b.len());   // 3 3
    println!("{}", Rc::strong_count(&a));  // 2
}

Option and Result

Two enums from the standard library carry the possibility of “no value” in the value itself, so a caller cannot ignore it:

enum Option<T> { None, Some(T) }    // a T, or nothing
enum Result<T, E> { Ok(T), Err(E) } // a T, or an error E

Option<T> models a value that might be absent; Result<T, E> a computation that might fail, carrying why in the E. Both are ordinary enums — you take them apart with match:

fn checked_div(a: i64, b: i64) -> Option<i64> {
    if b == 0 { None } else { Some(a / b) }
}

fn main() {
    match checked_div(10, 2) {
        Some(q) => println!("quotient {}", q),   // quotient 5
        None    => println!("undefined"),
    }
    println!("{:?}", checked_div(10, 0));         // None
}

The ? operator

Threading a failure through several steps by hand nests match inside match. The ? operator does it in one character: applied to a Result, e? unwraps Ok(v) to v and, on Err(x), returns Err(x) from the enclosing function at once. The happy path then reads top to bottom:

fn checked_div(a: i64, b: i64) -> Result<i64, String> {
    if b == 0 {
        return Err("divide by zero".to_string());
    }
    Ok(a / b)
}

fn eval(a: i64, b: i64, c: i64) -> Result<i64, String> {
    let x = checked_div(a, b)?;   // an Err bubbles straight out here
    let y = checked_div(x, c)?;
    Ok(y)
}

fn main() {
    println!("{:?}", eval(100, 5, 2));   // Ok(10)
    println!("{:?}", eval(100, 0, 2));   // Err("divide by zero")
}

This is the exact shape an interpreter takes: evaluate a subexpression, and if it failed, propagate that failure — its message and the source it points at intact — without the surrounding code having to mention the failure at all. Values and runtime errors is where the book puts this pattern to work.

A handful of combinators are worth knowing, each a shorthand for a match that appears constantly:

  • opt.unwrap_or(d) — the value inside, or d if it is None.
  • opt.map(f) / res.map(f) — apply f to the value inside, leaving None / Err untouched.
  • res.ok() — turn a Result into an Option, discarding the error.

Reach for match when the cases do different things, and a combinator when one line says it more plainly.

Where to go next

Practice your rust skills in Milestone M0: a coin purse built from the enum, struct, collection, and borrowing pieces above. When its tests pass, the tools run and the language is familiar enough to start the interpreter at Milestone M1.

To go deeper into Rust itself — well past what this book needs — the 100 Exercises to Learn Rust work through the language one small, test-driven step at a time.

Milestone M0

The Rust Warm-Up

Unlike the milestones that follow, this one builds nothing of the language itself. It is a warm-up: a way to get the tools working and the metalanguage familiar before the interpreter starts. If you already write Rust comfortably, read the specification below, confirm your solution against the self-check tests, and move on to Milestone M1. Work through Appendix A alongside this milestone.

What you will build

A coin purse: a little Rust program for holding coins and counting money. It exercises the enum, struct, match, collection, and ownership patterns every later milestone rests on, and it asks you to make one representation decision: the same kind of decision the interpreter turns on again and again. Nothing here touches Bridger.

Getting Rust running

Install Rust with rustup, then start a fresh library project:

cargo new --lib coin_purse
cd coin_purse
cargo test      # runs the sample test cargo generated

If it reports one passing test, the toolchain is ready. You will write the whole coin purse in src/lib.rs: replace its contents with the scaffold at the end of this chapter and fill in the parts marked for you.

The coins

A coin is one of four kinds, each worth a fixed number of cents:

#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum Coin { Penny, Nickel, Dime, Quarter }
CoinCents
Penny1
Nickel5
Dime10
Quarter25

The derive line is what lets a coin be copied, compared with ==, printed with {:?}, and used as a HashMap key — Appendix A explains each trait. The worth of a coin is a match over Coin, written as the free function cents, and every operation below reuses it.

The purse

A purse is a collection of coins, and it is a struct. Its interface is fixed — the methods in the scaffold, with the signatures given there. What the struct holds is yours to choose: this is the first design decision the book asks of you, the same choice, in miniature, that Values and runtime errors makes for the interpreter’s own values. Three reasonable representations, each making some operations easy and others a loop:

  • a Vec<Coin> — every coin listed individually; add is one push, value a fold over the coins;
  • a HashMap<Coin, u64> — each kind paired with how many the purse holds; combine merges counts;
  • four u64 fields, one count per kind, since the set of coins is finite and known; value is then four multiplications.

Pick one, and be ready to say what it made easy and what it made harder.

The operations, and what each is doing:

  • value — sum cents over the coins the purse holds. Borrows the purse (&self).
  • count — how many coins in total. Borrows the purse. It is what lets a test confirm exchange actually reduced the coin count.
  • add — put one coin in. Mutates the purse (&mut self).
  • remove — take one coin of kind c out if present, returning true; return false when the purse held none of that kind. Mutates the purse.
  • combine — take both purses by value (self, other: Purse) and return one holding every coin of each. Taking them by value is natural here: combining consumes them, and Rust’s move rule then stops you from using a purse you have already emptied into another.
  • exchange — return a purse of the same total value using the fewest coins. With these denominations the greedy rule is optimal: take as many quarters as fit, then dimes, then nickels, then pennies. (That greedy step is not optimal for every possible set of denominations; it is for these four, which is the assumption you may rely on.)

Worked examples

Purse::new().value()                         =  0     // empty
Purse::new().count()                         =  0

{Quarter, Dime, Nickel, Penny}.value()       =  41
{Quarter, Dime, Nickel, Penny}.count()       =  4

{Dime × 4}.exchange()   =  {Quarter, Dime, Nickel}      // 40¢: 4 coins -> 3
{Penny × 30}.exchange() =  {Quarter, Nickel}            // 30¢: 30 coins -> 2

{Quarter}.combine({Dime, Nickel})  =  {Quarter, Dime, Nickel}   // value 40

exchange never changes what a purse is worth — only how many coins carry that worth.

The scaffold

Here is src/lib.rs in full. Fill in the struct’s fields and every todo!() body; the tests are written for you. todo!() is a macro that compiles as any type but panics (runtime error) if it is ever reached, so the file builds even if you haven’t filled in any of the missing components. Try replacing the todo!()s one at a time and running cargo test as you go.

// src/lib.rs — coin purse (Milestone M0)
// Fill in the fields of `Purse` and every `todo!()`. Run `cargo test`.

// use std::collections::HashMap;   // uncomment for the HashMap representation

#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum Coin { Penny, Nickel, Dime, Quarter }

/// The worth of a single coin, in cents.
fn cents(c: Coin) -> u64 {
    todo!()
}

struct Purse {
    // Your representation. Pick one and delete the others:
    //   coins: Vec<Coin>,                          // every coin, listed
    //   counts: HashMap<Coin, u64>,                // each kind, a count
    //   pennies: u64, nickels: u64,                // one count per kind
    //   dimes: u64, quarters: u64,
}

impl Purse {
    /// An empty purse.
    fn new() -> Purse { todo!() }

    /// Put one coin into the purse.
    fn add(&mut self, c: Coin) { todo!() }

    /// Take one coin of kind `c` out, if the purse has one.
    /// Reports whether a coin was actually removed.
    fn remove(&mut self, c: Coin) -> bool { todo!() }

    /// The total worth of the purse, in cents.
    fn value(&self) -> u64 { todo!() }

    /// How many coins the purse holds in total.
    fn count(&self) -> u64 { todo!() }

    /// One purse holding every coin of `self` and `other`.
    fn combine(self, other: Purse) -> Purse { todo!() }

    /// A purse of the same total value using the fewest coins.
    fn exchange(&self) -> Purse { todo!() }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_purse_is_worth_nothing() {
        let p = Purse::new();
        assert_eq!(p.value(), 0);
        assert_eq!(p.count(), 0);
    }

    #[test]
    fn value_and_count_add_up() {
        let mut p = Purse::new();
        p.add(Coin::Quarter);
        p.add(Coin::Dime);
        p.add(Coin::Nickel);
        p.add(Coin::Penny);
        assert_eq!(p.value(), 41);
        assert_eq!(p.count(), 4);

        // another of a kind already present keeps counting
        p.add(Coin::Penny);
        assert_eq!(p.value(), 42);
        assert_eq!(p.count(), 5);
    }

    #[test]
    fn remove_reports_whether_it_found_a_coin() {
        let mut p = Purse::new();
        p.add(Coin::Quarter);
        p.add(Coin::Dime);

        assert_eq!(p.remove(Coin::Quarter), true);   // was there
        assert_eq!(p.remove(Coin::Quarter), false);  // now gone
        assert_eq!(p.remove(Coin::Nickel), false);   // never there
        assert_eq!(p.value(), 10);                   // only the dime
        assert_eq!(p.count(), 1);
    }

    #[test]
    fn combine_holds_every_coin_of_both() {
        let mut a = Purse::new();
        a.add(Coin::Quarter);
        let mut b = Purse::new();
        b.add(Coin::Dime);
        b.add(Coin::Nickel);

        let c = a.combine(b);            // consumes a and b
        assert_eq!(c.value(), 40);
        assert_eq!(c.count(), 3);
    }

    #[test]
    fn combine_with_empty_changes_nothing() {
        let mut a = Purse::new();
        a.add(Coin::Dime);
        let c = a.combine(Purse::new());
        assert_eq!(c.value(), 10);
        assert_eq!(c.count(), 1);
    }

    #[test]
    fn exchange_preserves_value_and_minimizes_coins() {
        let mut p = Purse::new();
        for _ in 0..30 { p.add(Coin::Penny); }       // 30¢ in 30 coins

        let mut q = p.exchange();
        assert_eq!(q.value(), 30);                    // same money
        assert_eq!(q.count(), 2);                     // a quarter, a nickel
        assert!(q.remove(Coin::Quarter));             // exactly those two
        assert!(q.remove(Coin::Nickel));
        assert_eq!(q.count(), 0);
    }

    #[test]
    fn exchange_of_four_dimes() {
        let mut p = Purse::new();
        for _ in 0..4 { p.add(Coin::Dime); }          // 40¢ in 4 coins
        let q = p.exchange();
        assert_eq!(q.value(), 40);
        assert_eq!(q.count(), 3);                     // quarter, dime, nickel
    }

    #[test]
    fn exchange_of_a_minimal_purse_keeps_the_count() {
        let mut p = Purse::new();
        p.add(Coin::Quarter);
        p.add(Coin::Nickel);                          // already minimal, 30¢
        let q = p.exchange();
        assert_eq!(q.value(), 30);
        assert_eq!(q.count(), 2);
    }

    #[test]
    fn exchange_of_empty_is_empty() {
        let q = Purse::new().exchange();
        assert_eq!(q.value(), 0);
        assert_eq!(q.count(), 0);
    }
}

Any correct implementation should satisfy the following two properties:
p.exchange().value() == p.value(), and p.exchange().count() <= p.count().
I.e., exchanging a purse always gives a purse of equal value using a fewer or equal number of coins.

Appendix B: Bridger Concrete Grammar

This appendix gives Bridger’s concrete grammar: the rules that decide which strings of characters are Bridger programs and how each one groups into its parts. The grammar is presented one sublanguage at a time; the final section collects every production into one listing.

The distinction between the text as written and the structure underneath it (concrete versus abstract syntax) is the subject of Syntax and semantics; the grammar here describes the concrete side, which strings the provided parser accepts as a program. The abstract syntax that parser produces, the values a program computes, and the rules that give them meaning (evaluation, typing, and purity) are fixed formally in the language reference. In the AST chapter, we further detail the abstract syntax of Bridger including the Rust enum output by the provided Bridger parser.

Reading the notation

The productions are written in a variant of Extended Backus–Naur Form. A production names a syntactic category on the left of ::= and gives its forms on the right, built from the terminals, other categories, and the metasyntax below:

{ x }     zero or more repetitions of x
[ x ]     an optional x
( x )     grouping
x | y     either x or y
'…'       a terminal, written verbatim
<desc>    an informally described set of characters
(* … *)   a comment

Identifiers, Literals, Comments

Bridger uses two identifier conventions: snake_case for variables, functions, relations, struct fields, and methods, UpperCamelCase for type names, constructors, and traits. We denote the two classes of identifiers ident and UpperIdent, respectively.

(* snake_case *)
ident       ::= letter_lc { letter_lc | digit | '_' ( letter_lc | digit ) } ;
(* UpperCamelCase *)
UpperIdent  ::= letter_uc { letter | digit } ;

letter      ::= letter_lc | letter_uc ;
letter_lc   ::= 'a' | 'b' | … | 'z' ;
letter_uc   ::= 'A' | 'B' | … | 'Z' ;
digit       ::= '0' | digit_nz ;
digit_nz    ::= '1' | '2' | … | '9' ;

An ident may separate words with single underscores, but may not start with, end with, or double underscores. For example, _foo, bar_, and foo__bar are not identifiers.

A Bridger program operates over integer, Boolean, string, and unit values. A literal is a string representing a concrete value. In Bridger, an integer literal is a decimal number (optionally with ‘_’ to group by thousands). Boolean literals are either “true” or “false” and unit has a single value denoted “()”. A string literal is a sequence of characters on a single line between quotation marks ". Any Unicode character may be written directly (in UTF-8 encoding); a quotation mark, backslash, or control character (such as a newline) is written with an escape: \n, \t, \r, \0, \", \\, or \u{…} for an arbitrary code point (\u{1F600} is 😀).

literal     ::= int_lit | bool_lit | string_lit | unit_lit ;
int_lit     ::= [ '-' ] ( '0' | pos_int ) ;
pos_int     ::= digit_nz { digit }
              | digit_nz [ digit [ digit ] ] { '_' digit digit digit } ;
bool_lit    ::= 'true' | 'false' ;
unit_lit    ::= '(' ')' ;
string_lit  ::= '"' { string_char } '"' ;
string_char ::= <any Unicode scalar value except '"', '\', or a control character>
              | escape ;
escape      ::= '\n' | '\t' | '\r' | '\0' | '\"' | '\\'
              | '\u{' hex_digit { hex_digit } '}' ;
hex_digit   ::= digit | 'a' | 'b' | … | 'f' | 'A' | 'B' | … | 'F' ;

An int_lit denotes a 64-bit signed integer, so a pos_int is at most 9223372036854775807 — or 9223372036854775808 when it follows -, since -9223372036854775808 is the least integer and is a literal only with its sign. The sign belongs to the literal when - stands directly before the digits and cannot be subtraction — that is, when the token before it is not an identifier, a literal, self, a closing bracket, or ?. Elsewhere - is an operator: x -1 subtracts, and - 1, with a space, negates 1. :- is one token wherever it appears, so a negative field value is written a: -1, with a space. Lists ([1, 2, 3]) and tuples ((a, b)) are built from subexpressions, so they appear under The expression language rather than here as literals.

Whitespace between tokens is any Unicode whitespace, line breaks included; it separates tokens and means nothing else, so layout is free. Comments are line comments, which run to the end of the line, and block comments, which nest. Inside a block comment every /* opens a nested comment and every */ closes one, so the delimiters must balance: /* /* */ */ is one comment, while /* /* */ is unterminated and /* */ */ closes after the first */, leaving a stray */.

line_comment  ::= '//' { <any character except a line break> } ;
block_comment ::= '/*' { block_comment
                       | <any character not beginning '/*' or '*/'> } '*/' ;

The keywords are reserved and may not be used as identifiers:

fn  let  if  else  while  match  type  struct  trait  impl  for  in
relation  rule  add  clear  solutions  not  and  or  true  false
ref  deref  self  Self  return  mut

Three of these are keywords only in one position: add, clear, and solutions are read as keywords when a relation name follows them (add edge(1, 2), clear edge, solutions path(0, ?x)) and as ordinary identifiers everywhere else, so set.add(x), fn clear(…), and a field or variable named solutions are all allowed; the one exception is solutions(r(?x)), which the parser rejects with a note that the keyword form takes no parentheses. mut is reserved without a use: it lets let mut x be explained rather than parsed, since a mutable cell is written ref x = e;.

Programs, definitions, and blocks

A program is a sequence of definitions: functions, types, structs, traits, impl blocks, relations, rules, and top-level let and ref bindings. Their order is free, since definitions are mutually visible, and each top-level name is declared once — a repeated name is rejected. Execution starts at main, an ordinary fn main() taking no arguments; a program that is run must define exactly one, and its result, whatever its type, is the program’s value.

program     ::= { def } ;

def         ::= fn_def | type_def | struct_def | trait_def | impl_def
              | relation_def | rule_def
              | let_binding | ref_binding ;

block       ::= '{' { block_item } [ expr ] '}' ;

block_item  ::= let_binding | ref_binding
              | ( if_expr | match_expr | while_expr | for_expr | block ) [ ';' ]
              | expr_stmt ;

let_binding ::= 'let' ident [ ':' type ] '=' expr ';' ;
ref_binding ::= 'ref' ident [ ':' type ] '=' expr ';' ;
expr_stmt   ::= expr ';' ;

A block is a sequence of let/ref bindings, expressions, and nested blocks. Neither an expr_stmt nor the block’s trailing expression begins with a braced form — if, match, while, for, or a block — since those stand as items in their own right: { x } + 1; is not a statement, a braced form that ends the block with no ; after it is the block’s value, a ; after one makes it a statement, and an operator after one needs parentheses, { (if c { 1 } else { 2 }) + 3 }; let y = { x } + 1; is fine. The definition forms (fn, type, struct, trait, impl, relation, rule) are top-level only, so a local function is written as a lambda. A ref_binding is surface sugar: ref x = e; reads as let x = ref e;.

The expression language

An expression (expr) is a lambda function (lambda), a returned value (return), or an operation (assign).

expr        ::= lambda | return_expr | assign ;

The operation grammar is a precedence cascade from the loosest-binding operator (assignment :=) down to the tightest (the postfix cluster), each level taking its operands from the one below. The cascade bottoms out at primary (atomic operands).

assign      ::= or_expr [ ':=' or_expr ] ;
or_expr     ::= and_expr { 'or' and_expr } ;
and_expr    ::= cmp_expr { 'and' cmp_expr } ;
cmp_expr    ::= cat_expr [ cmp_op cat_expr ] ;
cmp_op      ::= '==' | '!=' | '<' | '<=' | '>' | '>=' ;
cat_expr    ::= add_expr [ ('++' | '::') cat_expr ] ;
add_expr    ::= mul_expr { ('+' | '-') mul_expr } ;
mul_expr    ::= unary   { ('*' | '/' | '%') unary } ;
unary       ::= ('deref' | 'ref' | '-' | 'not') unary | postfix ;
postfix     ::= primary { '(' [ args ] ')' | '.' ident [ '(' [ args ] ')' ]
                        | '.' ( '0' | pos_int ) | '?' } ;
args        ::= expr { ',' expr } ;

Two associativity choices are visible in the shapes: comparisons recur on neither side, since they are non-associative and cannot be chained; ++ and :: recur on their right operand, so they associate to the right, and 1 :: 2 :: xs prepends two elements. The postfix cluster is function call f(...), field access e.field, method call e.m(args), tuple projection e.0, and the ? operator; the projection index is an integer literal. A name after . that is followed by an argument list is always a method call, so a function held in a field is called through parentheses, (e.f)(args). A primary is an atom or a bracketed form; the UpperIdent alternative is a constructor application, or the bare name of a nullary constructor, a struct, or a type (the receiver of an associated call, Point.origin()):

primary     ::= literal | ident | '(' expr ')' | tuple | list | block | struct_lit
              | UpperIdent [ '(' args ')' ]
              | if_expr | match_expr | while_expr | for_expr
              | 'add' query | 'clear' ident | 'solutions' query | query
              | 'self' ;

tuple       ::= '(' expr ',' expr { ',' expr } ')' ;
list        ::= '[' [ expr { ',' expr } ] ']' ;
struct_lit  ::= UpperIdent '{' [ ident ':' expr { ',' ident ':' expr } [','] ] '}' ;

A one-element parenthesization (e) is just grouping; any expr may sit inside, a while or := included. The unit literal is (); a tuple needs arity two or more. A block { … } is a primary as well, so a braced sequence may stand anywhere an operand is expected. The add, clear, solutions, and bare-query primaries belong to the relations sublanguage and are described below; each of the three keywords is followed directly by its relation atom or name, with no parentheses of its own.

Point { x: 1, y: 2 }

Precedence and associativity

The operator cascade fixes precedence and associativity by its shape. A primary is the operand the operators combine; here they are, tightest-binding first:

OperatorsAssociativity
f(...) e.field e.0 e? (postfix)left
deref ref - not (prefix)prefix (stacks)
* / %left
+ -left
++ ::right
== != < <= > >=non-associative
andleft
orleft
:=non-associative

Assignment := binds loosest of all, which is why r := deref r + 1 reads as r := (deref r + 1). The prefix operators bind tighter than any infix one and stack (deref ref e, not not b), so -a * b is (-a) * b and not a == b is (not a) == b.

Control flow

Next we consider conditional expressions, matching expressions, looping expressions, lambda functions, and return expressions.

if_expr     ::= 'if' expr block [ 'else' ( block | if_expr ) ] ;
match_expr  ::= 'match' expr '{' arm { ',' arm } [','] '}' ;
arm         ::= pattern [ 'if' expr ] '=>' expr ;
while_expr  ::= 'while' expr block ;
for_expr    ::= 'for' ident 'in' expr block      (* list iteration *)
              | 'for' query block ;              (* relational iteration *)
return_expr ::= 'return' expr ;
lambda      ::= '|' [ lparam { ',' lparam } ] '|' expr ;
lparam      ::= ident [ ':' type ] ;

A match arm may carry a guard — the if after its pattern — and a lambda’s parameters sit between single bars, so a nullary lambda is ||.

match xs {
    []      => 0,
    [h, ...t] if h > 0 => h,
    _       => 1,
}

|x: Int, y: Int| x + y
|| 0

Functions, types, and data

Function definitions, the type language, and the type/struct/trait/impl declarations.

fn_def      ::= 'fn' ident [ generics ] '(' [ params ] ')'
                [ '->' type ] fn_body ;
fn_body     ::= '=' expr ';' | block ;
params      ::= param { ',' param } ;
param       ::= ident ':' type ;
generics    ::= '<' tparam { ',' tparam } '>' ;
tparam      ::= UpperIdent [ ':' trait_ref ] ;

An omitted result type is (), so fn f() = 1; is a type error and fn f() -> Int = 1; is the function meant.

The type language:

type        ::= 'Int' | 'Bool' | 'String' | '(' ')' | 'Self'
              | '(' type ',' type { ',' type } ')'
              | '[' type ']'
              | 'fn' '(' [ type { ',' type } ] ')' '->' type
              | 'ref' '<' type '>'
              | UpperIdent [ '<' type { ',' type } '>' ] ;

Declarations introduce named types, structs, traits, and implementations.

type_def    ::= 'type' UpperIdent [ generics ] '='
                [ '|' ] variant { '|' variant } ';' ;
variant     ::= UpperIdent [ '(' type { ',' type } ')' ] ;

struct_def  ::= 'struct' UpperIdent [ generics ]
                '{' [ field { ',' field } [','] ] '}' ;
field       ::= ident ':' type ;

trait_def   ::= 'trait' UpperIdent [ generics ] '{' { fn_sig ';' } '}' ;
fn_sig      ::= 'fn' ident '(' [ 'self' [ ',' params ] | params ] ')' [ '->' type ] ;

impl_def    ::= 'impl' [ generics ] [ trait_ref 'for' ] type
                '{' { method } '}' ;
trait_ref   ::= UpperIdent [ '<' type { ',' type } '>' ] ;
method      ::= 'fn' ident '(' [ 'self' [ ',' params ] | params ] ')'
                [ '->' type ] fn_body ;

An impl block either stands alone or names a trait with for; a method’s first parameter may be self. With self it is a method, called on a value as e.m(args); without, it is an associated function of the type, called on the type’s name as Type.m(args) — the same postfix call syntax, with a bare type name as the receiver.

The Option below is the prelude’s own definition, shown for its shape; a program that declares it again collides with the prelude.

type Option<T> = None | Some(T);

struct Point { x: Int, y: Int }

trait Show { fn show(self) -> String; }

impl Show for Point {
    fn show(self) -> String = "point";
}

Patterns

Patterns appear in match arms. A pattern is the wildcard _, a literal, a variable, a constructor, a tuple, a list — with a :: cons form and an optional ... tail — a struct, or several patterns joined by |.

pattern     ::= '_' | literal | ident
              | UpperIdent [ '(' pattern { ',' pattern } ')' ]
              | '(' pattern ')'
              | '(' pattern ',' pattern { ',' pattern } ')'
              | '[' [ pattern { ',' pattern } [ ',' '...' ident ] ] ']'
              | pattern '::' pattern
              | UpperIdent '{' [ field_pat { ',' field_pat } [ ',' ] ] '}'
              | pattern '|' pattern ;
field_pat   ::= ident [ ':' pattern ] ;

In a pattern, :: binds tighter than | and associates to the right with an atomic head, so a | b :: t is a | (b :: t) and (a | b) :: t needs its parentheses.

The relations sublanguage

Bridger’s also includes a Datalog fragment that adds relation declarations, rules, and queries.

relation_def::= 'relation' ident ':' '(' type { ',' type } ')' ';' ;
rule_def    ::= 'rule' rule_atom [ ':-' body ] ';' ;
body        ::= conjunct { 'and' conjunct } ;
conjunct    ::= rule_atom      (* generator: binds logic variables *)
              | expr ;         (* filter: pure, binds nothing, no return *)
rule_atom   ::= ident '(' term { ',' term } ')' ;
term        ::= ident | literal ;   (* a lowercase ident is a logic variable *)

query       ::= ident '(' qarg { ',' qarg } ')' ;
qarg        ::= expr | hole ;
hole        ::= '?' [ ident ] ;     (* named ?x or anonymous ? *)

Syntactically, there is no difference between a query and a function call. Instead, the difference comes from its use and via name resolution.

rule ancestor(x, y) :- parent(x, y);

The complete grammar

Every production above, collected. This listing is the grammar as a whole; the sections above are the same rules broken out with commentary.

(* --- lexical --- *)
ident       ::= letter_lc { letter_lc | digit | '_' ( letter_lc | digit ) } ;
UpperIdent  ::= letter_uc { letter | digit } ;
letter      ::= letter_lc | letter_uc ;
letter_lc   ::= 'a' | 'b' | … | 'z' ;
letter_uc   ::= 'A' | 'B' | … | 'Z' ;
digit       ::= '0' | digit_nz ;
digit_nz    ::= '1' | '2' | … | '9' ;

literal     ::= int_lit | bool_lit | string_lit | unit_lit ;
int_lit     ::= [ '-' ] ( '0' | pos_int ) ;
pos_int     ::= digit_nz { digit }
              | digit_nz [ digit [ digit ] ] { '_' digit digit digit } ;
bool_lit    ::= 'true' | 'false' ;
unit_lit    ::= '(' ')' ;
string_lit  ::= '"' { string_char } '"' ;
string_char ::= <any Unicode scalar value except '"', '\', or a control character>
              | escape ;
escape      ::= '\n' | '\t' | '\r' | '\0' | '\"' | '\\'
              | '\u{' hex_digit { hex_digit } '}' ;
hex_digit   ::= digit | 'a' | 'b' | … | 'f' | 'A' | 'B' | … | 'F' ;

line_comment  ::= '//' { <any character except a line break> } ;
block_comment ::= '/*' { block_comment
                       | <any character not beginning '/*' or '*/'> } '*/' ;

(* --- programs, items, blocks --- *)
program     ::= { def } ;
block       ::= '{' { block_item } [ expr ] '}' ;
def         ::= fn_def | type_def | struct_def | trait_def | impl_def
              | relation_def | rule_def
              | let_binding | ref_binding ;
block_item  ::= let_binding | ref_binding
              | ( if_expr | match_expr | while_expr | for_expr | block ) [ ';' ]
              | expr_stmt ;
let_binding ::= 'let' ident [ ':' type ] '=' expr ';' ;
ref_binding ::= 'ref' ident [ ':' type ] '=' expr ';' ;
expr_stmt   ::= expr ';' ;

(* --- expressions --- *)
expr        ::= lambda | return_expr | assign ;
assign      ::= or_expr [ ':=' or_expr ] ;
or_expr     ::= and_expr { 'or' and_expr } ;
and_expr    ::= cmp_expr { 'and' cmp_expr } ;
cmp_expr    ::= cat_expr [ cmp_op cat_expr ] ;
cmp_op      ::= '==' | '!=' | '<' | '<=' | '>' | '>=' ;
cat_expr    ::= add_expr [ ('++' | '::') cat_expr ] ;
add_expr    ::= mul_expr { ('+' | '-') mul_expr } ;
mul_expr    ::= unary   { ('*' | '/' | '%') unary } ;
unary       ::= ('deref' | 'ref' | '-' | 'not') unary | postfix ;
postfix     ::= primary { '(' [ args ] ')' | '.' ident [ '(' [ args ] ')' ]
                        | '.' ( '0' | pos_int ) | '?' } ;
args        ::= expr { ',' expr } ;
primary     ::= literal | ident | '(' expr ')' | tuple | list | block | struct_lit
              | UpperIdent [ '(' args ')' ]
              | if_expr | match_expr | while_expr | for_expr
              | 'add' query | 'clear' ident | 'solutions' query | query
              | 'self' ;
tuple       ::= '(' expr ',' expr { ',' expr } ')' ;
list        ::= '[' [ expr { ',' expr } ] ']' ;
struct_lit  ::= UpperIdent '{' [ ident ':' expr { ',' ident ':' expr } [','] ] '}' ;

(* --- control flow --- *)
if_expr     ::= 'if' expr block [ 'else' ( block | if_expr ) ] ;
match_expr  ::= 'match' expr '{' arm { ',' arm } [','] '}' ;
arm         ::= pattern [ 'if' expr ] '=>' expr ;
while_expr  ::= 'while' expr block ;
for_expr    ::= 'for' ident 'in' expr block | 'for' query block ;
return_expr ::= 'return' expr ;
lambda      ::= '|' [ lparam { ',' lparam } ] '|' expr ;
lparam      ::= ident [ ':' type ] ;

(* --- functions, types, data --- *)
fn_def      ::= 'fn' ident [ generics ] '(' [ params ] ')'
                [ '->' type ] fn_body ;
fn_body     ::= '=' expr ';' | block ;
params      ::= param { ',' param } ;
param       ::= ident ':' type ;
generics    ::= '<' tparam { ',' tparam } '>' ;
tparam      ::= UpperIdent [ ':' trait_ref ] ;
type        ::= 'Int' | 'Bool' | 'String' | '(' ')' | 'Self'
              | '(' type ',' type { ',' type } ')' | '[' type ']'
              | 'fn' '(' [ type { ',' type } ] ')' '->' type
              | 'ref' '<' type '>'
              | UpperIdent [ '<' type { ',' type } '>' ] ;
type_def    ::= 'type' UpperIdent [ generics ] '='
                [ '|' ] variant { '|' variant } ';' ;
variant     ::= UpperIdent [ '(' type { ',' type } ')' ] ;
struct_def  ::= 'struct' UpperIdent [ generics ]
                '{' [ field { ',' field } [','] ] '}' ;
field       ::= ident ':' type ;
trait_def   ::= 'trait' UpperIdent [ generics ] '{' { fn_sig ';' } '}' ;
fn_sig      ::= 'fn' ident '(' [ 'self' [ ',' params ] | params ] ')' [ '->' type ] ;
impl_def    ::= 'impl' [ generics ] [ trait_ref 'for' ] type
                '{' { method } '}' ;
trait_ref   ::= UpperIdent [ '<' type { ',' type } '>' ] ;
method      ::= 'fn' ident '(' [ 'self' [ ',' params ] | params ] ')'
                [ '->' type ] fn_body ;

(* --- patterns --- *)
pattern     ::= '_' | literal | ident
              | UpperIdent [ '(' pattern { ',' pattern } ')' ]
              | '(' pattern ')'
              | '(' pattern ',' pattern { ',' pattern } ')'
              | '[' [ pattern { ',' pattern } [ ',' '...' ident ] ] ']'
              | pattern '::' pattern
              | UpperIdent '{' [ field_pat { ',' field_pat } [ ',' ] ] '}'
              | pattern '|' pattern ;
field_pat   ::= ident [ ':' pattern ] ;

(* --- relations --- *)
relation_def::= 'relation' ident ':' '(' type { ',' type } ')' ';' ;
rule_def    ::= 'rule' rule_atom [ ':-' body ] ';' ;
body        ::= conjunct { 'and' conjunct } ;
conjunct    ::= rule_atom | expr ;   (* an expr other than a return *)
rule_atom   ::= ident '(' term { ',' term } ')' ;
term        ::= ident | literal ;
query       ::= ident '(' qarg { ',' qarg } ')' ;
qarg        ::= expr | hole ;
hole        ::= '?' [ ident ] ;

The grammar is illustrative rather than massaged for a particular parsing algorithm; the provided parser resolves the usual details — a match arm’s => against an or-pattern’s |, and a struct literal in the head expression of an if, while, for, or match, where a { is read as the trailing block rather than the literal, so a struct literal at the top level of that head is parenthesized (if (Point { … }) { … }, if p == (Point { … }) { … }; one inside brackets or parentheses needs nothing). One keyword does double duty, resolved by lookahead: ref is both a prefix operator (ref e) and the binding keyword (ref x = e), and at the start of a block element ref ident = (or ref ident : type =) selects the binding sugar while any other continuation parses ref as the allocator. A hole ?x belongs to a query; a rule body names its logic variables directly, and a hole there is a parse error.

Appendix C: Bridger Prelude

The prelude is the set of names every Bridger program starts with: three algebraic data types, five traits, and the functions below. It is loaded before the program, and its names are reserved at the top level — a program may not declare a fn, let, ref, type, struct, trait, or relation with one of them, nor a constructor named like one of the prelude’s. A local binding, a parameter, a pattern variable, or a loop variable may shadow any of them, as it may any name in scope. The method names cmp, length, and from are not reserved: a program may declare a function cmp of its own, and a method call still resolves as Appendix D says.

Types and traits

type Option<T>   = None | Some(T);
type Result<T, E> = Ok(T) | Err(E);
type Ordering    = Less | Equal | Greater;

trait Eq {}
trait Print {}
trait Ord     { fn cmp(self, other: Self) -> Ordering; }
trait Len     { fn length(self) -> Int; }
trait From<T> { fn from(x: T) -> Self; }

Eq and Print declare no methods and cannot be implemented: a type is Eq when it admits equality and Print when it can be printed, both decided by its shape (Appendix D). Ord, Len, and From are implemented by impl blocks. The built-in types conform to some of them by rule rather than by impl: Int, String, and Bool are Ord, with cmp comparing integers numerically, strings by code point, and false below true; String and every list type are Len, with length counting a string’s Unicode scalar values or a list’s elements. A program may not write impl Ord for Int or impl Len for [T]; it may implement Len for Int, which has no built-in conformance. Ordering is not itself Ord.

Functions

Each entry gives the signature the type checker assigns and what the function does. A bound on a type parameter is discharged as Appendix D describes.

Printing

print(x: T) -> () and println(x: T) -> (), with T: Print, write the canonical text of x (below) to the output, println followed by a newline. to_string(x: T) -> String, with T: Print, returns that text. All three are effects for the purity judgment: a reference prints as the value it holds, so even to_string reads the store.

Integers

abs(n: Int) -> Int is the absolute value, wrapping like the operators: abs of the least integer is itself. even(n: Int) -> Bool and odd(n: Int) -> Bool test n % 2, so a negative odd number is odd.

Lists and strings

len(xs: T) -> Int, with T: Len, is xs.length(): the number of elements of a list, or of Unicode scalar values of a string, so a character written with a combining mark counts twice. head(xs: [T]) -> Option<T> and tail(xs: [T]) -> Option<[T]> are None on the empty list and otherwise Some of the first element or of the rest, the rest sharing its cells with xs. range(lo: Int, hi: Int) -> [Int] is the list of integers from lo up to but excluding hi, empty when lo >= hi; a range of more than 2^26 elements is stuck. contains(xs: [T], x: T) -> Bool, with T: Eq, is whether some element equals x by the language’s == — structurally for data, by identity for references.

min(a: T, b: T) -> T and max(a: T, b: T) -> T, with T: Ord, return the smaller or larger argument by cmp, and the first argument when the two compare Equal. minimum(xs: [T]) -> Option<T> and maximum(xs: [T]) -> Option<T>, with T: Ord, are None on the empty list and otherwise Some of the least or greatest element, the earliest of equals; they fold min or max over the list from the left, so cmp is called with the best so far as its receiver. Until the objects milestone these five are native and accept only the built-in Ord and Len types; from it they are written in Bridger over the traits, so a program’s own impl Ord or impl Len flows through them.

unwrap_or(o: Option<T>, d: T) -> T is the Some payload, or d. is_some(o: Option<T>) -> Bool is whether o is a Some.

Higher-order functions

map(xs: [A], f: fn(A) -> B) -> [B], filter(xs: [A], p: fn(A) -> Bool) -> [A], and fold(xs: [A], z: B, f: fn(B, A) -> B) -> B visit the list from the left, calling the function once per element. fold computes f(f(f(z, x1), x2), x3)…, so the accumulator is the first argument and the element the second. A return inside a lambda handed to them returns from the lambda. They iterate rather than recurse, so a long list costs no stack and their calls open no frames; the function they are handed opens one per call like any other. In a rule filter or a match guard, the function must be one the purity judgment can follow — a named function, a lambda, or a pure primitive.

Reading input

The readers consume the program’s input, a text read whole before the program runs; input from a terminal is never read, so a program run interactively sees an empty input.

read_int() -> Int skips ASCII whitespace and reads the next token, which must be an integer literal exactly as the lexer reads one: an optional -, then digits with no leading zero and either no grouping or groups of three digits separated by _. +5, 007, 1_0000, and a token with trailing letters are not literals. read_bool() -> Bool reads a token that is exactly true or false. read_line() -> String returns the rest of the current line without its newline, a trailing carriage return dropped; after a token reader has read part of a line, a remainder that is only whitespace is skipped and the next line is returned, and a remainder with content is returned as it stands, its leading whitespace included. Each is stuck when the input has ended or the token is not of the expected form, and the failure leaves the input where it was. read_int_opt() -> Option<Int>, read_bool_opt() -> Option<Bool>, and read_line_opt() -> Option<String> return None in those cases instead, consuming nothing, so a failed read can be followed by read_line to see what was there. Every reader is an effect for the purity judgment.

Printing

The canonical text of a value is what print, println, and to_string produce, and what a test compares against.

  • An integer prints in decimal with a leading - when negative; true, false, and () print as written.
  • A string at the top level prints raw: print("a\n") writes a and a newline. A string inside another value prints quoted, with " and \ escaped as \" and \\, newline, tab, carriage return, and the null character as \n, \t, \r, and \0, any other control character as \u{…} with its code in lowercase hexadecimal, and every other character as itself.
  • A tuple prints as (v, v, …), a list as [v, v, …] and the empty list as [], with one comma and one space between elements.
  • A constructor prints as its name, followed by its arguments in parentheses when it has any: Some(1), None, Ok((1, "a")), Less.
  • A struct prints as its name and its fields in declaration order, whatever order the literal gave them: Point { x: 1, y: 2 }; a struct with no fields prints as Empty {}.
  • A reference prints as ref(v) with the value it currently holds; a reference reached again while printing its own contents prints as ref(…).
  • A function, a relation, or a type name has no text: printing one is a static error — a function because it is not Print, a relation or a type name because neither is a value — and a stuck state when the program runs unchecked.

Purity

For the purity judgment of Appendix D, print, println, to_string, and the six readers are effects; every other prelude function is pure, and map, filter, and fold are as pure as the function they are handed. The Bridger-written min, max, minimum, maximum, and len are trusted to terminate, so a filter may call them; the cmp or length they reach is judged like any method, so an impl Ord whose cmp prints, or recurses, makes such a filter impure or non-total.

Appendix D: Bridger Language Reference

This appendix covers Bridger’s formal definitions: the rules that dictate what a program means. The concrete syntax (how one writes a Bridger program as a string of characters) can be found in Appendix B, and Bridger’s prelude (the set of built-in functions and capabilities) is in Appendix C. Throughout the book, each chapter develops the semantics of Bridger one feature at a time. This appendix develops the full formal semantics all at once in its complete form. To learn how to read an inference rule, and the symbols the rules use, see the Notation reference in Appendix G.

Abstract syntax

The rules range over the abstract syntax (the output of Bridger’s parser, after the surface details of the concrete syntax are resolved). In the AST chapter we detail that tree, including the exact Rust enum this book asks you to interpret through its milestones. Below is the grammar, over programs, expressions e, blocks, definitions d, patterns p, and query atoms q:

Here ranges over the arithmetic operators and over the comparisons. A projection index i is a literal integer; f is a field name, m a method name, C a constructor, S a struct name, a trait, R a relation. The quoted '|' inside a data type or an or-pattern is Bridger’s own token, set apart from the metagrammar’s as in Appendix B. The ref x = e binding desugars to let x = ref e, and fn foo(x: Int) = e; to fn foo(x: Int) { e }. The keyword form if e then e else e is this grammar’s spelling of the braced concrete syntax if e { e } else { e }. Error propagation e? is sugar over match and return (error handling); it has no evaluation rule of its own below, and its typing is stated after .

Definitions appear only at the top level; a block holds let bindings and expressions, so a local function is a let-bound lambda — which, like any let, is not in scope in its own right-hand side, so a local function cannot call itself; recursion is for top-level fns — and execution begins at a required main. A type, struct, trait, impl, or fn may take type parameters with trait bounds; the grammar leaves these implicit and the typing rules make them explicit. A bound on a type or struct parameter is required where a value of the type is built — a constructor application or a struct literal — so a Box<T: Ord> never holds a function.

Values

Evaluation produces a value. The values are the literals, the compound forms built from them, a location (the identity of an allocated cell), and a closure (a function paired with the environment it captured):

where n ranges over integers (), b over the booleans and (), and s over strings. An integer is a signed 64-bit two’s-complement value — every literal denotes one, the least of them written -9223372036854775808 — and the arithmetic operators wrap on overflow: the the rules write is addition modulo , so no overflow is stuck. Division truncates toward zero and the remainder takes the dividend’s sign, so -7 / 2 is -3 and -7 % 2 is -1. A list is the empty list or a cons ; the literal is shorthand for . A nullary constructor value is written C for C(). The last two are not data: a relation value is what a declared relation’s name evaluates to, consumed only by the query forms, and a type name is what a bare struct, type, or built-in type name evaluates to, consumed only as the receiver of an associated call. Neither compares, prints, or enters a relation.

Evaluation is carried out against three pieces of state. The environment maps each variable in scope to its value; the store maps each allocated location to the value it holds; and (from the relations section) the relation database holds the facts that queries read.

The grammar above is complete — every value a Bridger program produces is one of these. Its forms enter the language across the book (locations with mutable references, closures with functions, the compound forms with tuples and lists); the reference gathers them here so the rules below have their operands defined.

The evaluation judgment

The central judgment is

read in environment , evaluating expression against store and relation database produces outcome , updated store , and updated database . The outcome is for an expression that finishes normally with value , and for one that returns early (return w) without executing the remaining body of the enclosing function. Once hits the enclosing function boundary, it turns from a returned value into a regular .

Two conventions keep the rules readable:

  • A bare value () in result position means . A conclusion or premise written abbreviates . The outcome is always written out. So every value-case rule reads as an ordinary big-step rule, and only return, application, and the loops mention at all.
  • short-circuits. When a premise written is discharged by a subexpression that instead yields , the rule does not fire; the enclosing expression evaluates to , and the subexpressions to its right are not evaluated. This one convention threads outward through every rule without doubling any of them.

Worked expansion. To see what the conventions abbreviate, consider the rule for addition:

The above rule is actually three separate rules. The case where bot operands evaluate to values:

and two more cases where the left or right operand return a value :

Every rule uses these conventions to implicitly thread returned values while only needing to explicitly describe the semantics for the case where all operands evaluate to values. The application is explicitly stated as it unlike other rules explicitly turns a returned value within the function to a value (the value computed/returned by the function).

The environment is read-only; the store and the database are threaded — each rule passes them out of one subexpression and into the next, so a change one subexpression makes is visible to those evaluated after it. Threading is needed because a subexpression can be effectful: a call may allocate or write a reference, add and clear change the database, and either can sit as an operand of +, so x + f() may alter or between evaluating x and combining the results.

Every rule below threads both the store and in evaluation order, so each records how its subexpressions are sequenced; the rules that leave a piece of state untouched thread it unchanged rather than drop it. Slices appear in the chapters, not here — each an honest projection of the judgment above. Nothing writes the store until mutable references in Part IV, so those chapters use the store-free ; nothing writes until the relations material, so earlier chapters drop it.

The typing judgment

The static semantics is the judgment

read in typing context , expression has type , where records the type of each variable in scope together with the enclosing function’s return type. The typing rules below give the static semantics declaratively; the bidirectional checking and local inference that decide it are built with the type checker in Part VI. The judgment is stated here first so the evaluation rules can name the types they assume.

The metavariables used throughout extend the notation reference ’s table:

SymbolRanges over
, , expressions, values, patterns
, , integer, boolean, and string literals
, , variable, field, and method names
, , constructor, struct, and relation names
locations
, , environment, store, relation database
, typing context, types

Runtime errors: stuck evaluation

Some expressions have no value. Adding a boolean to an integer, applying a non-function, or reading a field a value does not have fits no rule, so no derivation of exists. Evaluation is then stuck: the interpreter halts and reports the offending expression rather than producing an outcome. These are Bridger’s runtime type errors, and in the untyped evaluator they are how a nonsensical program fails.

Stuckness is the absence of an outcome rather than a special one. It stands apart from the recoverable failures modeled by Option and Result, which are ordinary values handled with match and live entirely inside the evaluation relation — evaluating Err("…") is a successful derivation that yields a value. Error handling develops that distinction; the type checker later rules out the type-shaped stuck cases for the programs it accepts — a non-exhaustive match among them — leaving division by zero and a failed read from input as the errors that survive typing.

The stuck cases, gathered from the rules below, are: an unbound variable; arithmetic, negation, or ordering on a non-integer, and min, max, minimum, or maximum on operands with no shared order; a non-boolean condition to if, while, and, or, or not; division or modulo by zero; ++ on operands that are not both strings or both lists, or :: onto a value that is not a list; a projection or field access on a value that lacks it; applying a non-function, or calling a function, a method, a constructor, or a relation — in add or a query — with the wrong arity; applying a name that is not a constructor, or a struct literal naming no struct, missing one of its fields, or giving one twice; a match no arm covers; iterating a non-list; dereferencing or assigning through a non-location; equality on a function or relation, and printing one; a method the receiver’s head lacks, a self method called on a type name, or one without self called on a value; a return no function catches; a query, an add, or a clear inside a rule’s filter, and a filter or a guard whose value is not a boolean; a hole in an add atom; a program with no main, or whose global initializers form a cycle; a read_* whose input is not what it reads (its _opt form returns None instead and consumes no input; read_line drops a trailing carriage return); and two bounds of the machine, a call nested more than 100 000 frames deep — a frame per application of a function, lambda, or method body, the from a ? converts through included; natives and constructors open none, while the prelude’s functions written in Bridger open one like any other — and a range of more than 2^26 elements. The static checks report the first error in source order, and reject an expression nested more than 150 000 levels deep — every expression node a level, a block or a branch included. The interpreter holds that same bound before a program runs, so a milestone with no type checker rejects such a tree in place of exhausting the evaluator’s recursion; where the checker runs, it reports the depth first. Premises are discharged left to right, and a premise whose result has the wrong form ends evaluation there: a non-location target of := is stuck before its right-hand side runs, a non-function operator before its arguments, and a wrong arity — of a function, a method, a constructor, or a relation atom alike — or a method the receiver lacks, after them.

The reference interpreter names the stuck cases as follows in its RuntimeError, and a submission reports the same names. Each carries the span of the node whose rule failed — the operator, the call, the match, the guard, the add or query form; a return or ? that escapes an initializer or a filter carries that initializer’s or filter’s span, and a missing main a synthetic one. Where a rule has several operands, found is the first, left to right, whose form is wrong, and expected is the form the rule required of it.

Stuck caseVariant and payload
a name with no bindingUnboundVariable { name }
an operand of the wrong form: arithmetic, negation, or ordering on a non-integer; a non-boolean condition, connective operand, filter, or guard; ++ or :: on the wrong operands; iterating a non-list; deref or := on a non-locationTypeError { expected, found }
division or modulo by zeroDivByZero
a projection or field the value lacksNoSuchField { field }, the index of a projection as text
a struct literal missing a field, or naming one twiceMissingField { field }, DuplicateField { field }
a struct literal naming no struct (a pattern naming one simply fails to match)NotAStruct { name }
applying a value that is not a function, a type or struct name includedNotAFunction { found }
a function, method, constructor, or relation atom given the wrong number of argumentsArityMismatch { expected, found }
a match no arm coversNonExhaustiveMatch
equality on a function or relationNotComparable { found }
cmp on an Int, String, or Bool receiver with an argument of another type — reached by min, max, minimum, and maximum, whose span is then the prelude’s own a.cmp(b); a receiver with no cmp at all is NoSuchMethod thereNotOrdered { found }, found the argument
printing a function, relation, or type nameNotPrintable { found }
a method the receiver’s head lacksNoSuchMethod { method }
a self method called on a type name; a method without self called on a valueNoReceiver { method }, NotAMethod { method, ty }
a return no function catchesReturnOutsideFunction
a query, add, or clear inside a rule’s filterQueryInFilter
a query form whose name is bound to something other than a relationNotARelation { name }
a hole in an add atomHoleInAdd
a read_* whose input is not what it readsInputError { expected, found }, found the next token or None at the end of input
no main, a main with parameters or type parameters, or a main that is not a functionMain { reason }
a cycle among global initializersInitializationCycle { names }, from the alphabetically first global on the cycle, each depending on the next
a call nested more than 100 000 frames deepStackOverflow { limit }
a range of more than 2^26 elementsRangeTooLarge { len, limit }
an expression nested more than 150 000 levels deep, rejected before the program runsTooDeep { limit }

Evaluation rules

Throughout, the store and database are threaded left to right. Where a rule’s premises evaluate several subexpressions, they are written in evaluation order, and both pass and from each to the next.

Literals and variables

A literal evaluates to itself and leaves the store untouched (, , , and () all take this form):

A variable evaluates to the value the environment binds to it; self, inside a method, is the variable bound to the receiver:

When has no binding for the premise cannot be met, no rule applies, and evaluation is stuck on the unbound variable.

Arithmetic

Addition evaluates its left operand, then its right against the store the left produced, then combines the results — threading store and database, and , in evaluation order:

Subtraction and multiplication take the same shape, with computed on integers:

Division and modulo add a premise that the divisor is nonzero; when no rule applies and evaluation is stuck:

Modulo () is identical with the remainder in place of the quotient. Negation evaluates its operand and negates an integer:

Comparison and equality

A comparison evaluates both operands and returns a boolean. Writing for one of the six operators and for the corresponding relation on values:

The ordering operators <, <=, >, >= require integer operands. Equality == and inequality != are structural: they compare integers, booleans, strings, unit, tuples, lists, constructor values, and struct values componentwise, so is true. A location compares by identity: two references are equal exactly when they are the same cell, whatever the cells hold. Equality on a function (closure) or a relation has no value and is stuck. Comparisons do not chain: the grammar makes them non-associative, so a < b < c is not a program.

Boolean connectives

and and or short-circuit: the right operand is evaluated only when the left does not already settle the result. For and, a false left operand settles it, and the right is not evaluated:

or is symmetric: a true left operand settles it as without evaluating the right, and a false left operand yields the value of the right.

Negation evaluates its operand, which must be a boolean, and flips it:

A non-boolean operand to any of the three fits no rule and is stuck.

Concatenation, tuples, and lists

++ concatenates two strings or two lists, threading the store left to right. On lists it appends; on strings it joins:

where on lists is when and when . :: prepends one value to a list; its right operand must be a list, and its left may be any value:

A tuple and a list evaluate their elements left to right and collect the results:

Tuple projection e.i and struct field access e.f evaluate the compound and select a component; a projection out of range, or a field the value lacks, is stuck:

Conditionals

if evaluates its condition, which must be a boolean, and then evaluates the chosen branch against the store the condition produced. The other branch is not evaluated.

Both branches must have the same type, which is a typing rule rather than an evaluation one. The else -less form is admitted only when the then-branch has type (); it behaves as if e1 then e2 else (), taking the value () when the condition is false.

Bindings and blocks

A block evaluates its elements top to bottom in an environment that grows as its bindings are met, and its value is the final expression (or () when the last element is followed by ;). A let evaluates its right-hand side and extends the environment over the rest of the block:

An element evaluated for its effect discards its value and threads its store into the rest of the block; the final expression is the block’s value:

A block whose last item is a let has the value (), like a block with no trailing expression; naming that value binds x to (), and the checker catches a later use of x as anything else.

A program is a set of definitions whose order does not matter: every top-level name is visible in every definition, so any function, global, or method may refer to any other wherever it is written. Top-level names are distinct — declaring one twice is rejected, and so is declaring a name the prelude defines. Within one declaration, names are distinct too: a function’s or lambda’s parameters, a declaration’s type parameters, a struct’s fields, and a trait’s methods each name a thing once. A constructor is a top-level name like any other, since a constructor expression or pattern refers to it by that name alone: it may not coincide with a type, struct, trait, or another constructor, its own type included. A constructor name is not a value: it appears only applied. Evaluation binds them all in one program environment — each relation to a relation value that stands for it, each fn to a closure over , each global let x = e to the value of its right-hand side — and runs main under it — main takes no parameters, and its result is the program’s value, which the bridger command discards: a program reports through print. A missing main, or one that takes parameters or is not a function, is reported before the globals initialize. The fn bindings make recursive, each closure capturing the very environment that holds it:

Global initializers run in dependency order: a global depends on every global its initializer can reach — directly, through the body of any function it calls, through any method of a name it calls, or through the rule bodies if it evaluates a query — and each is initialized after the globals it depends on, so let a = b + 1; let b = 10; binds a to 11 whichever is written first. Globals are visited in alphabetical order of their names, each initialized after the globals it depends on, so a program’s initialization order is fixed; beyond the dependencies it carries no meaning. A cycle among initializers has no such order and is rejected before anything runs.

The type, struct, trait, impl, relation, and rule definitions register in the contexts they introduce (structs, relations) and bind no runtime value.

References and the store

ref e allocates a fresh location holding the value of e; the location is the value. deref e reads the location’s contents; e1 := e2 writes and yields (). Allocation picks a location outside the current store’s domain:

Assignment evaluates the cell first, then the value, then updates the store — the cell, then the right-hand side, then the write:

Two names may hold the same location, so a write through one is visible through the other — the aliasing the store model makes possible. Dereferencing or assigning through a value that is not a location is stuck.

Loops

while evaluates its condition; on false it stops with (), and on true it runs the body (whose value is discarded and is ()), then repeats against the resulting store:

By the convention, a return inside the body abandons the loop: the body premise yields , so the whole while does, and the remaining iterations do not run.

for x in e evaluates the iterable to a list, then walks it, binding x to each element. Once the iterable is a value, two rules drive the walk (a list value stands in the in position as an intermediate configuration):

A return in the body propagates out by the same convention, abandoning the rest of the iteration. Iterating a non-list is stuck. The relational form for q e_b walks the solutions of a query instead of a list; its rule is with the relations.

return

return e evaluates e and wraps the value as a outcome, which the conventions above carry outward until a function boundary catches it:

That return is legal only inside a function body is checked rather than evaluated: a that reaches the top level with no function boundary to catch it is a static error, ruled out before evaluation.

Functions and application

A lambda evaluates, with no effect, to a closure that captures the current environment — the source of Bridger’s lexical scope, since the body will later run under the environment of the lambda’s definition rather than its call:

A named fn is reached through E-Var: hoisting bound it to its closure in the program environment, so f evaluates to that closure and is applied by the same rule as a lambda. Application evaluates the operator to a closure, then the arguments left to right, then the body under the captured environment extended with the parameters; a return in the body is caught here and becomes the call’s value. When the operator evaluates to a relation value instead, the application is the hole-free query of E-Query-True/False, so path(0, 3) is a query exactly when path resolves to the relation — a local of that name shadows it, as it would a function:

where : whether the body finished normally or hit return, the application yields that value as a , so the never escapes the function it belongs to. Applying a value that is not a closure, or supplying the wrong number of arguments, is stuck.

A constructor application C(e_1, …, e_n) builds a tagged value rather than calling code — the constructor C comes from a type definition, and evaluation collects its arguments:

Constructors and pattern matching

match evaluates its scrutinee, then tries the arms top to bottom, taking the first whose pattern matches and whose guard holds. Pattern matching is the auxiliary relation

giving a binding (a finite map from the pattern’s variables to values) when v fits p, and when it does not. Its clauses:

A constructor pattern matches only its own constructor (a different one gives ); a list pattern matches a list of exactly elements, and a list of at least , binding x to the remaining tail as a list. A struct pattern names a subset of the fields, each at most once, and constrains only those (unchecked, a pattern naming a field the struct lacks simply fails to match); fields it omits may be anything. An or-pattern requires both sides to bind the same variables at the same types. Patterns are linear — a variable appears at most once in one pattern, a list pattern’s ...rest binder included — so the unions never collide. Both are static checks (T-Match). Any structural mismatch yields .

The match rule takes the first arm whose pattern matches and whose guard (if present) evaluates to true. A guard is pure — the purity judgment is required of every guard, as of every rule filter — so a failed guard leaves the store and the database as the scrutinee left them:

An arm with no guard is the case . When no arm matches, no rule applies and evaluation is stuck on the non-exhaustive match; the untyped evaluator reports it at run time, and the checker’s exhaustiveness check (T-Match) rules it out for the programs it accepts.

Structs, fields, and methods

A struct literal evaluates its fields in source order and builds a struct value whose fields are held in declaration order, so two literals that name the same fields in different orders build the same value — under ==, in a pattern, and when printed. A literal names every declared field exactly once; a missing or unknown field is stuck. Field access reads one back:

A method call e0.m(e1, …, en) evaluates the receiver and the arguments, then runs the method whose impl matches the receiver’s runtime head type. Let be that type — a struct name, a constructor’s type, or a built-in — and the method the matching impl provides. The body runs with self bound to the receiver, catching return as an application does; as with an application, the wrong number of arguments is stuck. When e0 is a bare struct or type name, or one of the built-in names Int, Bool, and String, the call is an associated call: the name is the head, the method is one declared without self, and its body runs with only the arguments bound. A method with self called on a type name, and one without called on a value, are both stuck:

Resolution is by the receiver’s runtime type, and coherence — at most one impl of a given trait instance per head type; one definition of an inherent method name per head type, and of a trait’s method per head type and instance, across all of its impl blocks; both checked statically — makes that method the same one a static, declared-type dispatch would choose. A method name belongs to one trait per head: a trait may provide it at several instances, but a second trait’s method of that name, an inherent one, or a built-in conformance’s cannot join it on the head, since a call through a bound is dispatched by head and name alone. An impl therefore extends a head type: a declared type or struct, Int, Bool, String, (), or the list head. A tuple, a function type, a reference, and a bare type parameter have no head, so an impl may not name one. An impl may extend a head at a particular instance, impl Len for Seq<Int>, or generically, impl<T> Len for [T]: a call on a receiver of known type is resolved to the impl covering it when the program is checked, and the evaluator runs the method the checker chose, so the two dispatch strategies agree on the code that runs. A trait may have several impls for one head only at instances its type arguments tell apart by their outermost form — a head, or a tuple, function, or reference type — From<[T]> and From<Map<K, V>> for Seq<…> — so a generic instance such as From<T> is the only one, and two impls of a trait without arguments are one too many. A call through a bound T: Tr runs the head’s method of that name, so a bound met by a type whose head implements Tr at several instances is rejected, whether the bound is a call’s or an impl’s own; a ? chooses its from by the error type, and a direct call chooses by its arguments or is ambiguous. (Unchecked, a run dispatches on the head alone and may take the first impl declared, and e? returns an Err payload as it is, converting nothing; passing the chosen impl along with a bounded call — dictionary passing — would lift the restriction and is left as further reading.) The built-in conformances count as impls for coherence: a program may declare neither impl Ord for Int nor an inherent cmp on Int, and likewise for the other built-in types and Len. An impl Tr for τ conforms to its trait: it provides every method Tr declares, each with the trait’s signature under Self = τ and the trait’s type arguments, and no method the trait lacks; a bounded call through Tr is therefore always answered. The built-in types carry the prelude’s trait methods without an impl: with no impl for the receiver, cmp on Int, String, or Bool and length on String or a list resolve to primitives. A call to a method neither an impl nor a built-in conformance provides is stuck.

Relations

The Datalog fragment reads and extends a global relation database , a set of ground facts . Every rule threads ; the rules here are the ones that change it, where the rest pass it along untouched. The chapters before the relations material present the -free slice.

A program’s rule definitions form a fixed rule set . Together with the current facts , they denote a set of derivable facts: the least set that contains and is closed under every rule, where a rule derives whenever a substitution makes each generator atom a member and each pure filter evaluate to true under — the program environment extended by the substitution. A rule is a top-level definition and closes over like a fn body; the locals in scope where a query is asked are not visible to it. is the least fixpoint of that immediate-consequence operator; it exists and is unique because the operator is monotone (all recursion is positive — negation is confined to pure filters), and it is finite because the fragment is range-restricted and terminating, the subject of the chapter on rule bodies and safety. Every query reads ; an implementation may compute it once and reuse it until or changes, which a pure filter cannot tell.

add evaluates a relation atom’s arguments to values and inserts the ground fact; clear drops a relation’s facts. Both change and yield (). An atom — in add or in a query — with other than the relation’s number of columns is stuck:

A rule with no body is a base fact of , not of : clear leaves it. In add, a query, solutions, and for, the bound arguments evaluate left to right, threading the store and , once per form — a for evaluates them once, not per solution — and the query reads as the arguments left it, so an add inside an argument is seen by that query. For each substitution that satisfies a rule’s generators, the rule’s filters evaluate left to right in the order written, wherever they stand among the generators, and the first to yield false drops the substitution: the filters after it are not evaluated for that substitution. A filter whose evaluation is reached and is stuck makes the query, and the program, stuck. Global initializers may add, clear, and query, in initialization order; a global holding solutions q keeps the list as it was then.

add is monotone by design — facts accumulate and are never retracted individually — which is what lets a query denote a least fixpoint; clear resets a relation wholesale between uses. The name R in each of these forms resolves in like any other: binds it to the relation, a local of the same name shadows that binding, and a form whose name is bound to anything but a relation is stuck. A query atom q carries holes ?x; a solution is a substitution for its holes with . A hole named twice is one variable, bound to one value at every position it names, and the query’s holes are its distinct names in order of first appearance. An anonymous hole ? binds no name: a solution is an assignment to the named holes, each distinct assignment counted once, so solutions r(?) is [()] when some fact matches and for r(?) { … } runs its body once. solutions q returns the list of solutions — the tuples of hole values, in that order; a single hole gives the value itself, and a hole-free query gives (), so its list is [()] when the fact is derivable and [] when it is not. A query in boolean position is true exactly when it has a solution: for a hole-free query, when its fact is derivable; with holes, when some filling of them is.

The canonical order is the total order on data values: integers numerically, false before true, strings lexicographically, tuples and lists lexicographically with a shorter prefix first, constructor values by constructor name and then by arguments, struct values by name and then by fields in declaration order, and references by location. Solutions are ordered by their hole values, first hole first, so the list depends on alone and never on the order the facts were added or derived.

The relational for q e_b walks the solutions of q in that same canonical order, binding the hole variables of each solution and running the body once per solution, threading the store and through:

A return in the body abandons the remaining solutions by the convention. The body neither reads a partially built relation nor writes one it is iterating — for iterates a fixed — so the iteration order is the only thing the canonical ordering pins down, and it pins it down precisely for reproducibility.

Typing rules

The rules below are declarative: they say which programs are well-typed. The bidirectional checking and local monotype inference that decide well-typedness — filling in the types a let or a lambda parameter omits — are built with the type checker in Part VI; they compute what these rules specify. The typing context binds variables to types and carries one extra slot, the enclosing function’s return type , which return consults; at the top level, outside any function, that slot is empty.

The types are those of the grammar:

where names a declared type or struct (a bare N when it takes no arguments), and a type variable T is such a name bound by an enclosing <T>. A written type is well-formed when every name in it is a declared type or struct applied to as many type arguments as it declares, or a type parameter of the enclosing declaration applied to none; any other name is a static error. A type parameter may not take the name of a declared or built-in type. A bound T: C<τ, …> names a declared trait applied to as many type arguments as it declares, each well-formed. Self stands for the type of the enclosing impl, in a method’s signature and in any annotation inside its body alike; outside an impl it names no type.

Literals, variables, and operators

Arithmetic and negation are on Int; the ordering comparisons take Int operands and yield Bool; and, or, not are on Bool. Writing and :

== and != share ; a type admits equality when it is built from Int, Bool, String, (), tuples, lists, references (compared by identity, whatever they hold), and constructor and struct types whose declared components admit equality — with generics instantiated, a type that reaches itself at the same instance taken to admit equality on that path, and one whose instances grow without settling — R<T> reaching R<[T]> — taken not to. A function does not, nor does a relation, so neither does a tuple, list, variant, or struct with a function anywhere inside it. Boolean connectives:

Concatenation joins two strings or two lists of the same element type:

Which rule applies is settled by the operands: a String operand chooses string concatenation, a list operand the list rule. When neither operand chooses — two return forms, or two operands of other types — the expression is judged as string concatenation, so two diverging operands leave it undetermined and any other pair is a mismatch, blamed on the first operand that is not a String, left to right.

Cons prepends an element to a list of that element’s type:

Compound data

Tuples and lists; a list literal’s elements share one type, and the empty list takes any element type (the inference pass fixes it from context):

Projection reads a tuple component by index; field access reads a struct field; a constructor and a struct literal build a value of their declared type. Writing for a struct whose field f has type , and for a constructor of type taking arguments :

A struct literal supplies every field, each once; a type definition gives each constructor the arrow above.

Control flow

if needs a boolean condition and branches of one type; the else -less form is that type at (). match types each arm’s body against the scrutinee’s type through the pattern, and all arms agree:

The judgment reads *pattern matches a value of type and binds the variables in *: a wildcard and a literal bind nothing (, at the literal’s type), a variable binds itself (), and a constructor, tuple, list, cons, or struct pattern types its parts against the corresponding component types and unions their bindings; the union is disjoint, since a pattern binding a variable twice is rejected, and a struct pattern naming a field twice with it. An or-pattern types both sides against , and both must bind the same variables at the same types, since the arm’s body runs whichever side matched.

The premise that the patterns exhaust holds when the unguarded arms together cover every value of — equivalently, when a wildcard arm added after them could match nothing they miss. The check looks inside patterns: Some(1) and None do not exhaust Option<Int>, since Some(2) fits neither, and [] with [x] do not exhaust a list type. A type whose values are finitely many at the head — Bool, (), a tuple, a list (empty or cons), a declared type (its constructors), a struct — is exhausted by covering each head and, under it, the components; Int and String have no such finite head, so only a wildcard or variable completes them. A guarded arm contributes nothing, since its guard may fail.

Loops are () -typed; return checks at any type, provided its operand matches the enclosing function’s return type:

gives return e an arbitrary type , so it slots into any context (as in let x = if c then 1 else return 0), while its operand is pinned to . No bottom type and no subtyping are needed; an empty slot — a return at the top level — is the static error.

A form whose every path leaves — a block with a statement that leaves, an if or a match whose branches or arms all leave — leaves too and, like return, takes any type. What follows a leaving statement in a block, and the branches or body of a form whose condition, scrutinee, or iterable leaves, never runs, so its types need not be determined and its bounds are not judged; a value such code produces — a lambda whose body leaves — is judged as usual.

e? is typed as its desugaring. With e of type Option<τ> it has type τ and pins to an Option; with e of type Result<τ, ε> it has type τ and pins to a Result<τ', ε'> whose error type ε’ is ε itself or one an impl From<ε> for ε' converts to, the impl chosen by the instantiated error types. One carrier does not cross into the other. An empty slot is the static error, as for return, and inside a lambda the slot is the lambda’s own result type. The conversion is a use of impl, so it arrives with the objects milestone; until then the two error types must be one.

References, bindings, and blocks

A let extends the context over the rest of the block; a non-binding element may have any type and is discarded; the block’s type is its final expression’s (or () when it ends in ;, unless a statement leaves):

A let may carry an annotation let x : \ty_1 = e_1, which pins ; without one the checker synthesizes it. A program’s fn definitions enter with their declared signatures — alongside its globals and type declarations — before any body is checked, so a definition may refer to any other regardless of order, itself included. A global enters at its annotation or, without one, at the type synthesized for its initializer, checked once in initialization order; it has that one type at every reference:

Each function body is checked against its declared return type, and both return e and the fall-through value are checked against it. Declaration signatures stay mandatory even where local inference is available.

Functions, methods, and generics

A lambda types its body under its parameters, its result type serving as for a return inside it; application checks each argument against the corresponding parameter type:

A method call resolves m against the receiver’s type and checks the arguments against the method’s parameter types, with Self standing for that type in the signature. An associated call T.m(e1, …, en) on a bare struct, type, or built-in name T (Int, Bool, String) types the same way with T, freshly instantiated, as and a method declared without self; the mismatched pairings are errors wherever the method is found — an impl, a built-in conformance, or the bound of a type parameter — and T must be a declared or built-in type, since a type parameter has no type to dispatch on at run time:

Generics are declaration-level. A fn f<T: C>(…) -> … has, at its definition, a scheme quantified over T with the single trait bound C. At a call site the checker instantiates T with a concrete type by first-order matching, and requires the bound to be satisfied: by an impl C for \sigma, by a built-in conformance (Int, String, and Bool are Ord; String and every list type are Len), or, inside the declaration, by being a type parameter that carries the bound C; the call is then typed by on the instantiated signature. A bound’s type arguments are part of it — T: Conv<String> is met by an impl Conv<String>, not by an impl Conv<Int> — and an impl with bounds of its own, impl<T: Show> Show for [T], is an impl for [σ] only when σ meets them, at a call through it and as evidence for a bound alike. Every expression’s type is fully determined once its declaration is inferred: a type that nothing in the declaration pins down — an unannotated global or local, a lambda parameter, a bare [] or None — is a static error (“type annotation needed”), so a bound is always judged on a determined type, and no type crosses from one declaration into another except through an annotation. The prelude’s min, max, minimum, maximum, and len are bounded functions of this kind, written over Ord::cmp and Len::length, so a user impl Ord or impl Len flows through them. There is no let-generalization — a let never acquires a ∀ — so the only schemes are the ones written on declarations. The matching and bound-checking are the mechanical heart of Part VI; stated here, a generic call is well-typed exactly when some instantiation of its type parameters makes the arguments check and satisfies every bound.

Two prelude traits declare no methods and are satisfied by a type’s shape alone: a type is Eq when it admits equality and Print when it can be printed — data all the way down, a reference being Eq whatever it holds and Print when its contents are. Neither can be implemented by an impl, and an impl Ord for τ requires that τ admit equality — a generic impl under its own bounds, so impl<T> Ord for W<T> needs T: Eq or T: Ord — so an Ord bound also grants ==. A bare type parameter admits equality only under an Eq or Ord bound and prints only under Print. The prelude’s contains is bounded by Eq, and print, println, and to_string by Print, so a function where data is required is a type error rather than a stuck state.

Relations

A relation R : (τ₁, …, τₙ) declares R; each column type must admit equality, since facts are distinguished by it, so no column holds a function at any depth. add checks its arguments — expressions, never holes — against that signature and has type (), as does clear. A query in boolean position is Bool, holes or not; solutions q collects the tuples filling the distinct holes, in order of first appearance, at their positions’ declared types — a hole named at two positions takes one type, so those columns must agree. The premise is read after is consulted: a local R in makes the form an error rather than a query, and a relation name on its own is not a value — it has no T-Var type — so it may appear only as the head of a query form or a call:

A single hole gives solutions q : [τ] rather than a one-tuple, and no hole gives [()]. The relational for q e_b binds the hole variables at their declared types and checks the body at ():

Relation and rule declarations are checked by this same system: a rule’s head and generator atoms check against relation signatures, and its filter conjuncts against Bool, so the Datalog fragment reuses the core type checker rather than a second one. A rule is also range-restricted: every variable its head uses is bound by a generator of the same rule, and every variable a filter uses is bound by a generator or names a top-level definition — a global constant or a function — which every definition may refer to; a generator-bound variable shadows a top-level name.

The static errors by name

The reference’s TyError names the static errors of this section as follows, and a submission reports the same names. The span is the expression or declaration the rule failed at: for a mismatch, the sub-expression whose type is wrong, expected being the type the context required of it and found the type it has; for a struct literal’s field, the field’s value; for an impl, its header or the method’s signature; for a call of a non-function, the callee. A bound that fails is blamed on the argument of the bounded parameter, or on the call when no argument mentions the parameter; an undetermined type on the earliest expression still undetermined once its declaration is inferred, an argument rather than the callee; is_param marks a failure on a bare type parameter, one a bound on the declaration would grant in the case of NoEquality, NotPrintable, and UnsatisfiedBound. ArityMismatch names an anonymous callee “this function”.

Static errorVariant and payload
a type where another was requiredMismatch { expected, found }
a name with no binding; a constructor, type, or trait no declaration introducesUnboundVariable { name }, UnknownConstructor { name }, UnknownType { name }, UnknownTrait { name }
a type, struct, or trait name used as a constructor; a type or trait name used as a structNotAConstructor { name }, NotAStruct { name }
a field the struct lacks; a field read off a value that is not a struct; a struct literal missing a field or naming one twiceNoSuchField { field }, FieldOfNonStruct { field, found }, MissingField { field }, DuplicateField { field }
a projection past the end of a tuple; a projection off a value that is not a tupleNoSuchComponent { index, arity }, NotATuple { index, found }
a function, method, or constructor given the wrong number of arguments; a type or trait applied to the wrong number of type arguments; an add, clear, query, or for form of the wrong arity (a rule’s own atoms are the rule checks’ Arity)ArityMismatch { callee, expected, found }, TypeArity { name, expected, found }, TraitArity { name, expected, found }, RelationArity { name, expected, found }
applying a value that is not a functionNotAFunction { found }
==, contains, or an Eq bound on a type that admits no equality; printing, or a Print bound on, a type that cannot be printedNoEquality { ty, is_param }, NotPrintable { ty, is_param }
a bound no impl, built-in conformance, or bound in scope satisfiesUnsatisfiedBound { trait_, ty, is_param }
a type reaching itself with no constructor betweenInfiniteType
a type nothing in the declaration determinesAmbiguous
an expression nested past the checker’s boundTooDeep { limit }
main with parameters or type parametersMainSignature
a return or ? with no function to return fromReturnOutsideFunction
a fn or method with no -> whose body is not ()MissingResultType { name, found }
an else-less if whose branch has a valueIfWithoutElse { found }
a match whose arms leave a value uncoveredNonExhaustiveMatch
a pattern binding a name twice; the alternatives of | binding differentlyNonLinearPattern { name }, OrPatternBindings { name }
? on a value that is neither Option nor Result; ? in a function or a lambda whose result cannot carry the failureTryOnNonCarrier { found }, TryInNonCarrierFunction { ret }, TryInLambda { found }
a method the receiver’s type lacks; a field called as a method; a method without self called on a value; a self method called on a type name; an associated call through a type parameterNoSuchMethod { method }, FieldNotMethod { field, ty }, NotAMethod { method, ty, is_param }, NoReceiver { method }, TypeParamCall { name }
a method provided at several instances that the arguments do not choose between; a bound met at several instancesAmbiguousMethod { method }, AmbiguousInstance { trait_, ty }
an impl whose target has no head — a type parameter, a tuple, a function, or a reference type; an impl of Eq or Print; an impl of a trait a built-in type satisfies by rule; two impls of one trait instance for one head; two overlapping instances; a method name defined twice for one headImplTarget { ty, reason }, BuiltinImpl { trait_, ty }, DuplicateImpl { trait_, ty }, OverlappingImpls { trait_, ty }, DuplicateMethod { method, ty }
an impl of a trait lacking a method, with a method the trait does not declare, or with a signature other than the trait’sMissingMethod { trait_, method }, NotInTrait { trait_, method }, SignatureMismatch { method, expected, found }
a type parameter named like a typeTypeParamShadows { name }
a cycle among global initializersInitializationCycle { names }
an add, clear, query, or for form naming a relation no declaration introduces (a rule’s atoms are the rule checks’ NotARelation); a query form whose name is bound to something other than a relation; a relation name used as a valueUndeclaredRelation { name }, NotARelation { name }, RelationNotAValue { name }
a relation column whose type admits no equalityRelationColumn { name, ty }
a hole in an add atomHoleInAdd

Purity and effects

A Datalog rule’s filters must be pure — free of effects and a deterministic function of the variables the generators bind — so that a query denotes a least fixpoint that the evaluation schedule cannot perturb. The judgment

reads given the set of functions known pure, expression is pure. The effect-free forms are pure when their subexpressions are, so purity is a congruence over most of the grammar:

The same congruence covers negation, the comparisons, and/or/not, ++, tuples, lists, projection, field access, constructor application, struct literals, if, and match (arms and guards): each is pure when all of its subexpressions are. A call is pure when it targets a named function already known pure and its arguments are pure:

A method call is pure on the same condition, taking every impl ’s version of the method to be pure. A call whose operator is anything but a named function or a native primitive — a parameter, a local, a global holding a closure, or a computed closure — has no instance and so is impure, since the analysis cannot follow what it will run; this is what excludes function-typed parameters from filters. A named function that appears as a value, handed to fold say, counts as called, and the function handed to map, filter, or fold must be a named function, a pure native, or a lambda, for the same reason a call must. e? is a match and a return around a call to from, so it is pure when e is and every impl From ’s from is. The prelude’s own functions are trusted: map, filter, and fold iterate their list and call the function they are handed once per element, and the Bridger-written ones are written over fold and match, so each is total and pure whenever the functions handed to it are. The remaining forms are never pure, each because it reads or writes state that makes its result depend on more than its inputs:

ref/deref/:= touch the store; add/clear and every query read or write the relation database ; the loops exist for effect. None has a purity rule, so any expression containing one is impure, and so is a call to an input/output primitive (print, println, the read_* family). return e is control, not an effect: a function that returns early is as pure as one written with if, and a return directly in a rule body, where there is no function to return from, is a static error rather than an impurity. A top-level constant read in a filter is a variable and pure by ; a global holding a reference is readable only through deref, which is not.

Which functions are pure is the least solution for the impure set. Let be the smallest set of function names such that whenever f ’s body contains one of the never-pure forms directly, or calls a function in , or calls through anything but a named function or a native. Then is every function name not in . Because is a least fixpoint over the call graph, it is exactly the functions that can reach an effect, and a rejected filter is reported with the witness path — the chain of calls from the filter to the effect it reaches.

The rule this serves: in a rule H :- B₁ and … and Bₘ, every conjunct that is a filter (an expression rather than a generator atom) must satisfy . The body is its and-chain however it is bracketed — and is associative, and parentheses leave no trace — so r(x) and (s(x) and t(x)) has three conjuncts, each read as a generator or a filter on its own. Enforcing it keeps rules monotone and their meaning independent of evaluation order, which is what makes the least-fixpoint semantics of the relations section well defined. A relation applied in call syntax is a query wherever it stands: as a whole conjunct it is a generator atom, and inside any other expression (not r(x), r(x) or e) it makes that filter impure, so a rule body reads the database only through its generators and there is no negation. The same judgment is required of every match guard, , which is what lets E-Match promise that a failed guard changes nothing. Neither a filter nor a guard may reach a function that takes a function-typed parameter, which the analysis cannot follow. A filter must also be total — no function it reaches may recurse — while a guard, which runs once per arm, need not be. The judgment is syntactic, reading types only from declarations: a method call whose receiver is self, a parameter, a literal, a type name, a struct literal, or a field read off one of those — and whose name the body never rebinds — reaches that head type’s method alone, so impl Ord for P { fn cmp(self, o: P) -> Ordering = self.a.cmp(o.a); } delegates to Int’s cmp rather than recursing; any other receiver reaches every impl’s method of that name — so does a receiver whose declared type is a type parameter, which has no head, the enclosing impl’s own method included; a generator-bound variable has its column’s declared type. A name bound locally — a parameter, a let, a pattern variable, a loop variable, a hole, a logic variable — is a local wherever it appears, never the function or primitive of that name, so a call through it is a call through a local. to_string is an effect: a reference prints as the value it holds, so to_string reads the store, through whatever helper a filter or guard reaches it by. e? is impure when any impl From’s from is, whether or not the conversion applies; e? standing in a rule body, with no function to return from, is the static error it is at the top level, and return there does not parse. In a rule atom the name before the parentheses is always the relation, even where a logic variable of that name is bound.

The reference’s RuleError names the failures of this section as follows; the span is the rule atom, the filter, the guard, or the offending generator argument: a column mismatch in a generator, whose arguments are expressions, is blamed on the argument, while one in the head, whose terms carry no span of their own, is blamed on the whole head atom. witness is the chain of calls from a filter or guard to the effect it reaches, outermost first — a function by its name, a local or parameter called through by its name, a method by its head and name as H.m or by name alone as .m when the receiver is not settled, and last the impure primitive or the relation queried in call form — and empty when the effect is a form of the filter’s own: add, clear, solutions, for q, while, ref, deref, :=, or a lambda applied on the spot.

FailureVariant and payload
a rule atom naming no declared relationNotARelation { name }
a rule atom of the wrong arityArity { name, expected, found }
a generator argument that is neither a variable nor a literalArgumentNotATerm
a head or filter variable no generator bindsUnbound { var }
a filter that is not pureImpure { witness }
a filter reaching a recursive functionRecursive { name }
a filter or guard reaching a function with a function-typed parameterHigherOrder { name }
a match guard that is not pureImpureGuard { witness }

Determinism and evaluation order

For every , , , and there is at most one triple with : evaluation is a partial function, partial exactly where an expression is stuck. The order is fixed. Each rule with several premises evaluates them left to right and threads the store (and ) between them, so a program’s effects — writes, add s, print s — occur in one determined order. Allocation chooses some fresh location, so a store is determined up to the renaming of locations, which no expression can observe. This determinism is what lets the reference interpreter serve as an oracle and lets step counts reproduce.

Cost semantics

The challenge measures a program by a step count: the size of its big-step evaluation derivation — one unit per rule applied, so the count is the number of nodes in the tree for , not a new construct. Being big-step, the tree already reflects the work done: an unevaluated and/or operand or an untaken branch adds nothing, each loop iteration is its own subtree, and a short-circuiting return drops its right-hand siblings.

Two computations otherwise ride along as side conditions, which the bare tree would charge nothing; the count adds them back. Each step of the matching relation counts as a node, and computing the relation database costs one unit per fixed-point iteration of its immediate-consequence operator. Environment and store lookups stay atomic — one unit within the rule that performs them. The model is fixed with Part X, where each construct’s contribution is set.

Appendix E — Milestone Index


Under Construction


From Part II onward, each part of this book closes with a milestone: a chapter that specifies the piece of the interpreter the part’s concepts call for, with worked examples and tests to check it against. This appendix lists them, with the part each one closes, what you write, and what is provided.

The exception is M0, which builds none of the language. It is a warm-up for readers new to Rust, and it sits with Appendix A instead of closing Part I.

MilestoneClosesYou writeNewly provided
M0: Rust warm-up — (with Appendix A) a coin purse: the fields of Purse and the bodies of its methods the method signatures and the tests
M1: expressions Part II eval_expr arms for literals, unary and binary operators, tuples, lists, and projection the starter interpreter: the AST, the parser, Value and type_of, RuntimeError and Control
M2: binding Part III arms for variables and blocks (let and sequencing) the environment, Env, with lookup and extend
M3: control and state Part IV arms for if, while, for, :=, and return; ref and deref as new cases of the unary arm the store, Store, with alloc, read, and write
M4: functions Part V arms for lambdas and calls closures (Closure), the call-depth counter, the prelude's native primitives, and the driver that runs a whole program from a file
M5: types Part VI arms of check_expr for the forms of M1–M4, and unify the checker's driver, the typing context Ctx, inference variables, and a helper module printed in the milestone chapter

One interpreter, grown

From M1 on, the milestones extend a single interpreter. The starter repository has an arm in eval_expr for every form of the language from the start, and each arm a milestone fills is a hole tagged with that milestone (todo_m1!, todo_m2!, and so on), so the crate compiles at every stage. One command lists a milestone’s holes:

grep -rn 'todo_m3!' src

The tests follow the same plan. cargo mN runs the tests for milestone N together with those of every earlier milestone, so an arm written for M1 is still checked at M4, and a later change that breaks it shows up as a failing earlier test.

Rust itself is introduced at the point of use across the chapters; Appendix A gathers it in one place.

Appendix F — A Timeline of Languages and Ideas

History in this book is not kept in one place. Each idea is introduced where it becomes relevant — Church at first-class functions, Scheme at closures, Liskov at algebraic data types — because an idea makes far more sense once you have felt the problem it solved.

This appendix is the other view: the same material laid out in order, so you can see the whole arc at once and find your way back to where each idea is discussed. Dates are approximate where a language evolved over years rather than launching.

Before there were programming languages

Inference rules · Gentzen, 1935. Writing a logical argument as rules, with the assumptions above a line and the conclusion they justify below. The rules this book uses to define evaluation take their shape from this format, worked out in logic before there were programs to run.
Syntax and semantics · Notation reference

The λ-calculus · Church, 1936. A model of computation built from nothing but functions: definition, application, substitution. It predates electronic computers entirely, and yet every language with first-class functions is a descendant.
First-class functions

Symbolic notation for machine orders · Britten and Booth, Coding for A.R.C., 1947. Write a letter for an operation instead of a number, and let a program work out the bits — among the earliest documented cases of bookkeeping handed from the programmer to the machine. On the EDSAC the same idea was designed into the instruction encoding: an operation’s bit pattern was the teleprinter code of its letter.
From machine code to high-level languages

Relocation, and the subroutine library · Wheeler’s Initial Orders 2 for the EDSAC, September 1949; Wilkes, Wheeler, and Gill, 1951. Adjust a routine’s addresses as it is loaded, and the routine stops caring where it lands — which is what lets code be written once and kept in a catalogue. The forerunner of the assembler, in 42 orders.
From machine code to high-level languages

1950s — abstraction arrives

“Automatic programming” · Hopper’s A-0, 1952. A program that assembles a program, argued for on the economics of programmer time. The early automatic-programming systems ran five to ten times slower than hand-coding, which is why the argument had to be had at all.
From machine code to high-level languages

Fortran · Backus and team, 1954–1957. The first widely used high-level language, and the first serious argument that a compiler could generate code good enough that humans need not write assembly. It shipped with an optimizing compiler because nothing less would have been accepted and widely used.
From machine code to high-level languages

Lisp · McCarthy, 1958. Programs as data, functions as values, garbage collection, recursion as the primary control structure. Also the origin of dynamic scope — largely an artifact of how the first interpreter resolved variables, later understood as a bug rather than a design.
First-class functions · Static vs. dynamic scope

Algol 60 · international committee, 1960. Block structure, nested scope, and the BNF grammar notation used to define it — the moment syntax itself became something you specify formally rather than describe in prose. Also the moment a language became a document, defined by a committee report before any compiler for it existed. Algol 58 came first and was likewise settled by committee ahead of any implementation, and its begin/end grouped statements without declaring names; however, the block and formal syntax definition were introduced in Algol 60.
From machine code to high-level languages · Syntax and semantics · Environments and let

1960s–70s — structure, objects, and types

Simula 67 · Dahl and Nygaard. Classes, objects, and inheritance, invented to simulate real-world systems — objects were a modelling idea before they were a software-engineering one.
Structs with methods · Traits vs. inheritance

“Go To Statement Considered Harmful” · Dijkstra, 1968. The argument that unrestricted jumps make programs impossible to reason about, and the case for structured control flow.
Conditionals and loops

Type inference · Hindley 1969, Milner 1978, Damas–Milner 1982. You need not write types down for a checker to know them; the principal type can be computed.
Function types and polymorphism

Pascal · Wirth, 1970. Static typing as a discipline for ordinary programmers, and a generation of students taught that the compiler catches your mistakes.
Why types?

C · Ritchie, 1972. Written so that an operating system need not be coded in assembler, and classed among the high-level languages of its day; it is now routinely called low-level. What constitutes a high-level language moved, while little about C itself changed.
From machine code to high-level languages

de Bruijn indices · 1972. Replace variable names with their binding distance, and shadowing and renaming problems disappear. A choice about representation, made below the level of the language itself — the surface syntax need not change at all.
Environment representations and lookup

Prolog · Colmerauer and Roussel 1972; Kowalski’s “logic as a programming language” 1974. State what holds, let the machine search for what follows.
Declarative programming · Unification

Smalltalk · Kay, Ingalls, and colleagues, 1972–1980. Everything is an object, computation is message-passing, and the language is inseparable from its live environment.
Dispatch and encapsulation

CLU · Liskov and colleagues, 1974–1977. Abstract data types with enforced encapsulation, plus early iterators and exception handling — much of what “modularity” now means.
Sum and product types · Error handling

Scheme · Steele and Sussman, 1975. Lexical scope done right, closures as first-class values, and proper tail calls. The repair of Lisp’s dynamic-scope accident, and the direct ancestor of the scope semantics this book builds.
Closures · Static vs. dynamic scope

ML · Milner and colleagues, from 1973. Type inference, algebraic data types, pattern matching, and the slogan this book’s Part VI is organized around: well-typed programs don’t go wrong.
Soundness, informally · Pattern matching

1980s — semantics, logic, and fixpoints

Hope · Burstall, MacQueen, Sannella, 1980. Algebraic data types and pattern matching as a language’s primary way of building and taking apart data.
Sum and product types

Operational semantics · Plotkin’s structural operational semantics (1981) and Kahn’s big-step “natural semantics” (1987). The notation this book uses to specify every feature before building it.
Syntax and semantics · Program semantics · Notation reference

Datalog · named in the early 1980s. Prolog’s declarative core minus function symbols, which guarantees termination — exactly the choice this book’s relational sublanguage makes.
Declarative programming

Semi-naïve evaluation · Bancilhon and others, mid-1980s. Compute a least fixpoint without re-deriving what you already know: only consider facts new in the previous round.
Evaluating relations: the least fixpoint

Linear logic · Girard, 1987. A logic where assumptions are consumed when used. Decades later this becomes the theory behind ownership and borrowing.
Appendix A · Why types?

1990s–2000s — the modern settlement

Haskell · committee, from 1990. Purity and laziness taken seriously enough to find out what they actually cost.
Programming functionally · Evaluating function calls

Java · 1995. Static types, garbage collection, and single inheritance with interfaces for the mainstream — and, in time, the standard exhibit for the costs of classical inheritance.
Traits vs. inheritance

Gradual typing · Siek and Taha, 2006. A principled account of how typed and untyped code can coexist in one program, and what the boundary between them must cost.
Dynamic and gradual typing

2010s — composition over hierarchy

Go · 2009, and Rust · 1.0 in 2015. Both reject classical inheritance in favour of composition plus interfaces/traits. Rust additionally makes ownership a checked, substructural discipline — linear logic arriving in a production language.
Traits vs. inheritance · Appendix A

TypeScript · 2012. Gradual typing at industrial scale, layered onto a language never designed for it.
Dynamic and gradual typing

Ideas without a single date

Cost semantics · abstract cost models that count reduction steps rather than seconds, making “how expensive is this program?” a question about the semantics instead of the hardware.
Cost semantics

Dynamic scope’s deliberate survivors · exception handlers, thread-local variables, Emacs Lisp’s defvar, and effect handlers all resolve at the call site on purpose. Dynamic scope failed as a language-wide default while remaining the right semantics in the small.
Static vs. dynamic scope

Notation reference

This book gives a language its meaning with inference rules: compact statements of the form if these facts hold, then this following fact holds. This section explains how to read them and collects the various notations the book uses to formally define semantics. While each notation is introduced and explained when first introduced, this section collects all such notations in one place.

Reading an inference rule

A rule is written with a horizontal line. The statements above the line are its premises, the statement below is its conclusion, and the name on the right identifies the rule:

Read it downward: when every premise holds, the conclusion holds. The premises are the assumptions the conclusion rests on.

A rule with nothing above the line is an axiom. Its conclusion stands on its own, with nothing to assume. A numeric literal evaluates to itself (writing for evaluates to):

When a rule does carry premises, they are the facts that must already hold for it to apply. Addition evaluates each operand, then combines the two results:

Derivations

A premise is itself a conclusion: it holds for some reason, and that reason is another rule. Stacking the rules that justify one another produces a derivation — a tree whose leaves are axioms and whose root is the fact you set out to establish. A derivation is a proof that the judgment at its root holds.

Evaluating builds this tree:

Each leaf is an axiom; each step combines two values already derived; the root is the value of the whole expression. Every step is licensed by a rule, so no line of the tree is taken on faith. When the same shape of derivation recurs, it can be recorded as a single derived rule and used directly wherever that shape appears.

Metavariables

The rules use single letters as metavariables — placeholders that range over a kind of object. A subscript or a prime distinguishes several of the same kind (, , , ).

SymbolRanges over
expressions
integer literals
variable names
values — the result of evaluation
environments (below)
stores (below)
locations — the identity of a store cell

Evaluating expressions

The central judgment of the semantics is

read in environment , expression evaluates to value . The environment records the value of each variable in scope, which is what a variable needs in order to evaluate:

Every rule threads the same through its premises, since a subexpression is evaluated in the same scope as the expression containing it:

An expression with no variables evaluates to the same value under any environment; the arithmetic derivations above drop for that reason and write .

Threading a store

Once a program can create and overwrite a memory cell, the value of an expression depends on what the cells hold, and evaluating an expression can change them. The store — a finite map from locations to values — records those cells, and the judgment carries it, taking one store in and handing one out:

read in environment , evaluating against the store yields the value and leaves the store . The environment goes in but does not come back, since evaluating an expression cannot rebind a name; the store goes in and comes out, since evaluating an expression can change a cell. Threading it left to right through a rule’s premises also fixes the order the subexpressions run in — each premise evaluates against the store the one before it produced:

A form that writes no cell of its own, like , threads the store through unchanged; the forms that create, read, and write a cell are in the mutation chapter. A program that uses no cell leaves the store the same coming out as going in, and the earlier judgment is that case with the store left implicit. The language reference states the judgment in full, threading the relation database and the outcome of a return alongside the store.