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 expressionx 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.

@dataclass class Variable(Expr): # reading a variable's value name: str def accept(self, visitor): return visitor.visit_variable(self) @dataclass class Assign(Expr): # x = value -- an expression, not a statement name: str value: Expr def accept(self, visitor): return visitor.visit_assign(self) @dataclass class VarStmt(Stmt): # var x = value; (initializer may be None) name: str initializer: Expr def accept(self, visitor): return visitor.visit_var_stmt(self) @dataclass class BlockStmt(Stmt): # { ...statements... } statements: list def accept(self, visitor): return visitor.visit_block_stmt(self)

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.

Verified directly — declaring and reading variables works end to end
Running 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.

Verified directly — reading and assigning to an undeclared name both fail cleanly
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.
This is a deliberate design choice, not an obvious default
Some real languages let a bare assignment to an undeclared name silently create one — pre-"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.

class NaiveEnvironment: def __init__(self, enclosing=None): # BUG: copies the parent's CURRENT values into a new flat dict self.values = dict(enclosing.values) if enclosing else {} def assign(self, name, value): if name in self.values: self.values[name] = value return raise WispRuntimeError(f"undefined variable '{name}'")

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.

Verified directly — a real, reproduced scoping bug
Running 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.

class Environment: def __init__(self, enclosing=None): self.values = {} self.enclosing = enclosing # a LIVE reference, never copied def define(self, name, value): self.values[name] = value # always defines in THIS environment def get(self, name): if name in self.values: return self.values[name] if self.enclosing is not None: return self.enclosing.get(name) # walk UP the chain raise WispRuntimeError(f"undefined variable '{name}'") def assign(self, name, value): if name in self.values: self.values[name] = value return if self.enclosing is not None: self.enclosing.assign(name, value) # walk UP, don't create locally return raise WispRuntimeError(f"undefined variable '{name}'")

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.

Verified directly — the exact same program now gives the correct answer
Running the identical 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.

Verified directly — a block's own "var x" shadows, an assignment inside it wouldn't have
Running 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 findingWhat it connects to
Environment's live enclosing chainChapter 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 afterwardChapter 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 doThis 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 declareChapter 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

Exercise 1

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).

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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 NaiveEnvironment makes a block's assignment to an outer variable silently vanish (printed 10, not 15)
  • Verified — the fix: a live enclosing chain, walked by get()/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