R0KernelPath
module 135 / 262

Modularity, State & Environments

Master the environment model of evaluation, closures, and mutable state — the conceptual bedrock that explains how every function, variable, and module in every language actually works.

~18h focused study12 concepts

Overview

Every program you have ever written secretly depends on a hidden bookkeeping system: the environment. When you call a function, something must remember what x means inside it, while a completely different x lives outside it. When a closure captures a variable, something must keep that variable alive even after its enclosing scope has "returned". When you mutate state, something must decide which of many possible xs gets changed. This module makes that bookkeeping system explicit, precise, and beautiful.

The environment model of evaluation is the formal account of how a program runs. It supersedes the simpler "substitution model" (which pretends that calling f(3) just textually replaces every x with 3) by introducing frames linked into chains: a structured, pointer-based model that correctly handles mutation, closures, and higher-order functions. SICP, the canonical MIT 6.001 textbook, devotes an entire chapter to it — and for good reason. Once you see it, you can predict the behavior of any program in any language, not just the one you are currently using.

Mutable state is the most powerful and most dangerous tool in the programmer's toolkit. It lets you build accumulators, caches, message-passing objects, and streaming parsers that would be impossible (or grotesquely inefficient) in a purely functional style. But mutation breaks the beautiful property that a name always means the same thing, which is the foundation of equational reasoning. The module therefore examines when to reach for mutation, how to contain its damage through encapsulation and modules, and what formal price you pay.

Closures and namespaces are the primary tools for managing complexity at scale. A closure is a function bundled with its environment — a zero-cost object, or alternatively a poor person's object system. A module is a named namespace that hides implementation details behind a public interface. Both are expressions of the same deep idea: selective information hiding. This module builds all three ideas from first principles, using runnable Python and Scheme-style pseudocode, and shows how they compose into the modularity techniques used in production systems at every scale.

why this matters for the kernel

The environment model is not a curiosity for language theorists — it is the mental model that separates programmers who debug by guessing from programmers who debug by reasoning. When a JavaScript developer is confused by this, when a Python developer is puzzled by a UnboundLocalError, when a C developer gets a use-after-free, the root cause is almost always a mismatch between their mental model of environments and what the language actually does. Internalizing this model once, rigorously, pays compound interest across every language you will ever use.

At the architectural level, the tension between mutable state and modularity is the central problem of software engineering. Every major paradigm shift — from structured programming (Dijkstra) to object-oriented programming (Smalltalk, Simula) to functional programming (ML, Haskell) to reactive programming (RxJS, Elm) — is primarily a different answer to the question: "how do we manage state so that programs remain comprehensible as they grow?" Understanding the formal costs and benefits of mutation is therefore not academic; it is the prerequisite for making principled architectural decisions in real systems, from a 200-line script to a million-line codebase.

Environment Chain for a Closure Call

rendering diagram…

When add5(3) is called, frame E2 is created for the inner function 'add', with its enclosing pointer set to E1 (where 'add' was defined, not where it is called). Lookup for 'k' finds it immediately in E2; lookup for 'n' follows the enclosing pointer to E1. This is the environment model in action: the chain of frames, not the call stack, determines name resolution.

The concepts

01The Substitution Model and Why It Breaks

The simplest mental model for evaluation is textual substitution: to evaluate square(5) where square is defined as lambda x: x * x, imagine replacing every x in the body with 5, getting 5 * 5 = 25. This model is clean, requires no auxiliary data structures, and correctly predicts the behavior of purely functional programs with no mutation.

But the substitution model silently assumes that a name has exactly one value for all time. The moment we introduce set! (Scheme) or = reassignment (Python/C), the model collapses. Consider:

x = 1
def f(): return x
x = 2
print(f())  # prints 2, not 1

Under substitution, f would have been built with x=1 baked in. Under the real model, f looks up x at call time in a shared environment, so it sees 2. The substitution model gives the wrong answer.

The deeper problem is aliasing: two names pointing at the same mutable object. If a = [] and b = a, then a.append(1) changes what b sees too — even though we only touched a. Substitution cannot represent this at all. We need a model based on memory locations and pointers, not textual replacement.

# Substitution model would predict f() == 1; real answer is 2
x = 1
def f(): return x
x = 2
print(f())  # 2

# Aliasing — substitution can't represent this
a = []
b = a
a.append(42)
print(b)  # [42]

02Frames, Environments, and the Environment Model

