Environment for the Scheme evaluator: immutable lexical frames over a mutable globals table.
An environment is a chain of lexical frames innermost-first plus a
globals slot identified by a process-dictionary key (a fresh
reference). Lexical frames are immutable; new frames are pushed by
extend/2 (or, inside the evaluator, push_frame/2). The globals
slot is mutable in the strict sense — define/3 writes to it — so
closures that captured an env see top-level definitions added after
they were created. This is the standard letrec-style knot-tying
applied to the top-level frame: closures reference the frame by
identity, not by value-at-bind-time.
Globals live in the process dictionary, not in :ets, so
consecutive lookups of the same name return the same heap term.
This preserves erts_debug.same/2 identity for aggregates bound
at top level — (eq? x x) answers #t for vectors, pairs,
parameters, and other aggregates, matching the lexical case.
Each global has its own cell: a process-dictionary entry keyed by a fresh reference, holding the value. The globals slot maps names to cells. The evaluator resolves a global reference to its cell once, when it compiles the reference, so reading a global at run time is a single process-dictionary get rather than a lookup by name. A cell is created the first time a name is defined or compiled as a reference, and holds an "unbound" sentinel until it is defined.
The globals slot is owned by the process that calls new/0 and is
reclaimed when that process exits, so per-execution sandboxing
falls out of the BEAM's process model.
Frame shapes
The evaluator resolves every lexical variable reference to a
{depth, slot} address at analysis time (see
Schooner.Eval.Analyze), so the frames it pushes are positional:
a tuple whose element 0 is a tuple of the frame's names and whose
remaining elements are the values in the same order. The names are
carried so lookup/2 can still resolve by name; the evaluator
itself only indexes.
extend/2 pushes a name → value map frame instead. Both shapes, and
the recursive frames below, are understood by lookup/2.
Recursive lexical frames
letrec, letrec*, named let, and the letrec* produced by
internal-define splicing each push a recursive frame, unless the
evaluator has compiled the form to direct calls or proved that its
targets are distinct and every initializer reference to the same
frame targets an earlier binding, including references inside nested
procedures. Those forms use immutable positional frames: init
closures capture the earlier values, and body closures capture the
fully initialized frame. These snapshots live as long as the closures
that reference them and need no process-dictionary slot or cleanup.
Duplicate targets and initializer references to their own or later
bindings still need recursive frames unless compiled to direct calls.
A recursive frame is a process-dictionary slot keyed by make_ref/0,
holding the frame's names and a tuple of values, so closures captured
during init evaluation see later bindings via frame identity.
Each such frame is released by release_rec/1 when the body
finishes — unless the body's return value carries a closure whose
env still names the frame. In that case the evaluator detects the
escape and leaves the slot alive holding the now-finalised
snapshot, so the escaped closure (and any closures it reaches via
rec lookups, including mutually-recursive bindings) can resolve
rec names through the slot for the rest of the process lifetime.
Slot leakage is therefore bounded to actual closure escapes — a
letrec form whose body returns a non-closure value (the dominant
case in typical Scheme code, including all named-let recursion
whose body is (tag val ...)) releases its slot as usual.
Summary
Functions
Add or replace a top-level binding. Visible to every closure that captured this env, regardless of when it was created.
Push a new lexical frame on top of env.
Push a fresh letrec-style frame whose bindings start uninitialised
and can be assigned later via rec_set/3. Closures created against
the returned env see the bindings by frame identity, so forward
references resolve once the frame has been populated. Backed by a
process-dictionary slot keyed by a fresh reference; the slot must
be released with release_rec/1 once the binding form's body has
finished evaluating.
Look up name. Walks lexical frames innermost-first, then globals.
Pop the topmost lexical frame on env.
Set a binding in the topmost recursive frame on env.
Release the topmost recursive frame on env, deleting the
process-dictionary slot that backs it. Must be paired with
extend_rec/2. Idempotent — releasing a frame whose slot was
already deleted is a no-op.
Types
@type frame() :: %{optional(binary()) => Schooner.Value.t()} | tuple() | {:rec, reference()}
@type lookup_result() :: {:ok, Schooner.Value.t()} | :error | {:uninitialised, binary()}
Functions
@spec define(t(), binary(), Schooner.Value.t()) :: t()
Add or replace a top-level binding. Visible to every closure that captured this env, regardless of when it was created.
@spec extend(t(), [{binary(), Schooner.Value.t()}]) :: t()
Push a new lexical frame on top of env.
Push a fresh letrec-style frame whose bindings start uninitialised
and can be assigned later via rec_set/3. Closures created against
the returned env see the bindings by frame identity, so forward
references resolve once the frame has been populated. Backed by a
process-dictionary slot keyed by a fresh reference; the slot must
be released with release_rec/1 once the binding form's body has
finished evaluating.
Duplicate names share one slot.
@spec lookup(t(), binary()) :: lookup_result()
Look up name. Walks lexical frames innermost-first, then globals.
Returns {:uninitialised, name} when name is bound by an enclosing
recursive frame whose init expression has not yet been evaluated;
callers should treat this as a forward-reference runtime error.
@spec new() :: t()
Pop the topmost lexical frame on env.
@spec rec_set(t(), binary(), Schooner.Value.t()) :: t()
Set a binding in the topmost recursive frame on env.
@spec release_rec(t()) :: :ok
Release the topmost recursive frame on env, deleting the
process-dictionary slot that backs it. Must be paired with
extend_rec/2. Idempotent — releasing a frame whose slot was
already deleted is a no-op.