What the two function types changed in the documents

The four refusals the function-value lane wrote down are now three and a
half: capture is a feature, the escape is the refusal in its place, and a
struct field or a global of function type is refused for the same zero as
before with a sharper reason behind it.  The handler note is amended
rather than deleted -- its reading was right, and saying so is worth more
than a clean paragraph.

The convention section carries both rulings, the name and every name it
beat, the warning that CFn is not C interop today, the four reasons to
reach for it, what was measured rather than asserted about an ordinary
defn, and the wasm32 finding that killed the first design -- being
exactly typed is checkable by a verifier and the alternative was an
argument from a calling convention.

FIX.org carries the rulings verbatim, what the escaping lane inherits and
changes, the thunk's environment holding a code pointer rather than a GC
object, and two things recorded and not built: a CFn under a future
--no-conditions reaching C's exact convention, and CFn in a struct or a
fixed array, which ZII refuses and capture has nothing to do with.
This commit is contained in:
Joseph Ferano 2026-09-21 12:54:13 +07:00
parent 2572f0a537
commit 4cb53a0a9f
3 changed files with 390 additions and 23 deletions

89
FIX.org
View File

@ -6079,3 +6079,92 @@ table names. Harmless today only because check.ml refuses a dyn field in a
struct — the stopgap item 2 of the M2 queue lifts. Whoever lifts it has to
root this buffer, or a collection that runs inside the construction will not
see what has been built so far.
* Closures, 2026-09-21 — two rulings, and what this lane built
Two rulings, both in the author's words.
The first, on what to build:
"do both, capture by value and handle escaping closures, allocated on the
GC side"
This lane is the first half: capture by value into a stack environment,
spec-memory.md's case 2, non-escaping only. The second half — an environment
the collector allocates, and with it the escaping closure — is a separate lane,
and every refusal this one prints names it.
The second, on the calling convention, after the first design put an
environment parameter on every Flan signature:
"while it's dyn first, static side should never have to pay the price for
the existence of the dyn side... if you fully opt out, for instance, using
--no-gc flag, then we should be operating under Odin/C semantics and never
paying any runtime costs"
So there are two function types, Rust's and Swift's shape:
(Fn [T ...] R) captures; {code, env}; the common case, short name
(CFn [T ...] R) the bare address; one word; cannot capture
** The name, ruled 2026-09-21
CFn, because the C carries information rather than being decoration: a value
with no environment is the only kind that could ever cross to C, and under the
--no-conditions direction below it becomes literally a C function pointer. The
name points at what the type is and at where it is going.
Rejected: Closure, too long. Proc, because "procedure" is a word we disagree
with Odin about. Fun and Func, because beside Fn they differ only in length,
so nothing tells a reader which one captures. Fnptr, as ugly.
The thing to be careful about, and it is written into the crossable refusal so
a reader meets it where they would otherwise be misled: CFn is not the type
for C interop *today*. A declare cannot take a function type at all, because a
Flan signature ends with the transfer channel.
** Nobody needs CFn, and one of the four reasons is the common one
An Fn accepts everything a CFn does, so the narrow one is always reached for
on purpose:
1. handing a function to C — later, per the item below;
2. a table of bare addresses;
3. forbidding capture at a boundary, where the type is the statement;
4. performance, which is likeliest in practice. A *named* function passed to
an Fn parameter goes through the widening thunk and pays an indirect hop
per call; a CFn parameter is a direct call. (map-in-place s double) is the
example. The prelude's four stay Fn on purpose — a capturing predicate is
what people want — so this is the cost someone would opt out of by writing
their own signature, not one the prelude should have avoided.
** What the escaping lane inherits, and what it changes
Unchanged by it: a (Fn ...) is two words; the environment is the last
parameter; it is declared by exactly the bodies an Fn value can reach — a
lifted literal in an Fn position, every handler clause, and the widening
thunks. An ordinary defn declares none and is byte-for-byte what it was.
Changed by it: where the environment points. It is a Make into a frame slot
today and becomes a collector allocation; Check.escape_check goes away with it,
and with it the refusals on returning, storing, pointing at and pushing a
capturing value.
One thing for the collector to know now rather than discover: a widening
thunk's environment holds a *code pointer*, not a frame address and not a GC
object. When env becomes collector-allocated the marker needs a way not to
follow a thunk's.
Also still refused and belonging to the second half: capturing a dyn. The
struct dyn field is no longer refused — per-type descriptors landed — but a
synthesised environment has no descriptor, so the refusal stands until it does.
** Recorded, not built: CFn and C's convention
A CFn is one word and is the right shape for a C callback, and it is still
not one: a Flan function's signature ends with the transfer channel and a C
caller knows nothing about one. Under a future --no-conditions flag a CFn
signature could drop the channel and reach C's exact convention, which is the
direction the author is interested in. The refusal in [crossable] names it.
** Also worth an item: CFn in a struct or a fixed array
no_zeroed_fn refuses a function value in any position ZII would conjure one,
and it refuses a CFn for the same reason it refuses an Fn: a zeroed function
value is a null pointer, which is the one zero that is not a value the type can
have. But "a table of function pointers" is exactly what CFn is for, and that
objection is about ZII rather than about capture — an (Option (CFn ...)) field
is already legal and is the shape that works. Its own item.