The environment model replaces name-as-value with name-as-binding-in-frame. A frame is a table mapping names to values (or to locations in memory). An environment is a chain of frames: each frame has a pointer to an enclosing frame, except the global frame whose enclosing pointer is null.

Evaluation rules become:

  1. Lookup: to find the value of name x, search the current frame; if not found, follow the enclosing pointer and repeat; if the global frame has no binding, raise NameError.
  2. Assignment (x = 5): find the frame where x is already bound (in Python, this is always the current frame unless global/nonlocal is declared) and update it.
  3. Function call: create a new frame whose enclosing pointer is the frame in which the function was defined (not called!). Bind the parameters in this new frame, then evaluate the body in this new environment.

Rule 3 is the critical one. The enclosing pointer follows lexical scope (where the function was written), not dynamic scope (where it was called). This is why closures work.

The diagram in this module shows a concrete environment chain. Every Python locals() call, every JavaScript closure bug, every C stack frame — all of these are instances of exactly this structure.

# Trace the environment model manually:
x = 10           # Global frame: {x: 10}

def adder(n):     # Global frame: {x:10, adder: <fn>}
    def add(k):  # new frame E1: {n: ?}, enclosed by Global
        return n + k   # looks up n in E1, k in E1
    return add

add5 = adder(5)  # E1={n:5}, returns add (closure over E1)
print(add5(3))   # new frame E2={k:3}, enclosed by E1; n found in E1 => 8

03Closures: Functions as First-Class Values with Captured Environments

A closure is a pair: (function-code, pointer-to-defining-environment). When you return a function from another function, you are returning this pair. The defining environment keeps all captured bindings alive, even after the outer function's frame would normally be garbage-collected.

This is the mechanism behind:

  • Partial application / currying: add5 = adder(5) creates a closure that remembers n=5.
  • Private state without classes: wrap mutable state in a closure; only the returned functions can touch it.
  • Callbacks and event handlers: a button click handler closes over the loop variable — a classic source of bugs.

The classic JavaScript loop bug illustrates closure semantics precisely:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// prints: 3 3 3  (not 0 1 2)

All three callbacks close over the same i binding (because var is function-scoped, not block-scoped). By the time they run, the loop has finished and i === 3. The fix is let i, which creates a new binding per iteration, so each closure captures a distinct frame.

In Python, closures capture variables (bindings), not values. You can mutate the captured binding with nonlocal. This is subtle: if you forget nonlocal, Python creates a new local variable instead of updating the captured one, and you get UnboundLocalError on read-before-write.

# Python closure with mutable captured state
def make_counter():
    count = 0
    def increment():
        nonlocal count   # without this: UnboundLocalError
        count += 1
        return count
    return increment

c = make_counter()
print(c(), c(), c())  # 1 2 3

d = make_counter()    # fresh environment, fresh count
print(d())            # 1  (independent of c)

04Lexical vs. Dynamic Scope

Lexical scope (also called static scope): a name is resolved by looking up the chain of frames in which the function was textually defined. This is what Python, JavaScript, C, Haskell, and most modern languages use.

Dynamic scope: a name is resolved by looking up the chain of frames in which the function was called at runtime. Early Lisps (pre-Common Lisp), Emacs Lisp in legacy mode, and some shell languages use dynamic scope.

The difference is observable only when a function is called from a different lexical context than where it was defined:

;; Lexical scope (Scheme):
(define x 1)
(define (f) x)          ; f closes over the global x=1
(define (g) (define x 2) (f))  ; local x=2, but f sees global
(g)  ;=> 1

;; Dynamic scope (hypothetical):
;; (g) would call f; at call time, x=2 is in scope => result 2

Lexical scope enables referential transparency within closures and makes programs easier to reason about: you can understand f by reading its definition, without knowing who calls it. Dynamic scope is more flexible for some metaprogramming patterns (it is similar to how Python's contextlib.contextmanager or Common Lisp's let can rebind dynamic variables), but it destroys the closure abstraction.

C has neither in the full sense, because C functions are not first-class and cannot be returned. But C's block scoping and the rule that a function sees the globals at its definition site implement lexical scope for the cases C supports.

# Python is lexically scoped:
x = 'global'
def outer():
    x = 'outer'
    def inner():
        return x          # resolves to 'outer' lexically
    return inner

f = outer()
x = 'changed global'
print(f())  # 'outer' — lexical, not dynamic
            # dynamic scope would give 'changed global'

