Schooner.Env (schooner v1.2.0)

Copy Markdown View Source

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 when it analyses the program, before it runs, 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

frame()

@type frame() ::
  %{optional(binary()) => Schooner.Value.t()} | tuple() | {:rec, reference()}

lookup_result()

@type lookup_result() ::
  {:ok, Schooner.Value.t()} | :error | {:uninitialised, binary()}

t()

@type t() :: %Schooner.Env{globals: reference(), lex: [frame()]}

Functions

define(env, name, value)

@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.

extend(env, bindings)

@spec extend(t(), [{binary(), Schooner.Value.t()}]) :: t()

Push a new lexical frame on top of env.

extend_rec(env, names)

@spec extend_rec(t(), [binary()]) :: t()

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.

lookup(env, name)

@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.

new()

@spec new() :: t()

pop(env)

@spec pop(t()) :: t()

Pop the topmost lexical frame on env.

rec_set(env, name, value)

@spec rec_set(t(), binary(), Schooner.Value.t()) :: t()

Set a binding in the topmost recursive frame on env.

release_rec(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.