05 — Environments and Lexical Scope
The environment model answers one question: when a variable is used, which binding does it refer to? MiniLang uses lexical scope — the binding is determined by where the code is written, not where it is called.
The Environment structure
class Environment {
std::unordered_map<std::string, Value> vars_;
std::shared_ptr<Environment> parent_;
public:
explicit Environment(std::shared_ptr<Environment> parent = nullptr);
void define(const std::string& name, Value v);
Value& get(const std::string& name);
void set(const std::string& name, Value v);
};
get walks up the parent_ chain until it finds the name or reaches the
top-level (null parent) and throws "undefined variable". set does the
same but writes back instead of reading.
Chain creation for blocks and calls
When entering a block:
void Interpreter::visitBlock(BlockStmt& b) {
auto child = std::make_shared<Environment>(env_);
std::swap(env_, child);
for (auto& s : b.body) execute(*s);
std::swap(env_, child); // restore on exit (RAII alternative below)
}
When calling a function, a fresh environment is created with the function's closure (the captured enclosing environment) as parent — not the caller's current environment. This is what makes lexical scope different from dynamic scope.
Why shared_ptr?
A closure can outlive the scope that created it:
fn makeCounter() {
var n = 0;
fn inc() { n = n + 1; return n; }
return inc;
}
let c = makeCounter();
print c(); // 1
print c(); // 2
Here inc captures the environment created when makeCounter ran, and
that environment holds n. After makeCounter returns, the n binding
is still alive because the closure inc holds a shared_ptr to it. When
c is eventually garbage-collected, the shared_ptr ref-count drops to
zero and the environment is freed.
If environments were stored on the stack by value, the closure would hold
a dangling reference. shared_ptr is the minimal-complexity solution;
real VMs use heap-allocated scope frames instead.
The define vs set distinction
definealways writes in the current environment (creates a new slot).setwalks the parent chain to find an existing binding and updates it.
This matters for:
var x = 1;
{
var x = 2; // define: creates a NEW x in the inner scope
x = 3; // set: updates the INNER x
}
print x; // still 1 — the outer x was never touched
Without the distinction, x = 3 inside the block would climb to the outer
x, breaking scope.
Scope chain depth
Every define at block entry and every scope exit is O(1). Every get and
set is O(depth) — proportional to the nesting depth of scopes. In
practice depth is small (rarely > 10 for real programs), so this is
acceptable.
cp-04 introduces depth annotations on variable uses that let the interpreter do one hash-map lookup at the right depth instead of walking every parent:
Value& getAt(int depth, const std::string& name); // cp-04 addition
For cp-03, the naive walk is fine and pedagogically clearer.