From 4cb53a0a9f90dd4a49bbf4ee0623c8b895f1188c Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Mon, 21 Sep 2026 12:54:13 +0700 Subject: [PATCH] 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. --- FIX.org | 89 ++++++++++++++ docs/BUILT.md | 321 +++++++++++++++++++++++++++++++++++++++++++++---- web/index.html | 3 +- 3 files changed, 390 insertions(+), 23 deletions(-) diff --git a/FIX.org b/FIX.org index d0b9333f..ca4acdca 100644 --- a/FIX.org +++ b/FIX.org @@ -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. diff --git a/docs/BUILT.md b/docs/BUILT.md index 56c23650..bb08a35a 100644 --- a/docs/BUILT.md +++ b/docs/BUILT.md @@ -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/`, 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. diff --git a/web/index.html b/web/index.html index d48628ac..be27bf4d 100644 --- a/web/index.html +++ b/web/index.html @@ -519,7 +519,8 @@ notation reads as exactly one data item.

(Handle T)a reference into a pool that reports a dead referentindex and generation packed into an i64 (Ptr T)raw pointera pointer (Option T)Some / Nonetag byte + T -(Fn [T ...] R)a function valuea pointer +(Fn [T ...] R)a function value, which may have captureda code address and an environment pointer +(CFn [T ...] R)a function value that cannot capture — the C is what a C function pointer would need, not a way to reach C todaya pointer Allocatoran opaque builtin: a proc, its data and a capability seta pointer to that $ta type variable — see genericswhatever it is instantiated at a structvalue typefields in declaration order