Closures, Scope, Modules and Object Systems

A closure is a function that remembers the variables around it when it was created, even after the function that created them has returned.

Learning Objectives

Explain closures: free and captured variables, open and closed upvalues, heap-allocated environments, mutable captures and closure lifetime; first-class, higher-order, anonymous and mutually recursive functions.

Describe module systems: imports and exports, initialisation order, circular imports, namespaces and symbol resolution.

Describe object systems: classes, method dispatch, vtables, prototypes, property lookup and inline-cache motivation, reflection, metaprogramming and dynamic loading.

Plain-Language Explanation

A closure is a function that remembers the variables around it when it was created, even after the function that created them has returned. A module is a file of code with its own names that other files can import. An object is a bundle of data with functions attached; calling a method means finding the right function for this particular object.

Precise Technical Explanation

Lexical scope revisited A function can use variables from the scopes surrounding its definition; the compiler resolves such uses to captured variables.

A closure is a function plus the environment (or the captured variables) it needs from its defining scope.

Variables used in a function but not declared in it (and not global): count is free in the inner function of makeCounter.

Free variables the closure must keep alive — captured by reference in MiniLang, JavaScript and Python (closures see later assignments), by value in some languages or capture modes (C++ [=], Java's "effectively final" rule).

Open and closed upvalues An upvalue is open while the captured local still lives in an active stack frame (it points at the stack slot) and closed after the frame ends (the value has been moved into the upvalue object). MiniLang emits CLOSE_UPVALUE when a captured local goes out of scope and closes all of a frame's open upvalues on RETURN.

Heap-allocated closure environments Tree-walkers capture whole environment objects; bytecode VMs capture only the needed variables (upvalues), which is cheaper and avoids keeping unrelated variables alive.

Two closures capturing the same variable share it: both see each other's assignments. Open upvalues are deduplicated per stack slot so sharing works.

A captured variable lives as long as any closure referencing it — managed by the garbage collector.

Functions are values: stored, passed, returned, compared by identity.

Functions taking or returning functions (map, callbacks, decorators, makeCounter).

Anonymous functions and lambdas Function expressions without a name (function () {}, x => x * 2, Python lambda). MiniLang has only named function declarations; local named functions serve the same purpose.

Recursion and mutual recursion A function referring to itself or two functions referring to each other: works because names are resolved when called (globals) or because the local is declared before its body is compiled.

Files with their own namespaces and explicit interfaces: CommonJS and ES modules, Python modules and packages, Java packages and JPMS modules, .NET assemblies.

Imports and exports Exports declare what a module offers; imports bind names to another module's exports (by copy of the binding in ESM — live bindings; by object attribute in Python).

Module initialization order A module's top-level code runs once, the first time it is imported; importers see a fully or partially initialised module depending on order.

Circular imports and dependency cycles If A imports B and B imports A, one of them sees the other partially initialised — names defined after the import statement are missing. Restructure to break the cycle or defer imports.

Namespace and symbol resolution module.name lookups, re-exports, qualified names; at run time a module is often a dictionary or object of bindings (Python's module.__dict__).

Classes and objects in language runtimes A class describes instances' fields and methods; an object stores a pointer to its class plus its fields.

Finding the implementation of obj.method() for the object's dynamic type: walk the class hierarchy (Python's MRO), or index a table.

Virtual method tables (vtable) overview Statically typed OO runtimes (C++, JVM, CLR) give each class an array of method pointers; a virtual call loads the object's vtable and calls the entry at a fixed index — constant time. Interfaces need extra machinery (itables, interface dispatch stubs).

Prototype-based object models JavaScript objects delegate to a prototype object; property lookup walks the prototype chain.

Property lookup and inline-cache motivation Dynamic lookups (hash maps, chains) are slow; engines cache, per call site, where a property was found for the last seen object shape — the inline cache (Modules 16, 20, 25).

Reflection and introspection Programs examining types, fields and methods at run time (getattr, Java reflection, System.Reflection).

Programs that create or modify program structure: decorators, metaclasses, proxies, macros, code generation.

Dynamic loading and plugin models Loading code at run time (Java class loaders,.NET AssemblyLoadContext, Python importlib, dlopen) — powerful and a trust boundary.

Closure example in JavaScript (Node.js 24.18):

count remains reachable after makeCounter returns because the returned function references it; how the engine stores it (a heap-allocated context object in V8) is an implementation detail.

function makeCounter() {
  let count = 0;
  return function () {
    count += 1;
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2

Why It Matters in Real Systems

Callbacks, event handlers, iterators, dependency injection and module systems all rely on closures and module initialisation; object dispatch dominates the performance of OO code.

Key Terms

Used but not declared in a function Upvalue (open/closed) Captured-variable reference pointing to the stack / holding its own copy Two modules importing each other Per-class array of method pointers Linked objects searched for properties

Syntax or Instruction Format

MiniLang: CLOSURE k (isLocal, index)…, GET_UPVALUE u, SET_UPVALUE u, CLOSE_UPVALUE.

Complete Example

MiniLang closures: two independent counters, a shared captured variable, and the bytecode that implements them:

Output (both back ends):

Output (inc reads and writes count through upvalue 0; CLOSURE records that it captures local slot 0 of makeCounter):

Python's closures use *cells*, and its modules initialise once and can be seen partially initialised in a cycle (CPython 3.13.1; the program writes two small modules to a temporary folder and imports them):

Prototype lookup in JavaScript and method resolution order in Python (Node.js 24.18 and CPython 3.13.1):

fun makeCounter() {
  let count = 0;
  fun inc() { count = count + 1; return count; }
  return inc;
}
let a = makeCounter();
let b = makeCounter();
a(); a();
print(a());          // 3 — a's own count
print(b());          // 1 — b has a separate count

fun pair() {
  let shared = 0;
  fun add() { shared = shared + 10; return shared; }
  fun get() { return shared; }
  add();
  return get;        // get sees the variable add changed
}
print(pair()());
fun makeCounter() {
  let count = 0;
  fun inc() { count = count + 1; return count; }
  return inc;
}
== <script> ==
constants: 0 → <fn makeCounter>
names:     0 → makeCounter
0000    1 CLOSURE          0    ; <fn makeCounter>
0002    | DEFINE_GLOBAL    0    ; 'makeCounter'
0004    | HALT

== makeCounter (0 parameters) ==
constants: 0 → 0, 1 → <fn inc>
0000    2 CONSTANT         0    ; 0
0002    3 CLOSURE          1    ; <fn inc> captures [local 0]
0006    4 GET_LOCAL        1    ; slot 1
0008    | RETURN

== inc (0 parameters) ==
constants: 0 → 1
0000    3 GET_UPVALUE      0    ; upvalue 0
0002    | CONSTANT         0    ; 1
0004    | ADD
0005    | SET_UPVALUE      0    ; upvalue 0
0007    | POP
0008    | GET_UPVALUE      0    ; upvalue 0
0010    | RETURN
import os, sys, tempfile, textwrap


def make_counter():
    count = 0
    def inc():
        nonlocal count
        count += 1
        return count
    return inc


c = make_counter()
c(); c()
print("free vars:", c.__code__.co_freevars, "cell contents:", c.__closure__[0].cell_contents)

d = tempfile.mkdtemp()
open(os.path.join(d, "mod_a.py"), "w").write(textwrap.dedent("""
    print("mod_a: start")
    import mod_b
    VALUE_A = "A"
    print("mod_a: end, sees mod_b.VALUE_B =", mod_b.VALUE_B)
"""))
open(os.path.join(d, "mod_b.py"), "w").write(textwrap.dedent("""
    print("mod_b: start")
    import mod_a
    print("mod_b: mod_a has VALUE_A?", hasattr(mod_a, "VALUE_A"))
    VALUE_B = "B"
"""))
sys.path.insert(0, d)
import mod_a
import mod_a                      # second import: cached in sys.modules, top-level code does not run again
print("in sys.modules:", "mod_a" in sys.modules, "mod_b" in sys.modules)
free vars: ('count',) cell contents: 2
mod_a: start
mod_b: start
mod_b: mod_a has VALUE_A? False
mod_a: end, sees mod_b.VALUE_B = B
in sys.modules: True True
const animal = { describe() { return `${this.name} says ${this.sound()}`; }, sound() { return "..."; } };
const dog = Object.create(animal);
dog.name = "Rex";
dog.sound = function () { return "woof"; };
console.log(dog.describe());                          // describe found on the prototype, sound on dog
console.log(Object.getPrototypeOf(dog) === animal, Object.hasOwn(dog, "describe"));
Rex says woof
true false
class Base:
    def hello(self):
        return "Base.hello"

class Left(Base):
    def hello(self):
        return "Left.hello → " + super().hello()

class Right(Base):
    def hello(self):
        return "Right.hello → " + super().hello()

class Child(Left, Right):
    pass

print([cls.__name__ for cls in Child.__mro__])
print(Child().hello())
['Child', 'Left', 'Right', 'Base', 'object']
Left.hello → Right.hello → Base.hello

Line-by-Line Explanation

MiniLang: each makeCounter() call creates a new frame with its own count; inc captures it, so a and b have independent counts. In pair, add and get capture the *same* variable (one upvalue object), so get sees 10.

Bytecode: makeCounter returns inc while count is still in its frame; RETURN closes the upvalue (copies count to the heap) so inc keeps working after the frame is gone.

Python: co_freevars lists captured names; __closure__ holds cells; the cycle shows mod_b running while mod_a is only half initialised (VALUE_A missing); the second import hits the sys.modules cache.

JavaScript: describe is not an own property of dog; lookup walks to the prototype; this.sound() dispatches back to dog's own method.

Python MRO: C3 linearisation orders Child, Left, Right, Base, object; super() follows the MRO, not the static parent.

Step-by-Step Runtime Behavior

a = makeCounter(): frame for makeCounter → count in slot 0 → CLOSURE captures slot 0 (open upvalue) → RETURN closes it → a holds a closure whose upvalue owns count = 0. Each a() reads and writes through GET_UPVALUE/SET_UPVALUE.

Common Misconceptions

"Closures copy variables." Most capture by reference; loops that capture a loop variable can surprise (JavaScript var versus let, Python late binding in lambdas).

"Importing a module twice runs it twice." It is cached.

"Method lookup is always a table index." Only in statically typed vtable designs; dynamic languages search and cache.

Common Bugs and Failure Modes

Late-binding bugs in closures created in loops; memory leaks through closures retaining large objects; circular imports seeing missing names; MRO surprises with multiple inheritance.

Corrected or Safer Patterns

Bind loop values explicitly (default arguments in Python, let in JavaScript); keep closures small; restructure module cycles; use super() consistently in cooperative hierarchies.

Debugging and Inspection Techniques

Inspect captured variables (__closure__, browser devtools' "Closure" scope), print sys.modules, Class.__mro__, Object.getPrototypeOf.

Performance Considerations

Capturing only needed variables (upvalues) keeps memory small; vtable dispatch is constant time; dynamic lookup needs inline caches and hidden classes to be fast.

Security Considerations

Dynamic loading and reflection can reach any code; restrict class loaders and plugin directories; never import modules named by untrusted input.

Portability and Version Caveats

Closure representation (cells, contexts, upvalues), module caching and MRO details are implementation- and language-specific.

Real-World Use Cases

Event handlers, iterators, memoisation, dependency injection containers, plugin systems, ORMs (metaprogramming), mocking frameworks (reflection and proxies).

Best Practices

Capture minimally, avoid import cycles, prefer composition over deep hierarchies, use reflection only at well-defined boundaries.

Exercises

E1 · Beginner. What does fun f() { let x = 1; fun g() { return x; } x = 2; return g; } print(f()()); print?

Answer: 2 — g captures the variable x by reference and sees the assignment made before f returned.

E2 · Intermediate. Why does the MiniLang VM need CLOSE_UPVALUE for block-scoped captured variables?

Answer: When a block ends, its locals are popped while the enclosing function continues; a closure created in the block must keep the variable, so the VM copies it into the upvalue (closing it) before popping the slot.

E3 · Advanced challenge. How does a JavaScript engine make dog.describe fast despite prototype lookup?

Answer: It gives objects hidden classes (shapes) and caches, at each property-access site, the shape seen and where the property was found (own slot or prototype holder); on later accesses with the same shape it skips the lookup after a shape check (with validity cells invalidated if the prototype changes).

Quiz

? What is an open upvalue?
+ A captured-variable reference that still points into a live stack frame. :: It is closed when the frame ends.
- A global variable. :: Globals are not upvalues.
- A function with no parameters. :: Unrelated.
- An unverified instruction. :: Unrelated.

? In a circular import between modules A and B, what can B observe?
+ A partially initialised A, missing names defined after A's import of B. :: Module code runs once, in order.
- A fully initialised A. :: A has not finished running.
- An ImportError always. :: The import succeeds; names may be missing.
- Two copies of A. :: The module is cached once.

? Which use per-class tables or caches to make method calls fast? (select all that apply)
+ JVM virtual calls via vtables :: Constant-time dispatch.
+ JavaScript engines via inline caches on hidden classes :: Cache lookups per call site.
- MiniLang's PRINT instruction :: Not a method call.
+ .NET virtual calls via method tables :: Similar to vtables.

Summary and Key Takeaways

Closures capture variables, not values; VMs implement them with upvalues that move from the stack to the heap. Modules run once and are cached, which makes cycles observable. Object systems dispatch through vtables, MROs or prototype chains, made fast with caches.

Further Reading

*Crafting Interpreters*, ch. 25 "Closures".

Python Language Reference, §5 "The import system"; "The Python 2.3 Method Resolution Order".

V8 blog, "Fast properties in V8".