Variables, Scope & Environments
Writing a Compiler/Interpreter: Fundamentals
Chapter 5 · Variables, Scope & Environments
Every program so far in this course has been stateless — each statement ran with no memory of the ones before it. This chapter gives the interpreter somewhere to actually store values: an Environment. Declaring a variable, reading it back, reassigning it, and scoping it to a block all come down to one design question — how does an environment find a name that wasn't declared in its own immediate scope? Get that wrong, and the bug it produces is exactly the kind that looks fine until you nest two blocks.
Four New Node Types
Variables touch both halves of the AST built so far. Reading one is an expression — x needs a value the same way 2 + 3 does. Declaring and reassigning are statements and an expression, respectively — var x = 5; is a new kind of statement, while x = 5 is a new kind of expression that happens to also cause an effect. Blocks are statements too — a way to group statements and, critically, introduce a new scope.
Assignment is parsed at the very top of the expression grammar, above equality — parse_expression() now calls a new assignment() level first, which parses an ordinary expression, then checks whether it's followed by =. If the left side wasn't a Variable node, that's a parse error — 2 + 2 = 5; is nonsense, and this is where it gets caught.
var x = 10; print x; var y = "hi"; print y; produces exactly ['10', 'hi'].
Undefined Variables Fail Loudly
Reading a name that was never declared, and assigning to one, both need to raise a real, specific error — not a raw Python KeyError from a dict lookup, and not a silently-returned nil that would mask a typo as a legitimate empty value.
print y; (with no prior var y) raises WispRuntimeError: undefined variable 'y'. y = 5; — assignment, not declaration — raises the exact same error. Assignment does not implicitly create a variable.
"use strict" JavaScript famously does, and it's a well-known source of accidental global variables from a single missing var/let. Wisp requires var for every new binding on purpose, so a typo in an assignment (totl = totl + 1; instead of total) is a loud runtime error instead of a silently-created, always-nil-until-now variable that quietly produces wrong output three lines later.
The Naive Environment: A Snapshot, Not a Chain
Blocks need their own scope — a variable declared inside { ... } shouldn't leak out. The obvious-looking first attempt: when entering a block, make a new environment by copying whatever the enclosing scope currently holds.
This looks reasonable, and even runs without error. The bug only shows up when a block reassigns a variable that belongs to an outer scope, then that outer scope is read again afterward.
var x = 10; { x = x + 5; } print x; using NaiveEnvironment prints 10 — the assignment inside the block never reached the outer x at all. The block's own environment is a copy of the global environment's values at the moment the block was entered; assigning inside the block updates that copy, which is discarded the instant the block ends.
The Fix: A Live Chain of Environments
The fix isn't to copy values at all — it's to keep a live reference to the enclosing environment, and walk up that reference chain whenever a name isn't found locally.
A block statement now executes its own statements against a brand-new Environment(self.env) — a child whose enclosing points straight at whatever environment was active before the block started — then restores the previous environment when the block ends. get and assign both fall through to self.enclosing when a name isn't local, recursing outward until either the name is found or the chain runs out.
var x = 10; { x = x + 5; } print x; using Environment instead of NaiveEnvironment prints 15. Nothing about the AST or the parser changed — only how the environment looks up a name that isn't its own.
Shadowing
A block declaring its own variable with the same name as an outer one shouldn't reassign the outer variable — it should create a brand-new binding that temporarily hides it. define() always writes into this environment's own values dict, never walking the chain the way assign() does — that asymmetry is exactly what makes shadowing work.
var x = "outer"; { var x = "inner"; print x; } print x; produces ['inner', 'outer']. Had the block instead written x = "inner"; (assignment, no var), it would have walked the chain and overwritten the outer x instead of creating a new one — the same distinction the last section's bug hinged on.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
Environment's live enclosing chain | Chapter 7's own closures depend on exactly this mechanism — a function capturing "its own defining environment" means capturing a live reference to one of these chains, not a snapshot |
A block gets a fresh child Environment, restored afterward | Chapter 6's own if/while bodies reuse this identical block-execution mechanism — a loop body is just a block executed repeatedly |
define() never walks the chain; assign()/get() always do | This one-line asymmetry is the entire mechanism behind both shadowing and the naive-environment bug this chapter demonstrated — worth re-reading if either ever looks surprising later |
| Assignment does not implicitly declare | Chapter 9's own error-handling chapter gives WispRuntimeError a real source line, making "undefined variable" errors as easy to locate as a parse error already is |
Hands-On Exercises
Run var x = 1; var x = 2; print x; — redeclaring x in the exact same scope — through this chapter's own interpreter. Determine what gets printed, and explain whether define()'s own implementation permits or blocks this, and why that's a genuinely different case from the shadowing example in this chapter (which redeclares across a block boundary, not within one scope).
Write a 3-level-deep nested program: a global variable, a block declaring a second variable, and an innermost block declaring a third — with the innermost block reading all three and then reassigning the global. Verify every read resolves correctly, and trace exactly how many enclosing hops get() needs to reach the global from the innermost block.
Run var x = 1; { var x = 2; { x = 99; } print x; } print x; under both NaiveEnvironment and Environment. The two disagree on one of the two printed values but agree on the other — determine which value differs, what each implementation actually prints for it, and explain precisely why the naive version's bug applies to the middle block's x but not the outer one in this particular program.
Chapter 5 Quick Reference
- Four new nodes:
Variable/Assign(expressions),VarStmt/BlockStmt(statements) - Verified: declaring and reading variables works end to end —
var x=10; print x;→10 - Verified: both undefined reads and undefined assignments raise
WispRuntimeError— assignment never implicitly declares - Verified — the core bug: a snapshot-copy
NaiveEnvironmentmakes a block's assignment to an outer variable silently vanish (printed 10, not 15) - Verified — the fix: a live
enclosingchain, walked byget()/assign(), makes the identical program correctly print 15 - Shadowing:
define()always writes locally;get()/assign()always walk the chain — that asymmetry is the whole mechanism - Next chapter: Control Flow —
if/while/for, built on this chapter's own block-execution machinery