04 — Depth Annotations: O(1) Environment Lookups
After the resolver runs, every local variable reference has an integer
depth stored in interp_.locals_. This step shows how the interpreter
uses that information to avoid chain walking.
The getAt and setAt methods
Value& Environment::getAt(int depth, const std::string& name) {
Environment* e = this;
for (int i = 0; i < depth; ++i) e = e->parent_.get();
return e->vars_.at(name);
}
void Environment::setAt(int depth, const std::string& name, Value v) {
Environment* e = this;
for (int i = 0; i < depth; ++i) e = e->parent_.get();
e->vars_[name] = std::move(v);
}
The loop here is O(depth), but depth is a small compile-time constant for
each site. More importantly, it avoids calling vars_.count() at every
intermediate scope — the lookup is direct.
lookUpVariable and assignVariable
Value Interpreter::lookUpVariable(VarExpr& e) {
auto it = locals_.find(&e);
if (it != locals_.end())
return env_->getAt(it->second, e.name);
return globals_->get(e.name); // fallback to globals
}
void Interpreter::assignVariable(AssignExpr& e, Value v) {
auto it = locals_.find(&e);
if (it != locals_.end())
env_->setAt(it->second, e.name, std::move(v));
else
globals_->set(e.name, std::move(v));
}
The locals_ map key is the pointer to the AST node, not the variable
name. This is intentional: two different uses of x in the source (two
VarExpr nodes) can refer to bindings at different depths, and the
pointer uniquely identifies which AST node is being evaluated.
Why pointer keying is correct
let x = 1;
fn f() {
let x = 2;
print x; // VarExpr node A — depth 0 in f's scope
}
print x; // VarExpr node B — depth 0 in globals
Both uses are named x, but they are different AST nodes. The resolver
annotated A as depth=0 and B as not-in-locals (global). The interpreter
uses the pointer to distinguish them.
If the key were the name string, both would map to the same entry and one would be wrong.
Measuring the improvement
Without annotations, getAt is O(depth) and calls find at every
intermediate scope. With annotations, it's still O(depth) for the loop
but does only one find at the target scope. The real saving is
correctness (dynamic-scope bug eliminated) more than raw speed.
For a program with average nesting depth 3:
- Without annotations: 3 × (hash_lookup + parent_deref) per variable use.
- With annotations: 3 × parent_deref + 1 × hash_lookup.
At scale this matters. The JVM and V8 both keep variable annotations in bytecode for the same reason — not because they need O(1) vs O(3), but because the slot index is also used for register allocation and inlining.
Environment structure with slots
Production VMs go one step further: instead of named maps, each scope is
a slot array and each variable use has a slot index. getAt(depth, slot) is just pointer arithmetic:
// production VM sketch
env[depth].slots[slot]
cp-03/04 uses named maps for clarity. cp-06+ introduces bytecode with explicit stack slots, which is the array-indexed equivalent.