View File

@ -1861,15 +1861,17 @@ stack does *not* observe a redefinition of its own clause; the next entry to the
Which gives the two refusals, both by the house rule rather than by accident:
- **A handler cannot see the establishing function's locals.** That is a closure with an explicit environment, so a
reference to one is refused *for that reason* rather than reported as an unknown name. Globals and the condition are in
scope, which is what the accumulation case needs.
- **A handler cannot see the establishing function's locals.** *Superseded: it can, by value — see "Capture by value"
below. What is still refused is a `set` into one, because a captured name is a copy.* The original note read: that is a
closure with an explicit environment, so a reference to one is refused *for that reason* rather than reported as an
unknown name; globals and the condition are in scope, which is what the accumulation case needs.
What it needs is narrower than it looks, and worth getting right before anyone schedules it: a handler frame does not
outlive the function that established it, so this is spec-memory.md's **case 2** — a non-escaping `fn` capturing by
value into a stack environment — and *not* the escaping closure that plan.org's open decision #5 defers until a concrete
use case. Case 2 is settled, and #5 says in as many words that without it "conditions are not worth building". So the
biggest usability limit in conditions is not behind the thing that was just deferred.
The reading that turned out to be right: a handler frame does not outlive the function that established it, so this is
spec-memory.md's **case 2** — a non-escaping `fn` capturing by value into a stack environment — and *not* the escaping
closure that plan.org's open decision #5 defers until a concrete use case. Case 2 was settled, was built, and brought
the handler along with it for one struct field and one argument. #5 says in as many words that without it "conditions
are not worth building", and the biggest usability limit in conditions was indeed never behind the thing that was
deferred.
- **`return` inside a `handler-bind` body is refused.** The frames are popped on the way out and an early exit would
leave them on the stack pointing into a function that has gone. Same shape as `defer` inside a block.
@ -2702,9 +2704,11 @@ spec together; neither is a cleanup, and until one is taken the word is carried
`(Map K V)` is built — see below. What is left: `drop` and with it the transitive move-only rule, recursive teardown,
and the refusal to construct a drop-carrying container against an allocator without `can-free`; `(Result T E)` and
`try`; generics; the macro expander.
And the **accumulation pattern** — `(fn [c] (push errors c) ...)` over an enclosing Vec — which `Vec` does not buy:
capture does not exist at all, and the spec's captured-`Vec`-by-pointer rule has never had to exist because every
capturable type today is a value type. It is its own item and should be planned as one.
And the **accumulation pattern** — `(fn [c] (push errors c) ...)` over an enclosing Vec — which `Vec` does not buy.
Capture exists now (see "Capture by value" below) and this is still not it: capture is by *value*, so the `fn` would
push into its own copy of the header and leave the enclosing one at the length it had. The spec's
captured-`Vec`-by-pointer rule is exactly the thing that has still never had to exist. It is its own item and should
be planned as one.
## `(Map K V)`, which is Odin's map
@ -3817,9 +3821,10 @@ cannot share a name** — which is exactly what makes the bare name safe to read
`double` it could have meant instead, so the sharp quote would be punctuation answering a question the language does
not ask.
A `Types.Fn` is one pointer. There is no environment beside it, so the type resolves to `ptr` and lays out as eight
bytes, and a call through one is byte-for-byte the call a name would have produced — a Flan function's emitted
signature is its parameters followed by the transfer channel whether it was reached by name or by pointer. That is
A `Types.Fn` was one pointer when this lane landed. It is two words since capture, and the one-word version has a
name of its own now — `(CFn [T ...] R)`; see "Capture by value" below. A call through either is the call a name
would have produced, with the environment appended for a `Fn`: a Flan function's emitted signature is its
parameters, then the transfer channel, and then the environment on the bodies that can be reached that way. That is
why a handler established across a `fold` still catches a signal raised by the function the fold was handed:
`programs/fn-values.flan` does exactly that, and it is the case that would fail if an indirect call skipped the
guard.
@ -3840,11 +3845,8 @@ implemented.
**Refused, each with its own reason and its own program:**
- **Capture does not exist** (`fn-capture.flan`). An `fn` is lifted into a function of its own and handed nothing but
its parameters; a reference to a local of the enclosing function is refused by name. This is the same refusal a
handler clause has always carried, and the two now share one message with the construct's name in it.
`spec-memory.md`'s capture cases, and **escaping closures with them, stay deferred** — deliberately, and this is
what keeps a function value a bare code address that cannot outlive anything.
- **Capture does not exist.** *Superseded — see "Capture by value" below. It exists, the program that was this
refusal's witness now runs, and what is refused in its place is the **escape**.*
- **An `fn` with nothing to say what it takes** (`fn-no-type.flan`), above.
- **A position that would zero one** (`fn-in-struct.flan`): a struct field, a global, a fixed array's element,
`(zeroed)`. ZII fills an omitted field with all-bytes-zero, and **a zeroed function value is a null pointer, which
@ -3855,9 +3857,276 @@ implemented.
past its length and `flan_map_alloc` zeroes only the hash run, so neither conjures an element nobody pushed or
put. A function value as a map *key* is refused already, by `Types.keyable` — hashing an address is a different
operation from hashing what it points at.
- **A foreign function's address** (`fn-extern.flan`). A Flan function's signature ends with the transfer channel and
a C one does not, and an aggregate crossing the boundary is flattened by a generated shim the raw symbol knows
nothing about. Wrap it in a `defn` and pass that.
- **A foreign function's address** (`fn-extern.flan`). A Flan function's signature ends with the environment and the
transfer channel and a C one does not, and an aggregate crossing the boundary is flattened by a generated shim the
raw symbol knows nothing about. Wrap it in a `defn` and pass that. Capture widened this gap rather than closing
it: a Flan function value is now two words and a C symbol is one.
## Capture by value, and what "non-escaping" had to mean
`spec-memory.md`'s **case 2**, which the section above listed as the headline refusal and which is now the headline
feature. This compiles:
```
(let [bonus 10]
(apply2 (fn [x] (+ x bonus)) 5))
```
`bonus` is **copied** into an environment on the enclosing function's frame at the instant the `fn` value is made,
and the lifted body reads the copy. Not a reference: `fn-capture.flan` changes the local through a pointer *after*
the value exists and *before* it is called, and the `fn` still answers with the old one. That test is the whole
claim, and it is the one no evaluation order can fake.
### Two function types, because the static side does not pay for the dynamic side
```
(Fn [i32] i32) ; captures; {code, env}; the common case
(CFn [i32] i32) ; the bare code address; one word; cannot capture
```
The forcing constraint first. A callee that takes a `(Fn [i32] i32)` and calls it knows nothing about where the
value came from — `fold` is handed a value and calls it — so **the environment has to travel with the value** or
there is nowhere to put it. A `Fn` is therefore `{code, env}`: sixteen bytes, classified as an aggregate in both
backends exactly as a slice is.
The first design put an environment parameter on **every** Flan signature, uniform for the reason the transfer
channel is uniform. The author ruled against it, and the ruling is the principle rather than the case: *"while it's
dyn first, static side should never have to pay the price for the existence of the dyn side… if you fully opt out,
for instance, using `--no-gc`, then we should be operating under Odin/C semantics and never paying any runtime
costs."* A uniform environment taxes every function in every program for a feature most of them never use.
So there are two types, Rust's and Swift's shape. `Fn` keeps the short name because it is what almost every
higher-order signature wants; `CFn` is the narrow one.
**The `C` is information and not decoration**, which is what settled the name. A value with no environment is the
only kind that could ever cross to C, and under the `--no-conditions` direction FIX.org records — where a signature
that cannot transfer drops the transfer channel too — one becomes literally a C function pointer. The name points at
what the type *is* and at where it is going. `Closure` was rejected as too long; `Proc` because "procedure" is a
word this language disagrees with Odin about; `Fun` and `Func` because beside `Fn` they differ only in length, so
nothing tells a reader which one captures; `Fnptr` as ugly.
**It is not a capability today, and the diagnostic says so.** A `declare` cannot take a function type at all — a
Flan signature ends with the transfer channel and a C caller knows nothing about one — so anyone reaching for `CFn`
straight after writing a `declare-c` is reaching too early. `crossable`'s refusal names that in as many words: *"the
C in CFn is about having no environment, which is what a C function pointer would need, and not about crossing
today."*
**And nobody ever needs it.** `Fn` accepts everything a `CFn` does, so the narrow one is reached for on purpose, for
one of four reasons:
1. handing a function to C — later, as above;
2. a table of bare addresses;
3. forbidding capture at a boundary, where the type is the statement;
4. **performance, which is likeliest in practice.** A *named* function passed to an `Fn` parameter goes through the
widening thunk and pays an indirect hop per call; a `CFn` parameter is a direct call. `(map-in-place s double)`
is the example — and the prelude's four stay `Fn`, because a capturing predicate is exactly what people want.
**An ordinary `defn` keeps its exact signature.** Verified rather than asserted: the LLVM for `calc-me.flan` and
fourteen corpus programs was diffed against the same compiler without this lane. Exactly three kinds of difference
appear, and no fourth:
- two type declarations in the preamble — `%fnv`, and `%handler`'s new `env` field;
- the prelude's `sort-by-slice-u8`, whose *parameter* is now `%fnv` because it is declared `(Fn [$t $t] bool)` and
pays two words for the value it asked for; and the lifted literal it is handed, which gains a trailing `ptr %env`
because an `Fn` value can reach it;
- in a program with a `handler-bind`, its clauses gain the same trailing `ptr %env` — every clause declares one,
see below.
**No ordinary `defn` gained a parameter, in any program.** `flan_rt.c`'s hash and equality typedefs are untouched;
`main`, the macro thunk, the startup call and the reload thunk emit the calls they always emitted.
### The environment is the last argument, on exactly the bodies an `Fn` can reach
The environment is the last parameter, after the transfer channel, and it is declared by **exactly the bodies a
`(Fn ...)` value can reach**: a lifted `fn` literal written into an `Fn` position, capturing or not; every handler
clause, because `flan_signal` passes one to whichever clause matched and cannot know which of them captured; and the
widening thunks below, which exist to read it. Nothing else declares it, which is where an ordinary `defn` keeps
costing nothing.
So **every indirect call is exactly typed** and nowhere does a caller pass an argument the callee did not declare.
That was not the first attempt. The first put the environment last and let a body that never asked for one simply
ignore the register it arrived in — legal under SysV, where argument N is classified from arguments 1..N alone, and
exactly **Swift's thin-vs-thick convention**, where a thin function converts to a thick one by pairing with a null
context the thin body ignores. It works on x86-64 and it is dead on **wasm32**, where `call_indirect` compares the
signature at the call site: a spare argument is a trap, not a register nobody reads. `test_web`'s two cases and the
headless `sand` build failed with *"null function or function signature mismatch"*, and the honest reading is that
being exactly typed is checkable by a verifier rather than argued from a calling convention, which is the better
property to have wanted.
**So there is one adapter, and it is per *signature* rather than per function.** `CFn` → `Fn` is `Tast.Thicken`,
and the pair it builds is `{thunk, the address}`: the thunk's code, with the bare address stored where an
environment would be. The thunk — `thick/<mangled signature>`, minted and memoised by the checker the way
`struct_key_pair` mints a map's hash and equality pair — declares the environment, reads the address back out of it,
and calls through it. One small function per distinct shape a program widens, not per function it widens, and it
handles the dynamic case (a `CFn`-typed local or parameter widened at a call) with the same mechanism as the
static one.
**What it costs, plainly.** A *name* handed to an `Fn`-typed parameter now pays one indirect hop per call:
`(map-in-place s double)` goes through the thunk per element where it used to reach `double` directly. A literal
pays nothing — capturing or not, it is compiled to take an environment and needs no thunk. The escape hatch is
writing `CFn` in the signature, which is what the type is for; the prelude's `map`, `filter`, `reduce` and
`sort-by` correctly stay `Fn`, because a capturing comparator is exactly what people want, so the common
named-function case does pay. That is the one real price of two types, and it buys every function in every program
not paying for an environment it never has.
A redefinition module carries its own copy of every thunk, hidden. A module that widens a name refers to one, and
the host has no cell for it to be reached through — the same shape of bug as the `Fnval` cell below, found the same
way and closed before it shipped. `flan reload` with a body that widens a name builds on both backends.
The reverse coercion does not exist — there is nowhere for an environment to go — and is refused by the ordinary
type message, which names both spellings (`fn-cfn-narrow.flan`). A capturing literal written into a `CFn` position
is refused by name, with what it captured and the fix in the sentence (`fn-cfn-captures.flan`).
One trap worth naming, because it is where the map would have broken: `FnAddr` is asked for by unrelated readers. A
`Fn`-typed one is two words; a `CFn`-typed or `Alloc`-typed one is a bare address — the second is what the map's
hash and equality pair and a handler frame's clause are, fields of structs the runtime declares, and they must stay
one word. **The node's type is what discriminates**, in both backends.
A handler clause is the one place the runtime does the passing, so `flan_handler` grew an `env` field and
`flan_signal` calls `h->fn(condition, xfer, h->env)` — the same trailing position, and a clause that captured
nothing declares nothing and is unaffected.
### The environment is a struct the checker synthesised
One field per captured name, in first-reference order, registered in the same table a `defstruct` goes in — so both
backends lay it out with the calculator they already have and neither learns a new shape. Its name is the lifted
function's (`env/fn/OWNER/N`), which is unique and stable for the reason that name is.
Two ends, and the copy is at the near one. In the *enclosing* frame, a slot holding the struct, filled with a `Make`
of the outer locals: that store is the copy, and it happens where the value is made. In the *lifted* frame, a slot
holding the pointer and a `Let` around the whole body reading each field back into the named slot the body was
checked against — once, at entry, so nothing downstream has to know an environment exists.
Both of the compiler's new slots are **nameless**, which is how the break loop is told to hide them. That is a
deliberate call, not an omission: what a reader wants at a stop is the captured *copies*, and those are named slots
holding the values under the names the source gave them. An `env` pointer and a struct of bytes would be two rows of
noise above them.
A redefinition that changes which locals an `fn` names changes an environment's layout, and `Session`'s layout guard
**exempts** these. The guard is about values the running program is holding; an environment can be in exactly one
place, a slot of the frame the literal was written in, written by the same module that reads it on every entry. A
restart for editing a capture list would take the dev loop away from the feature it was built for.
### Escape, which is what makes "case 2" a bounded claim
A value carrying an environment may be **called, passed down, and held in a `let`**. It may not be **returned,
stored, pointed at, or pushed into a container**. The check runs over the typed IR of every function the program
ends up with — including the lifted ones, so an `fn` inside an `fn` needs no special case — and classifies
function-typed values as *suspect* or clean:
- suspect: a capturing literal (`Tast.Closure`, the only node that makes one); a **parameter** of type `Fn`, in
every function; an `Fn` read back out of a struct, a case or a pointer; a local bound to any of those,
transitively; a branch or a valued form whose value is one.
- clean: the address of a name, the result of any call, and **everything of type `CFn`** — the last for free,
because a `CFn` has no environment to dangle and the type says so. The second follows from the first refusal,
which is what stops a function from returning a suspect at all.
The two types made this pass narrower rather than wider, which is the point of having them: a signature that says
`CFn` has already promised what the analysis would otherwise have to prove, and nothing written against one is
ever examined.
Two of those arms are there because leaving them out is unsound rather than merely conservative, and each has a
program. **A function value read out of an environment** (`fn-escape-copy.flan`): a lifted body holds *copies* of
what it captured, read back with `Field(Deref env, i)`, so a copy of a captured function value carries whatever
environment the original did. Treat it as clean and the lifted body can return it, the return arrives at the outer
caller as an ordinary call result, and the whole "a call result is clean" rule has been walked around from inside.
**A valued form's tail** (`fn-escape-handled.flan`): `handler-bind`, `with-allocator` and `restart-case` are
expressions whose value is their body's — and a `restart-case`'s is a clause's too — so each is a way for a suspect
to be a function's answer that a check looking only at `return` and at the last form of a block would step over.
**The parameter rule is the whole answer to the hard case.** A capturing `fn` passed to a function that stores it is
caught *inside that function*: its parameter is suspect there and the store is refused where it is written. So no
call can leak what its caller passed, and no caller has to be analysed. What it costs is real:
`(defn keep [f (Fn [] i32)] (Fn [] i32) f)` is refused although it is harmless, and so is holding a parameter of
function type in a `Vec` that never leaves the frame. `fn-escape-param.flan` is that refusal, written down as a
refusal of something that would sometimes have been fine.
Refusing `Addr` of a suspect matters more than it looks: without it, `deref` of a `(Ptr (Fn ...))` launders a
suspect into a clean value and the return refusal has been walked around. Treating the `deref` itself as suspect is
the other half of that door, and it is free: nothing a `(Ptr (Fn ...))` can point at is anywhere but a frame, since
a global and a struct field of function type are both refused already.
Name resolution inside a lifted body now asks the enclosing function's locals **before** the globals, which is a
deliberate tightening: inside the enclosing function a local shadows a global of the same name, so a body lifted out
of it must mean the same thing. The old order was an accident of where the refusal sat.
Every one of these messages names **case 3** — the escaping closure, with an environment the collector owns —
because "this cannot be done" and "this cannot be done yet" are different sentences and the second is the true one.
Five programs: `fn-escape-return.flan`, `fn-escape-param.flan`, `fn-escape-store.flan`, `fn-escape-vec.flan`, and
`fn-capture-set.flan`.
### What may be captured
Anything but a **dyn**. A scalar, a struct and a fixed array copy whole. A string, a slice and a `Vec` or `Map`
header copy as their words, aliasing whatever they pointed at — which is exactly right while the value cannot
outlive the frame that owns the storage, and is exactly what would break under escape. A function value copies as a
function value, environment included; capturing one into another `fn`'s environment is the one place a suspect may
be written into an aggregate, and it is sound because the outer literal is itself suspect, so the pair of
environments lives and dies with one frame.
A **dyn is refused**, for the reason a struct field of dyn already is (`A struct cannot hold a dyn field the
collector would never find`): the collector's roots are frames, and nothing pushes the fields of a synthesised
environment. A copy in there would be a live value reachable only through memory the marker never walks. Milestone
2's per-type descriptors lift it, alongside the condition payload's and the struct field's — and case 3's
collector-allocated environment is where it belongs anyway. `fn-capture-dyn.flan`.
### Handlers, which get this for free and have no case 3 to wait for
`docs/BUILT.md` said a handler clause cannot see the establishing function's locals, refused for the same reason,
and that this is also case 2. **It is, and it now can.** A handler frame is popped by the body that pushed it and
nothing in the language can name one, so the establishing frame is alive whenever the clause runs — there is no
escaping case here to leave over. The only compiler change beyond the shared machinery is one field on
`flan_handler` and one argument in `flan_signal`, which the uniform signature required anyway.
What is still refused is a **store** into a captured name, in a clause as in an `fn`: the clause holds a copy, and
writing to it would change the copy and leave the local as it was. Scope is asked first, so a body's own `let`
shadowing a name the enclosing function also has is an ordinary local and an ordinary store — the refusal is about a
captured copy and not about a spelling.
A **field** of a captured struct is a different matter and is deliberately left alone: `(set (.x p) 9)` inside an
`fn` writes the copy and leaves the enclosing `p` as it was — which is exactly what `(set (.x p) 9)` inside a
function whose `p` is a *parameter* already does, and has always done. Capture takes a copy the way a call takes
one, so the two agree; refusing here would make the `fn` stricter than the `defn` it was written in for no reason
anyone could state. So §1's accumulation case still accumulates into
a global — and now with whatever the establishing function knew readable beside it, which is the half that was
missing. `fn-capture.flan`'s `handles` reads a captured budget in the clause.
Capturing the establishing frame **by reference** would make the accumulation case work directly and would be sound
here, uniquely — but it is a different feature from case 2, it would fork what "capture" means between the two
constructs, and it is not what was asked for. Named, not done.
### Recursion, nesting and loops
Capture is **transitive**: an `fn` inside an `fn` naming a local of the function both were written in makes the
middle one capture it and the inner one copy the middle one's copy. That is the same value, because every copy on
the way was taken at the moment its own value was made and those moments are nested. `check`'s context carries a
`parent` for exactly this, and it is safe to reach into precisely because a lifted body is checked at the point it
is written, with its parent paused there.
An `fn` written **inside a loop** stores into the same environment slot each time round, so what it sees is the
value on its own iteration and not the last one. `fn-capture.flan` sums `0 + 1 + 2 + 3` through a fresh `fn` per
iteration to say so. The trap this could have been — a closure `set` into a local bound outside the loop and called
after it, seeing the last iteration's copies — cannot be written: a captured value cannot be `set` anywhere, and a
`let` binding scopes to the iteration.
### What each backend cost
Very little, which was the point of putting the environment in a frame slot and passing it as an ordinary argument
at the one call that needs it.
`emit.ml`: a `%fnv` type and a 16-byte layout for `Fn`, `ptr` and eight for `CFn`; an `insertvalue` pair where a
symbol used to stand alone; two `extractvalue`s at a call through an `Fn`; one appended operand on that call and on
no other; one store in the prologue of a body that declared an environment. The four hand-written glue sites — the
startup call, `main`, the macro thunk and the reload thunk — are **unchanged**.
`x86.ml`: `Fn` becomes an aggregate and `CFn` stays a scalar; one appended argument after the channel on an `Fn`
call; an optional `incoming` slot; the 16-byte value split at a `CallPtr`. The three hand-built entries are
unchanged.
`flan_rt.c`: one field on `flan_handler` and one argument on the clause typedef, both trailing. The hash and
equality typedefs and their five call sites are **unchanged** — a hasher is reached from inside that file and never
through a function value, so it declares no environment and is handed none.
### `Fnval`, and the one thing a dev build cannot do
@ -3871,6 +4140,14 @@ What that does *not* give: a value taken *before* a redefinition and called afte
address is in a slot there is nothing left to re-resolve, and the honest fix is a trampoline per function, which is a
cost every program would pay for a case no one has hit. Named here rather than papered over.
**An `fn` literal was asking for `Fnval` and should never have been**, which the capture lane found as a bug rather
than as a design question: `flan reload` on any function containing an `fn` literal failed at `llc` with
`use of undefined value '@flan.cell.fn/OWNER/N'`. A lifted body has no name anyone can type and no way to be
redefined on its own — it is reached by address from the body it was written in, and a redefinition of that body
carries its own copy — so the cell could never hold anything but the symbol, and a redefinition module had no reason
to declare one. It takes `Flanfn` now, which is the choice a handler clause has always made and for the same reason.
`Fnval` remains what it was for: a `defn`'s *name* in value position.
The two lifted-function name sequences are counted **per kind** — `fn/OWNER/N` and `handler/OWNER/N/TYPE` —
rather than off one list. Sharing a counter would rename every `fn` in a function the moment a `handler-bind` was
added above one, which is a rename for a body that did not change, in exactly the names a redefinition module emits.

