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:
parent
2572f0a537
commit
4cb53a0a9f
89
FIX.org
89
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.
|
||||
|
||||
321
docs/BUILT.md
321
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/<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.
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user