Lexical scope in a Rust Lisp: environments, closures, and a two-path drop

A deep dive into risp's scope model — an Rc<RefCell<Environment>> chain, iterative lookup and set!, closures that capture frames by reference, and a fast/slow Drop that frees a million-deep scope chain without recursing or allocating on the hot path.

Source: github.com/kissishka/risp

Every variable reference in a language has to resolve against something, and that something is the environment. In risp the representation is deliberately plain — a hash map per scope plus a parent pointer — but the two decisions around it are not: how a closure captures scope, and how a deep scope chain gets freed without blowing the stack. This post is about the backbone.

A frame is a map plus a parent

A scope is one Environment: its own bindings, and an optional link to the frame that encloses it. The handle is reference-counted and interior-mutable, because closures alias frames and set! mutates them:

/// A shared, mutable handle to an Environment frame.
pub type Env = Rc<RefCell<Environment>>;

pub struct Environment {
    vars: HashMap<Rc<str>, Value>,
    parent: Option<Env>,
}

The global frame has no parent; every other frame is a child of the scope it was created in:

pub fn new_global() -> Env {
    Rc::new(RefCell::new(Environment { vars: HashMap::new(), parent: None }))
}
pub fn child(parent: &Env) -> Env {
    Rc::new(RefCell::new(Environment { vars: HashMap::new(), parent: Some(parent.clone()) }))
}

That parent.clone() is an Rc bump, not a copy of the parent’s bindings — a new frame costs one allocation and one reference count, regardless of how much scope sits above it. This is the data structure the whole evaluator threads through: def and parameter binding write into the local frame, a call creates a child, and lookup reads up the chain.

Lookup walks the chain, iteratively

Reading a variable means searching the current frame, then its parent, then its parent, up to the global. Like everything else that walks user-controlled depth, that walk is a loop, never recursion — a scope chain thousands deep costs heap iterations, not stack frames:

pub fn lookup(env: &Env, name: &str) -> Option<Value> {
    let mut cursor = env.clone();
    loop {
        let next = {
            let frame = cursor.borrow();
            if let Some(v) = frame.vars.get(name) { return Some(v.clone()); }
            frame.parent.clone()
        };
        match next {
            Some(parent) => cursor = parent,
            None => return None,
        }
    }
}

Defining and assigning split along the obvious line. define always writes the local frame — that’s what makes a let or a parameter shadow an outer binding. set! instead walks the chain for the nearest existing binding and mutates it in place, erroring with UnboundSymbol if there isn’t one. The shadowing falls straight out of “define is local, lookup is nearest-first”: a child can bind x to a new value while its parent’s x is untouched, and a set! from the child reaches up and rebinds the parent’s.

Closures capture the frame, not a copy

This is what the Rc is for. A closure is its parameters and body plus the environment it was defined in:

pub struct Closure {
    pub name: Option<Rc<str>>,
    pub params: Vec<Rc<str>>,
    pub rest: Option<Rc<str>>,     // variadic tail
    pub body: Rc<Vec<Value>>,
    pub env: Env,                  // the captured defining scope
}

Capturing is just env: env.clone() — one reference-count bump. The closure now co-owns its defining frame and keeps it alive for as long as the closure lives, which is exactly lexical capture: a returned lambda that closed over a local can still read and mutate it, because both point at the same RefCell. Applying the closure doesn’t reach back to where it was called; it makes a child of where it was defined:

Value::Closure(c) => {
    let frame = child(&c.env);     // extend the DEFINING scope, not the call site
    bind_params(&c, &args, &frame)?;
    // ... evaluate the body in `frame`
}

That one line — child(&c.env) and not child(&caller_env) — is the whole difference between lexical and dynamic scope. The captured env is the answer to “what did the free variables in this lambda mean?”, fixed at definition time.

The hazard: freeing a scope chain

Sharing frames by Rc creates the same trap that long cons lists do. A closure captures a frame; that frame’s parent is another frame; freeing the last owner of the outermost closure can trigger a chain of Drops that recurses one Rust frame per scope level. A thousand nested lets captured by a closure would, on the naïve derived Drop, recurse a thousand frames deep on teardown.