05Mutable State: Power and Peril

Pure functions are functions whose output depends only on their input and which produce no side effects. They are trivially parallelizable, trivially testable, and trivially cacheable. The price is that they cannot model time: a bank account balance that changes, a cache that grows, an iterator that advances.

Mutable state introduces a time dimension: the same expression evaluated at different times can return different values. This breaks two beautiful properties:

  1. Referential transparency: you can no longer freely substitute equals for equals. balance evaluated before a withdrawal is not the same as balance after.
  2. Confluence (Church-Rosser): the order of evaluation now matters. Two mutations to the same variable produce different results depending on which happens first.

The costs multiply in concurrent programs: two threads mutating the same state without synchronization produce data races, whose effects are undefined at the hardware level (due to cache coherence protocols and compiler reordering). This is why functional programming languages, immutable data structures, and pure functions are not just academic preferences — they are engineering tools for managing complexity.

The key insight from SICP: use assignment only when the real-world entity being modeled has genuine time-varying state (a counter, a queue, an account). If an entity is timeless (a mathematical function, a geometric shape), model it without assignment. This rule of thumb prevents enormous classes of bugs.

# Pure function — safe to call any number of times in any order
def add(a, b): return a + b

# Impure function — calling it twice gives different results
total = 0
def accumulate(x):
    global total
    total += x
    return total

print(accumulate(5))  # 5
print(accumulate(5))  # 10  -- same call, different answer

06Encapsulation: Taming State with Closures and Objects

The antidote to uncontrolled mutation is encapsulation: hide mutable state behind a controlled interface. External code cannot touch the state directly; it can only interact through the methods you expose. This converts a global hazard into a local invariant.

