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.
- 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);
}
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.
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.
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:
let x = 2;let y;print x;x = x * 10;print x + 1;print y;print z;
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.
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 }:
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.
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.
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;
}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:
printwrites output,letcreates a variable. An expression statement drops its value. - Global variables live in one table:
letdefines, 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
lethas 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.
What does this Drip program print?
let x = 1; x = x + 1; print x; x = x * 10; - 2.
What happens when Drip runs
count = 1;and nocounthas been declared? - 3.
Which statement has no visible side effect?
- 4.
What does
let a = 1; let b = a = 7; print a; print b;print? - 5.
A program prints two lines, then line 3 is
print zz;wherezzwas never declared, then line 4 prints a third line. What is the output? - 6.
Why can’t the parser report
print a; let a = 1;as an error?
References
- [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)