Step 4 — Locals vs Globals

The compiler resolves every variable reference to one of two operations:

  • GET_GLOBAL / SET_GLOBAL / DEF_GLOBAL — a hash lookup at runtime, keyed by the name string in the constants pool. Used for any binding declared at the top level.
  • GET_LOCAL / SET_LOCAL — a direct stack-slot fetch. Used for any binding declared inside a block.

The split mirrors Lox, CPython, and Lua. Globals are slow-but-flexible (you can monkey-patch them, late-bind them, redefine them); locals are fast-but-strict (their slot is baked into the bytecode at compile time).

Tracking Locals at Compile Time

The compiler keeps a flat std::vector<Local> parallel to the runtime stack layout:

struct Local {
    std::string name;
    int         depth;       // scope depth at declaration
    bool        isConst;     // `let` is true, `var` is false
};
std::vector<Local> locals_;
int scopeDepth_ = 0;

The index in locals_ is the stack slot. When the compiler emits OpGetLocal n, the runtime will compute frame.slots[n] and push that.

Entering and leaving scope

void beginScope() { ++scopeDepth_; }

void endScope(int line) {
    while (!locals_.empty() && locals_.back().depth >= scopeDepth_) {
        emit(Op::Pop, line);
        locals_.pop_back();
    }
    --scopeDepth_;
}

Every local that leaves the scope must be popped off the runtime stack, so we emit one Op::Pop per local being removed. The compiler's local table is the source of truth for runtime stack layout.

Declaring

void addLocal(const std::string& name, bool isConst, int line) {
    for (int i = locals_.size() - 1; i >= 0 && locals_[i].depth == scopeDepth_; --i) {
        if (locals_[i].name == name)
            error(line, "variable '" + name + "' already declared in this scope");
    }
    locals_.push_back({name, scopeDepth_, isConst});
}

Same-scope redeclaration is forbidden. Cross-scope shadowing is allowed — the resolver in cp-04 already enforced this, but the compiler double-checks because it's the one assigning slots.

Resolving

int resolveLocal(const std::string& name) const {
    for (int i = locals_.size() - 1; i >= 0; --i)
        if (locals_[i].name == name) return i;
    return -1;
}

We walk backwards so inner shadowing wins (the most recently declared x is the one in scope).

How Globals Are Encoded

void Compiler::visit(IdentExpr& e) {
    int slot = resolveLocal(e.name);
    if (slot >= 0) {
        emit(Op::GetLocal, e.line);
        emit(static_cast<uint8_t>(slot), e.line);
    } else {
        uint8_t ix = makeConstant(Value::makeString(e.name), e.line);
        emit(Op::GetGlobal, e.line);
        emit(ix, e.line);
    }
}

The global name ("x", "foo") is interned into the constants pool. At runtime, cp-07's VM will do globals[constants[ix].s] — a hash lookup. Note the same name string is reused for DEF_GLOBAL / GET_GLOBAL / SET_GLOBAL thanks to addConstant deduplication.

Init Expression and the Stack

void Compiler::visit(LetStmt& s) {
    if (s.init) visit(*s.init); else emit(Op::Nil, line);
    if (scopeDepth_ == 0) {
        uint8_t ix = makeConstant(Value::makeString(s.name), line);
        emit(Op::DefGlobal, line); emit(ix, line);
    } else {
        addLocal(s.name, s.kind == DeclKind::Let, line);
        // Init value is already on top of the stack — that's our local slot.
    }
}

A subtle invariant: for a local, the init expression leaves the value on the stack and we just say "from now on, that stack slot is named s.name". No OpDefLocal opcode is needed — the local exists implicitly the moment we record it in locals_.

Why Globals Survive endScope

endScope pops every local with depth >= scopeDepth_. Globals have depth == 0 but they aren't in locals_ at all — they're emitted as OpDefGlobal which writes to the runtime hash table and Pop, not to the stack. So they survive scope exit by living somewhere the scope-exit pop loop doesn't touch.

Self-Check

  • Why do we resolve locals back-to-front?
  • What runtime data structure does DEF_GLOBAL write to (refer ahead to cp-07)?
  • If you wanted to allow let x = x; legally (reading the outer x to init the inner one), where would you change the compiler?
  • How would you add OpGetLocalLong to support more than 256 locals?