View File

@ -519,7 +519,8 @@ notation reads as exactly one data item.</p>
<tr><td><code>(Handle T)</code></td><td>a reference into a pool that reports a dead referent</td><td>index and generation packed into an <code>i64</code></td></tr>
<tr><td><code>(Ptr T)</code></td><td>raw pointer</td><td>a pointer</td></tr>
<tr><td><code>(Option T)</code></td><td><code>Some</code> / <code>None</code></td><td>tag byte + T</td></tr>
<tr><td><code>(Fn [T ...] R)</code></td><td>a function value</td><td>a pointer</td></tr>
<tr><td><code>(Fn [T ...] R)</code></td><td>a function value, which may have captured</td><td>a code address and an environment pointer</td></tr>
<tr><td><code>(CFn [T ...] R)</code></td><td>a function value that cannot capture — the <code>C</code> is what a C function pointer would need, not a way to reach C today</td><td>a pointer</td></tr>
<tr><td><code>Allocator</code></td><td>an opaque builtin: a proc, its data and a capability set</td><td>a pointer to that</td></tr>
<tr><td><code>$t</code></td><td>a type variable — see <a href="#generics">generics</a></td><td>whatever it is instantiated at</td></tr>
<tr><td>a struct</td><td>value type</td><td>fields in declaration order</td></tr>