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

  • define always writes in the current environment (creates a new slot).
  • set walks 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.