In Python, classes provide encapsulation. But so do closures — and understanding the closure-as-object correspondence is deeply clarifying:

  • Each closure invocation creates a fresh frame (the object's instance storage).
  • The closure body is the method.
  • Multiple closures sharing the same frame are multiple methods on the same object.

A dispatch closure explicitly demonstrates this: a single function takes a message (method name) as an argument and dispatches to the appropriate behavior. SICP's make-account is the canonical example. This shows that objects are not a primitive concept — they emerge from closures plus the environment model.

Encapsulation enforces the invariants of your data. A bank account can enforce that the balance never goes negative; a priority queue can maintain the heap property. Without encapsulation, any code anywhere can corrupt the invariant by directly mutating the underlying data.

# Closure-as-object: dispatch pattern
def make_account(initial_balance):
    balance = initial_balance         # private state

    def deposit(amount):
        nonlocal balance
        balance += amount
        return balance

    def withdraw(amount):
        nonlocal balance
        if amount > balance:
            raise ValueError('Insufficient funds')
        balance -= amount
        return balance

    def dispatch(msg):                # the 'vtable'
        if msg == 'deposit':   return deposit
        if msg == 'withdraw':  return withdraw
        if msg == 'balance':   return lambda: balance
        raise AttributeError(msg)

    return dispatch

acc = make_account(100)
acc('deposit')(50)     # 150
acc('withdraw')(30)    # 120
acc('balance')()       # 120

07Modules and Namespaces: Organizing Complexity at Scale

A namespace is a mapping from names to objects. Python, Java, C++, Haskell, and every mature language provide namespaces to prevent name collisions between independently developed code. Without namespaces, writing max = 5 in one file would silently shadow Python's built-in max, and integrating two libraries that both define a utils.parse function would be impossible.

A module is a namespace with an explicit interface (the public exports) and an implementation (the private internals). The interface is what clients can depend on; the implementation can change freely as long as the interface is preserved. This is the abstraction barrier.

Modules enforce:

  1. Information hiding: implementation details that are not exported cannot be depended upon by client code, so you are free to refactor them.
  2. Controlled coupling: dependencies flow only through the public interface, making the dependency graph of a large system legible and tractable.
  3. Independent development: different teams can develop different modules simultaneously without stepping on each other.

In Python, every .py file is a module. import executes the module's top-level code in a fresh namespace and returns the module object. from foo import bar copies the binding bar from foo's namespace into the current namespace. __all__ declares the public interface.

In C, static functions and variables at file scope are the module system: they are invisible outside the translation unit. Header files declare the public interface. In Haskell, module Foo (publicFn) where is the declaration.

The principle of least privilege applies to modules: export only what clients need. Every additional export is a commitment you must maintain forever.

# Python module interface pattern
# --- mystack.py ---

_data = []          # private: underscore convention

def push(item):      # public
    _data.append(item)

def pop():           # public
    if not _data:
        raise IndexError('pop from empty stack')
    return _data.pop()

def peek():         # public
    return _data[-1]

# Client cannot do: mystack._data.clear()  (convention)
# But they CAN do:  import mystack; mystack.push(1)

08The Global Environment and Global State Anti-Pattern

The global frame is the root of every environment chain. Names bound there are accessible from any code that can reach the global frame, which in practice means any code in the same module (and in Python, any code that does import). This accessibility is precisely what makes global state dangerous.

Global mutable state violates encapsulation by definition: anyone can read or write it. It creates hidden dependencies — a function that reads a global variable secretly depends on whoever last wrote it. These hidden dependencies make functions hard to test in isolation (tests must set up and tear down global state), make functions unsafe to call concurrently (two threads racing on the same global), and make programs hard to reason about (understanding a function requires understanding every possible sequence of mutations to every global it touches).

The Singleton pattern in object-oriented programming is often a thinly disguised global variable with extra syntax. The same criticisms apply.

Disciplined alternatives:

  • Dependency injection: pass the mutable resource (database connection, configuration, random-number generator) as an explicit parameter, so functions declare their dependencies rather than hiding them.
  • Functional core, imperative shell (Gary Bernhardt): keep all business logic as pure functions; push mutation to the very edges (I/O layer, main loop). The core is testable without any setup.
  • Module-level constants (not variables): global state that is truly immutable (configuration read at startup, compile-time constants) is fine.
# Anti-pattern: hidden global dependency
_db = None
def get_user(uid):
    return _db.query(uid)   # secretly depends on _db being set

# Better: explicit dependency injection
def get_user(db, uid):
    return db.query(uid)    # dependency is visible in signature

# Testing is now trivial:
class FakeDB:
    def query(self, uid): return {'id': uid, 'name': 'Test'}

result = get_user(FakeDB(), 42)  # no global state needed

09The Environment Model for Object-Oriented Languages

Object-oriented languages (Python, Java, C++) implement the environment model with one extra twist: self (or this). When you call obj.method(args), the interpreter looks up method in obj's class hierarchy (a separate lookup chain from the lexical environment chain), creates a new frame bound to the method's code, and implicitly binds self to obj in that frame.

Python's attribute lookup follows the MRO (Method Resolution Order) chain: first the instance's __dict__, then the class's __dict__, then the base class chain in C3 linearization order. This is separate from the lexical scope chain for local variables.

The interaction between self and closures is a frequent source of confusion. In Python:

class Broken:
    def __init__(self):
        self.callbacks = [lambda: i for i in range(3)]  # classic bug

All three lambdas close over the same i in the list comprehension's scope (in Python 3, list comprehensions have their own scope, so this actually works correctly in Python 3 but not Python 2). The general principle: closures capture bindings, not values at capture time.

Class as module: in languages without an explicit module system, classes are often used purely as namespaces (a collection of static methods). This is common in Java. It is an anti-pattern relative to using a proper module system, because it invites the creation of unnecessary instances and pollutes the class hierarchy.

class Counter:
    def __init__(self, start=0):
        self._count = start  # instance __dict__

    def increment(self):
        self._count += 1    # self is bound in this frame
        return self._count

# The environment chain for increment():
# Frame: {self: <Counter obj>, ...}
#   -> enclosing: Counter class scope (for class vars)
#     -> enclosing: module global scope

a = Counter(0)
b = Counter(10)
a.increment()  # a._count = 1, b._count still 10 (separate instances)

10Tail Calls, Stack Frames, and Iterative Processes in a Recursive Syntax

The environment model makes the cost of function calls concrete: each call creates a new frame that must persist until the call returns. A chain of n nested calls maintains n live frames — n stack frames, each consuming memory.

A tail call is a function call that is the last action of the calling function — no work remains after the callee returns. In this case, the caller's frame is no longer needed when the callee is entered, so it can be replaced rather than stacked. This is tail-call optimization (TCO) or tail-call elimination. With TCO, a tail-recursive function runs in O(1) stack space even for arbitrarily deep recursion.

Scheme mandates TCO (R5RS §3.5). This is why SICP can write loops as tail-recursive functions without fear of stack overflow. Python deliberately does NOT implement TCO (Guido van Rossum's conscious design choice, to keep stack traces readable). C compilers perform TCO as an optimization but are not required to.

The distinction between recursive process (non-tail: builds up a chain of deferred operations, requires O(n) space) and iterative process (tail: constant space, computable by a loop) is one of the deepest insights of SICP Chapter 1. A function can look recursive while generating an iterative process:

;; Recursive process — O(n) frames:
(define (fact-recursive n)
  (if (= n 0) 1
      (* n (fact-recursive (- n 1)))))  ; deferred multiply

;; Iterative process — O(1) frames (tail call):
(define (fact-iter n acc)
  (if (= n 0) acc
      (fact-iter (- n 1) (* n acc))))   ; tail position, no deferred work
;; Scheme: tail-recursive factorial (iterative process)
(define (factorial n)
  (define (iter n acc)
    (if (= n 0)
        acc
        (iter (- n 1) (* n acc))))  ; tail call: iter is last action
  (iter n 1))

(factorial 1000000)  ; runs in O(1) stack space in Scheme

11Abstraction Barriers and the Design of Interfaces

An abstraction barrier is a conceptual wall between how a data structure is used and how it is implemented. SICP formalizes this: a rational number is defined by the operations make-rat, numer, and denom — not by the fact that it is internally a pair. Any code that only uses these operations is insulated from a change in representation (e.g., switching from a pair to a reduced-fraction pair).

Abstraction barriers have four levels in SICP's hierarchy:

  1. Programs that use rational numbers (arithmetic operators).
  2. The rational number interface (make-rat, numer, denom).
  3. The representation of pairs (cons, car, cdr).
  4. The representation of integers (machine words).

Changing one level requires only modifying the code at that level, not the code above or below. This is the formal basis for information hiding and the open/closed principle: open for extension, closed for modification.

Violating the abstraction barrier — e.g., directly accessing account._balance instead of calling account.balance() — creates representation dependencies that make refactoring expensive. One of the most common sources of technical debt is code that reaches through abstraction barriers.

In Python, the convention is the single underscore _name (implementation detail, do not touch) and double underscore __name (name-mangled to prevent accidental collision in subclasses, not just convention). In Java/C++, private enforces the barrier at compile time.

# Abstraction barrier: rational number
def make_rat(n, d):
    g = gcd(abs(n), abs(d))
    return (n // g, d // g)   # implementation detail: tuple + reduction

def numer(r): return r[0]    # these three ARE the interface
def denom(r): return r[1]

def add_rat(r1, r2):
    # Uses ONLY the interface — never r1[0] directly
    return make_rat(
        numer(r1) * denom(r2) + numer(r2) * denom(r1),
        denom(r1) * denom(r2)
    )

# Now we can change make_rat to use a dict internally;
# add_rat does not need to change.

12Message Passing and the Actor Model

Message passing is an alternative to shared mutable state for managing concurrency and modularity. Instead of letting components reach into each other's state, each component is an actor: an autonomous entity with private state that communicates only by sending and receiving immutable messages.

The dispatch closure introduced in the encapsulation section is a micro-actor: it has private state (balance) and accepts messages ('deposit', 'withdraw'). Scaling this idea up gives the Actor model (Hewitt, 1973), which is the foundation of Erlang, Akka (Scala/Java), and to a lesser extent Python's asyncio.

In the Actor model:

  • Actors have private state — no sharing, no locks.
  • Actors communicate by sending messages to named addresses (mailboxes).
  • Each actor processes messages sequentially (no internal concurrency), so no locks are needed within an actor.
  • Actors can spawn new actors and supervise them (crash-restart trees).

The conceptual link to the environment model is direct: each actor is a closure (or object) that maintains its own environment, and message sends are function calls that execute in the receiver's environment. The key difference from ordinary closures is that actors in a concurrent system may process messages asynchronously, whereas closures are called synchronously.

Understanding message passing as a modularity technique — even in single-threaded Python — helps you design components that are loosely coupled, independently testable, and composable.

# Minimal actor-style in Python (synchronous, illustrative)
from collections import deque

class Actor:
    def __init__(self):
        self._mailbox = deque()
        self._state = {}

    def send(self, msg):
        self._mailbox.append(msg)

    def run_one(self):           # process one message
        if not self._mailbox: return
        msg = self._mailbox.popleft()
        self._handle(msg)        # private handler; no shared state

    def _handle(self, msg):
        pass  # subclasses override

Key insights

  • ✦The environment model replaces 'a name has a value' with 'a name is bound in a frame, and frames form a chain' — this one change correctly predicts the behavior of closures, mutation, and scope in every language.
  • ✦A closure is not syntactic sugar; it is a precise data structure: a pair of (function-code, pointer-to-defining-frame). This is why closures capture bindings (variables), not values, and why the classic loop bug exists.
  • ✦Lexical scope means you can understand what a function does by reading where it is written, not where it is called. This is the single most important property that makes large codebases navigable.
  • ✦Mutation is not free: it breaks referential transparency, destroys the substitution model, and makes concurrent execution unsafe. Use it when the entity being modeled has genuine time-varying state; avoid it otherwise.
  • ✦Closures and objects are the same thing. The environment model shows that an object is just a closure with multiple entry points (methods), and a closure is just a single-method object. Python classes, Java objects, and Scheme closures all implement the same underlying idea.
  • ✦Abstraction barriers are not just good style — they are the formal mechanism by which changing an implementation does not require changing its clients. Violating them is the definition of tight coupling.
  • ✦A function can have recursive *syntax* but generate an iterative *process* (constant stack space) if the recursive call is in tail position. This is the difference between O(n) and O(1) space for a loop.
  • ✦The root cause of most 'mysterious' language bugs (JavaScript `this`, Python `UnboundLocalError`, C use-after-free) is a mismatch between the programmer's mental model of environments and what the language actually does.

Common pitfalls

  • ⚠Confusing 'capture by binding' with 'capture by value': closures capture the *variable* (binding), not its value at the time the closure is created. Mutating the variable after closure creation changes what the closure sees. The Python `default argument` trick (`lambda x=x: x`) is the idiomatic workaround to force capture-by-value.
  • ⚠Forgetting `nonlocal` in Python: if you assign to a variable inside a nested function without `nonlocal`, Python creates a *new local variable* in the inner frame, shadowing the outer binding. Reading the variable before the assignment then raises `UnboundLocalError` — a notoriously confusing error.
  • ⚠JavaScript `var` vs `let` in loops: `var` is function-scoped (one binding shared by all iterations); `let` is block-scoped (one fresh binding per iteration). Using `var` in a loop with a closure captures the same binding for all callbacks.
  • ⚠Treating global state as harmless for 'small' programs: global mutable state is a local decision that has global consequences. It makes functions impure, tests fragile, and concurrent code unsafe. The habit is hard to break once formed.
  • ⚠Violating abstraction barriers: accessing `obj._private` or `obj.__dict__` directly in production code creates representation dependencies. The abstraction barrier exists precisely so the implementation can change; breaking it means your code must change too.
  • ⚠Confusing lexical scope chain with Python's attribute lookup chain: in Python, local variable lookup (the LEGB rule) and attribute lookup (`obj.x`) are two different mechanisms. Confusing them leads to errors like accessing `self.x` when `x` is a local variable, or vice versa.
  • ⚠Assuming recursion is always less efficient than iteration: a tail-recursive function in a language with TCO (Scheme, Haskell, Scala) uses O(1) stack space, identical to a loop. The inefficiency is in non-tail recursion, not recursion itself.
  • ⚠Over-encapsulating: not every piece of state needs a class or closure. Premature encapsulation creates unnecessary indirection. Apply the rule: add encapsulation when you have an invariant to protect or an implementation you need the freedom to change.

Practice

  1. 01
    easy

    Implement `make_adder(n)` in Python and trace the complete environment model by hand: draw each frame created, its bindings, and its enclosing pointer, for the call sequence `add7 = make_adder(7); add7(3)`. Verify your trace matches Python's actual output.

  2. 02
    easy

    Fix the following broken counter WITHOUT using a class, and WITHOUT using global state. Then explain why the original is broken in terms of the environment model: ```python def make_counter(): count = 0 def inc(): count += 1 return count return inc c = make_counter() c() # UnboundLocalError ```

  3. 03
    medium

    Write a `make_account(balance)` closure-based bank account (no class) that supports `deposit`, `withdraw`, and `get_balance` operations. Enforce the invariant that the balance can never go below zero. Then show how the same interface could be implemented with a class, and explain the correspondence between the two in terms of frames.

  4. 04
    medium

    Implement a purely functional (no mutation) version of a stack in Python using tuples: `empty_stack`, `push(stack, item)`, `pop(stack)` (returns `(item, new_stack)`), `peek(stack)`. Then implement the same stack using a closure with mutable state. Write tests that prove both implementations satisfy the stack contract. Discuss: which is easier to test? Which is easier to use in concurrent code?

  5. 05
    medium

    The following Python code has a subtle bug related to closures and loops. Identify the bug, explain it using the environment model, and provide two distinct fixes: ```python functions = [] for i in range(5): functions.append(lambda: i * i) print([f() for f in functions]) # expected: [0, 1, 4, 9, 16] ```

  6. 06
    hard

    Implement a simple **module system** in Python: write a function `make_module(definitions_dict)` that returns an opaque object (use a closure or a class with `__getattr__`) where only the names in a provided `exports` list are accessible. Attempting to access a non-exported name should raise `AttributeError`. Test it with a module that has two private helper functions and one public function.

  7. 07
    hard

    Write a tail-recursive `flatten(lst)` in Python that flattens an arbitrarily nested list `[[1,[2,3]],[4]]` -> `[1,2,3,4]`. Discuss whether Python's lack of TCO affects the correctness or the performance of your implementation for very deeply nested lists. Rewrite it iteratively to achieve true O(1) stack space.

Resources

📕Structure and Interpretation of Computer Programs (SICP), 2nd ed.
bookfree

Chapters 3 (Modularity, Objects, and State) and 1.2 (Procedures and the Processes They Generate) are directly relevant. The canonical source for the environment model (Chapter 3.2) and closures.

🎓MIT 6.001 / 6.0001 OpenCourseWare — Structure and Interpretation of Computer Programs
coursefree

Original MIT lecture videos by Abelson and Sussman. Lectures 7-8 cover the environment model directly.

🎓Composing Programs (CS 61A Berkeley adaptation of SICP in Python)
coursefree

Section 1.6 (Higher-Order Functions), 2.4 (Mutable Data), and 2.6 (Implementing Classes and Objects) translate SICP's environment model into Python. Excellent complement to SICP.

📄Python Execution Model — official reference documentation
docsfree

The authoritative specification of Python's scoping rules (LEGB), binding operations, and the `global`/`nonlocal` declarations.

📕You Don't Know JS: Scope and Closures (Kyle Simpson)
bookfree

The most rigorous freely available treatment of JavaScript's environment model, including the nuances of `var`/`let`/`const`, hoisting, and the classic loop closure bug.

📜On the Criteria To Be Used in Decomposing Systems into Modules (D. L. Parnas, 1972)
paperfree

The foundational paper on information hiding and modular decomposition. Only 8 pages; essential reading. Argues for decomposing by 'design decisions likely to change', not by steps of execution.

📕Essentials of Programming Languages (EOPL), 3rd ed.
bookpaidFriedman, Wand — MIT Press

Chapters 3-4 give the most rigorous formal treatment of environments, closures, and the interpreter as a mathematical function. Harder than SICP but more precise.

✎Lisp as the Maxwell's Equations of Software (Michael Nielsen, 2012)
articlefree

A motivating essay explaining why the eval/apply cycle and the environment model are the irreducible core of computation — good for building intuition before diving into SICP Chapter 4.

Checkpoints — you can now…

  • ✓You can draw the complete environment diagram (frames, bindings, enclosing pointers) for any given Python or Scheme code snippet, including nested closures and mutation, and correctly predict the output.
  • ✓You can explain, using the environment model, why Python raises `UnboundLocalError` when a variable is assigned inside a function without `nonlocal`, and why the fix works.
  • ✓You can implement a stateful abstraction (counter, bank account, stack) using both a closure-based dispatch pattern and a class, and articulate the precise correspondence between the two representations.
  • ✓You can identify whether a recursive function generates a recursive process (O(n) stack frames) or an iterative process (O(1) stack frames), and rewrite any tail-recursive function in iterative style.
  • ✓You can describe the difference between lexical and dynamic scope, give an example where they diverge, and explain why lexical scope enables local reasoning about program behavior.
  • ✓You can identify at least three concrete costs of mutable state (broken referential transparency, non-confluence under concurrency, hidden dependencies) and give a design strategy to mitigate each.
  • ✓You can design a module with a clear abstraction barrier — deciding which functions and data are public vs. private — and explain how this choice enables the implementation to change without affecting clients.