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_GLOBALwrite to (refer ahead to cp-07)? - If you wanted to allow
let x = x;legally (reading the outerxto init the inner one), where would you change the compiler? - How would you add
OpGetLocalLongto support more than 256 locals?