But making every environment drop go through an iterative teardown would tax the common case — a function call that binds two integer parameters and returns — with an allocation it never needs. So the Drop has two paths.

A two-path drop

The fast path is a pure check: if this frame is not its parent’s last owner (someone else still holds the parent, so dropping won’t cascade up the chain) and none of its bound values can hold a deep Rc graph, then ordinary field drop recurses nowhere. Let it happen, allocate nothing:

impl Drop for Environment {
    fn drop(&mut self) {
        let parent_unique = self.parent.as_ref()
            .is_some_and(|p| Rc::strong_count(p) == 1);
        let has_deep_value = self.vars.values()
            .any(|v| matches!(v, Value::Pair(_) | Value::Closure(_) | Value::Macro(_)));
        if !parent_unique && !has_deep_value {
            return;   // the hot path: a call frame of atoms, sharing its parent
        }

        // Slow path: we own a (possibly deep) parent chain or a value graph.
        let (parent, vals) = self.take_children();
        let mut seed: Vec<Teardown> = Vec::with_capacity(vals.len() + 1);
        if let Some(p) = parent { seed.push(Teardown::Env(p)); }
        seed.extend(vals.into_iter().map(Teardown::Val));
        dismantle(seed);
    }
}

The slow path moves the frame’s onward links — its parent and its bound values — onto the heap work-stack that the shared dismantle loop drains. take_children empties the frame first, so by the time its fields drop they’re already None/empty and recurse nowhere. The Teardown enum is what lets one loop free both hazards at once: a long Value chain and a deep Env chain go on the same stack, so freeing a closure that captured a thousand let frames is as flat as freeing a thousand-element list.

The guard conditions carry the logic. parent_unique asks “am I about to trigger a cascade?” — if the parent has other owners, dropping me stops here and the chain is someone else’s problem later. has_deep_value asks “could my own bindings recurse?” — atoms can’t, but a captured closure or a list can. Only when one of those is true do we pay for the iterative path. The overwhelmingly common frame — a call that binds a couple of numbers and shares the global as its parent — takes the early return and costs nothing.

The cost the VM erases

This model has one real price: every variable read is a string hash and a HashMap probe, repeated up the chain. For a tree-walker that’s a fine trade — simple, correct, and stack-safe. But it’s also the largest gap between a naïve interpreter and a fast one, which is why risp’s bytecode VM resolves every variable to a fixed slot index at compile time and reads it as a single stack[base + i] array access — no map, no hashing, no chain walk. The environment chain described here is what the compiler exists to flatten. The tree-walker keeps it because the chain is the simplest thing that’s obviously correct, and “obviously correct” is the reference the faster engines are differentially tested against.

The representation is the plainest thing that works — a map and a pointer — and the cleverness is confined to two spots: a one-line child(&c.env) that fixes lexical capture, and a two-path drop that keeps the common case free while still freeing a pathological chain without recursion. Get the backbone simple and stack-safe, and the optimizing engines have something trustworthy to flatten.

Frequently asked questions

How does a tree-walking interpreter represent variable scope?
risp represents each lexical scope as a frame: a HashMap of bindings plus an optional parent pointer. An environment is an Rc<RefCell<Environment>>, so frames are shared and mutable. Lookup walks from the local frame up the parent chain to the global frame.
How does a closure capture its environment in Rust?
A risp closure stores env: Env, an Rc clone of the frame it was defined in. Cloning an Rc is a reference-count bump, not a copy, so the closure shares and keeps alive its defining scope. Captured variables stay live and mutable through that shared frame.
How do you free a deep scope chain without overflowing the stack?
risp's Environment Drop has two paths. The fast path, for a call frame that binds only atoms and shares its parent, lets ordinary field drop happen with no allocation. The slow path, when a frame owns a deep parent chain or a value graph, hands the links to an iterative teardown loop.

← All posts