Viscosity
Review
Part III · A tree-walking interpreter · Day 9

36. Statements and state

An evaluator that forgets everything after each answer is a calculator. Add statements that print and remember, and Drip becomes a language you can write programs in.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Explain a Drip program as a list of statements run in order.
  • Say what side effect each kind of statement has, and why an expression statement drops its value.
  • Implement global variables as a table that can define, look up and assign names.
  • Predict when using a variable is a runtime error.

A calculator with no memory

Last chapter’s evaluator can work out (1 + 2) * 3. Then it forgets. It can’t keep a running total, it can’t name an intermediate result, and it can’t even show you an answer unless something prints it. Real programs do things, and they remember. In Drip, the parts that do things are statements.

A program is a list

The parser does not return one tree. It returns an array of them, one per statement, in the order they were written. Running the program is a loop over that array, from interpret in companion/ts/src/interpreter.ts:

interpret(statements: Stmt[]) {
  for (const s of statements) execute(s);
}
Intuition

A program is a recipe: a numbered list of steps, followed in order. Step 4 can use the onion you chopped in step 2, but not one you will only chop in step 6. And if step 5 goes wrong, you stop there: steps 1 to 4 really happened, and steps 6 and 7 never will.

Definition

In Drip, a program is a list of statements. The interpreter runs them one at a time, top to bottom. Each statement runs to completion before the next starts, and a runtime error stops the list where it is.

Statements make things happen

Here are the three statements of this chapter, simplified from execute:

function execute(s: Stmt): void {
  switch (s.kind) {
    case 'expression':
      evaluate(s.expression); // dropped
      return;
    case 'print': {
      const v = evaluate(s.expression);
      out(stringify(v));
      return;
    }
    case 'let':
      globals.define(s.name.lexeme,
        s.initializer
          ? evaluate(s.initializer) : null);
      return;
  }
}

Look at the return type: void. evaluate hands back a value, but execute hands back nothing. A statement is only worth running for what it changes. print changes the world outside the program: a new line of output. let changes the program’s memory: a new variable. An expression statement evaluates its expression and drops the value, so 1 + 2; is legal Drip that does nothing you can see. It is there for expressions that change something as they are evaluated, like an assignment.

Definition

A side effect is a change that running code makes to the world, beyond producing a value: printing output, or creating or changing a variable. Statements exist for their side effects. An expression statement evaluates an expression only for its side effects, and discards the value.

A table of globals

Where does let put the variable? For now, in one table of names and values that the whole program shares. Simplified from the Environment class in runtime.ts:

class Globals {
  values = new Map<string, Value>();
  define(name: string, v: Value) {
    this.values.set(name, v);
  }
  get(name: Token): Value {
    const k = name.lexeme;
    if (this.values.has(k))
      return this.values.get(k)!;
    throw new RuntimeError(name,
      `Undefined variable '${k}'.`);
  }
  // assign: the same check, then set
}

Using a variable is a lookup, and assigning one is a lookup followed by an overwrite:

case 'variable':
  return globals.get(e.name);
case 'assign': {
  const value = evaluate(e.value);
  globals.assign(e.name, value);
  return value;
}

Notice that the assignment returns the value. Assignment is an expression with a side effect, which is why print a = 3; prints 3 and let b = a = 5; sets both. Step through a small program and watch the table and the output change:

Step throughRunning a program, one statement at a time
let x = 2;
let y;
print x;
x = x * 10;
print x + 1;
print y;
print z;
The table of global variables and the output, as each statement of the program runs.GLOBALSx2changedOUTPUT
The program is a list of seven statements, run one after another. The first is a let: evaluate the initializer 2, then define x in the table of globals.
1/7
Definition

A global variable is one declared at the top level of the program, outside any block. It lives in a single table shared by the whole program, from the moment its let runs until the program ends. In Drip, let always defines (even a name that already exists), while assignment and use require the name to exist already.

Because the table is filled in as the program runs, when matters. print a; let a = 1; fails with Undefined variable 'a'.: when the print runs, no let has put a in the table yet. The parser can’t catch this. It is a runtime error, found only when that line runs.

Try itState that lasts
let total = 0;
total = total + 5;
total = total + 7;
print total;
let b = total = 100;
print b;
print total = 3;

A first look at blocks

A block, { … }, is one statement that holds a list of statements. A let inside it does not go into the globals table. The block gets a small table of its own, which is thrown away at the }:

Try itA block’s own variable
let a = "outer";
{
  let a = "inner";
  print a;
}
print a;

The inner a hid the outer one, and vanished at the }. The tables behind that, and how a name is found in them, are the next chapter.

Under the hood

In the tree-walker, globals are a JavaScript Map from name strings to runtime values, and every use of a global looks its name up there while the program runs. The bytecode VM in Part IV keeps globals in a hash table it builds itself (companion/ts/src/vm/table.ts), but still finds them by name at run time. Local variables, coming soon, get much cheaper treatment. Drip’s rules for globals, such as allowing a second let of the same name, follow Lox[1].

Your turn

The core exercise is a whole little interpreter: statements, a globals table and output. The extra one is a classic Drip puzzle.

ExerciseA tiny interpreter with state

Finish run(prog). prog is a list of statements: { kind: 'let', name, init } (init may be null), { kind: 'print', expr } and { kind: 'expr', expr }. Expressions are num, add (already done), { kind: 'var', name } and { kind: 'assign', name, value }. Keep globals in the Map, push each printed value onto out, and return out. Using or assigning a name that is not defined throws Error("Undefined variable 'NAME'."). Follow Drip: let without an initializer gives null, let may redefine a name, and assignment returns its value.

function run(prog) {
  const globals = new Map();
  const out = [];
  function evaluate(e) {
    switch (e.kind) {
      case 'num': return e.value;
      case 'add':
        return evaluate(e.left) +
          evaluate(e.right);
      // 'var' and 'assign'
    }
  }
  function execute(s) {
    // 'let', 'print' and 'expr'
  }
  for (const s of prog) execute(s);
  return out;
}
Extra practiceSwap two variables

Add statements between the lets and the prints so that the values of a and b are swapped. The program must print right and then left. Don’t change the lets or the prints.

let a = "left";
let b = "right";
// swap a and b here

print a;
print b;

What you learned

  • A Drip program is a list of statements, run in order. A runtime error stops the list, and earlier output stays.
  • Statements run for their side effects: print writes output, let creates a variable. An expression statement drops its value.
  • Global variables live in one table: let defines, a name looks up, and assignment overwrites a name that must already exist.
  • Assignment is an expression, so it has a value as well as an effect.
  • Using a name before its let has run is a runtime error.
  • A block gets its own table, discarded at the }.

Next: environments, the chain of tables that makes scope work.

Check your understanding

0/6
  1. 1.

    What does this Drip program print?

    let x = 1;
    x = x + 1;
    print x;
    x = x * 10;
  2. 2.

    What happens when Drip runs count = 1; and no count has been declared?

  3. 3.

    Which statement has no visible side effect?

  4. 4.

    What does let a = 1; let b = a = 7; print a; print b; print?

  5. 5.

    A program prints two lines, then line 3 is print zz; where zz was never declared, then line 4 prints a third line. What is the output?

  6. 6.

    Why can’t the parser report print a; let a = 1; as an error?

References

  1. [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)