Global and Local Variables in Bytecode
Writing a Compiler/Interpreter: Advanced
Chapter 4 · Global and Local Variables in Bytecode
Every chunk so far has computed one throwaway value and returned it — nothing has been able to remember anything between instructions. Course 1's own Environment (Chapter 5) solved this with a dict per scope, chained to its parent. This chapter solves it twice, on purpose: globals, looked up by name at runtime, much like Course 1's own approach — and locals, which this chapter resolves entirely differently: not by name, not at runtime, but by position, worked out once, at compile time.
Six New Opcodes
| Opcode | Does |
|---|---|
| OP_POP | Discard the top of the stack — needed for expression statements, and for locals leaving scope |
| OP_DEFINE_GLOBAL | Pop a value, store it in the VM's own globals table under a name |
| OP_GET_GLOBAL | Look up a name in globals, push the result |
| OP_SET_GLOBAL | Overwrite an existing entry in globals — the new value stays on the stack |
| OP_GET_LOCAL | Push a copy of whatever's already sitting at a specific stack slot |
| OP_SET_LOCAL | Overwrite a specific stack slot with the current top of stack — which stays there |
A minimal OP_PRINT is also added here, purely so a multi-statement program can actually show something observable — Course 1's own PrintStmt node needs somewhere to compile to.
Globals: Looked Up by Name, at Runtime
A top-level var x = 10; compiles to: compile the initializer, then OP_DEFINE_GLOBAL with an operand pointing at the constant pool, where the variable's own name — the string "x" — lives as a constant, exactly like any other literal.
At runtime, the VM keeps one plain Python dict, self.globals. OP_GET_GLOBAL reads the name out of the constant pool and looks it up; OP_SET_GLOBAL overwrites an existing entry, matching Course 1's own rule that assignment never implicitly declares.
var x = 10; print x; compiles and runs to ['10']. var x = 10; x = 20; print x; runs to ['20'].
print nope; raises RuntimeError: Undefined variable 'nope'. — and so does nope = 1;, with the identical message. The VM's own OP_GET_GLOBAL/OP_SET_GLOBAL handlers both check if name not in self.globals before touching the dict, refusing to let a bare assignment silently create a new global — the same deliberate rule Course 1, Chapter 5 established.
Locals: Resolved by Position, at Compile Time
A local variable gets no runtime lookup at all — not by name, not by any kind of table. The compiler works out, while compiling, exactly which stack slot a local will occupy, and bakes that slot number directly into the instruction as a plain integer operand.
The trick that makes this work at all: a local's value never moves anywhere new. Once its initializer is compiled, the result is already sitting on the VM's own operand stack — the exact same stack arithmetic uses — at exactly the slot it needs to stay at. declare_local doesn't emit a single instruction; it just remembers, at compile time, which position that value now occupies. Locals aren't stored anywhere separate from the rest of the VM's own working stack.
var x = 1; { var x = 2; print x; } print x; runs to ['2', '1']. Inside the block, resolve_local finds the inner x first (searching innermost-outward) and compiles the print to OP_GET_LOCAL. Outside the block, the inner x has already been removed from self.locals by the time the second print x; is compiled, so it resolves to the global x instead, via OP_GET_GLOBAL. Two completely different instructions, decided entirely while compiling — the VM itself never has to ask "which x did the programmer mean."
var x = 1; { var y = 2; y = 5; print y; } print x; runs to ['5', '1'] — y's reassignment only ever touches its own stack slot, and x in the outer scope is never disturbed by anything happening to a same-numbered-or-not slot inside the block.
Leaving a Block: OP_POP, Once Per Local
When a block ends, every local declared inside it has to actually come off the stack — otherwise the stack keeps growing forever and every slot number calculated afterward would be wrong.
Two things happen for every local going out of scope, and both matter: an OP_POP is emitted so the VM's own stack actually shrinks back down at runtime, and the entry is removed from self.locals so any later resolve_local call can no longer find it — which is exactly the mechanism behind the shadowing test above.
A Real Performance Difference, Honestly Measured
A dict lookup by string name and a plain list index by integer are both, in the abstract, "fast" — but they're not the same cost, and it's worth actually measuring rather than assuming.
OP_GET_GLOBAL, dict lookup) — 0.0931s. Local (OP_GET_LOCAL, list index) — 0.0882s. A 1.056× advantage for locals. A more realistic mixed workload — 200,000 repetitions of i = i + 1, one get and one set per iteration — gives 1.045×, the same modest margin. Both ratios held consistently across repeated runs (median of 11 each).
dict and list types — the theoretical gap between "hash lookup" and "array index" gets mostly absorbed by Python's own implementation before it ever reaches this VM's own code. This is the same shape of result Chapter 1 found for tree-walking versus bytecode dispatch generally: architectural advantages that are large in a compiled, low-level implementation don't automatically transfer at full strength once everything is written in the same interpreted host language.
Same Ceiling, Different Reason
OP_GET_LOCAL/OP_SET_LOCAL encode a slot number as a single operand byte, the same way OP_CONSTANT encodes a constant-pool index — which means Chapter 2's own 256 ceiling shows up again here, for an entirely different quantity.
ValueError: byte must be in range(0, 256) — not because there are too many constants this time, but because a single-byte slot operand can't address a 257th simultaneously-live local. Two genuinely different resources (the constant pool, and the local-variable stack region) hit the identical numeric ceiling, for the identical structural reason: a one-byte operand.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Constant-pool deduplication, reused for variable names | Chapter 2, Exercise 2's own already-verified add_constant fix — without it, reading the same global repeatedly would exhaust the 256-constant ceiling after just 256 reads, a real bug this chapter's own benchmark hit while being built |
| Locals live directly on the VM's own value stack, with no separate storage | Chapter 6's own call frames will extend this same idea — a function call's own locals will occupy a fresh region of the same stack, offset from a frame's own base pointer, rather than needing a new data structure |
| Locals resolved entirely at compile time; globals resolved entirely at runtime | Course 1, Chapter 5's own single, uniform Environment chain — this course deliberately splits what Course 1 handled one way into two genuinely different mechanisms, chosen for what each one is actually used for |
| A modest, honestly-measured ~5% speed advantage for locals over globals | Chapter 1's own finding that Python-on-Python bytecode dispatch doesn't automatically inherit the performance characteristics of a real, compiled VM |
Hands-On Exercises
Compile { var a = 1; { var b = 2; print a; print b; } print a; } — two nested blocks, two locals at different depths — using this chapter's own Compiler. Trace through declare_local/resolve_local by hand to determine which stack slot each of a and b occupies, then verify your trace against the actual output.
Build a Chunk/Compiler pair using a version of add_constant without Chapter 2 Exercise 2's own deduplication fix. Compile a program that declares one global and then reads it 300 times in a row. Determine whether this fails, and if so, explain precisely why — given that the program only ever refers to a single variable name.
Compile and run var x = 1; var y = 2; x = y = 10; print x; print y; — a chained assignment, where the right-hand side of x = ... is itself an Assign expression. Verify the result, and explain specifically which line of visit_assign makes this work without any special-case code for chained assignment at all.
Chapter 4 Quick Reference
- Globals: a name-keyed
dicton the VM;OP_DEFINE_GLOBAL/OP_GET_GLOBAL/OP_SET_GLOBAL, resolved by name at runtime - Locals: plain slots on the VM's own operand stack;
OP_GET_LOCAL/OP_SET_LOCAL, resolved by position at compile time —declare_localemits zero instructions - Verified: the classic shadowing test —
var x=1; {var x=2; print x;} print x;— correctly prints2then1, decided entirely at compile time - Verified: undeclared reads and assignments both fail with a clean
RuntimeError, matching Course 1's own rule that assignment never implicitly declares - Verified: locals beat globals by a real but modest ~5% in this pure-Python VM — architectural advantages don't fully transfer once both paths run through the same host interpreter
- Verified: a 256-slot ceiling applies to simultaneously-live locals too — same one-byte-operand cause as Chapter 2's constant-pool ceiling, different resource
- Next chapter: Control Flow & Jumps in Bytecode — compiling
if/whileusing instructions that overwrite their own operand byte after the fact