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

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.