From d5901e809b9634e1dd3a50cbc762fe6158af378a Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 17:00:47 +0700 Subject: [PATCH] What a union is, and every decision the spec did not settle BUILT.md gets the section and NEXT.md's item 5 and its diagnostics bug are struck through. The decisions worth recording are the ones nothing upstream had made: an i32 tag, a payload aligned to the widest member of any case, qualified construction and bare patterns, declaration-order tags -- so case order is part of a union's contract the way field order is a struct's -- and a non-exhaustive match refused rather than defaulted. And the one finding the macro lane needs: an imported union is still refused at load.ml:312, but the prelude is prepended into the same flat namespace before collect runs, so a defunion Form in prelude.ml needs no import and no load.ml change. Verified by declaring one there and matching it. --- BUILT.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ NEXT.md | 30 ++++++++---- 2 files changed, 158 insertions(+), 8 deletions(-) diff --git a/BUILT.md b/BUILT.md index 7f19e5e..eb889c0 100644 --- a/BUILT.md +++ b/BUILT.md @@ -2011,6 +2011,142 @@ What remains at 18 ns is the type erasure itself: a non-inlinable call into the calls to the pair. That is the trade `spec-memory.md` chose deliberately — "It is type-erased on purpose… No generics are involved, and none are needed" — and monomorphisation is what would buy it back, at the cost the spec declined. +## Unions, and the tag they carry + +`defunion` parsed and its shape was checked long before this; naming the type (`check.ml:312`) and constructing a +value (`:1075`) were both refused as milestone 6. They are not any more. + +### It is closer than the milestone number suggested, and the reason is `Option` + +`Tast.arm` already carried `acase` (a case name) and `binds` (the slots a payload binds to). That is union shape, +built and exercised, because **`Option` is a two-case union wearing a special coat** and `match` over it worked +already. So `check_match` grew a second *subject* rather than a second path: one function decides which case each arm +names and what type each name it binds has, and everything after it — the dead-set join across alternatives, the +result type, the exhaustiveness check — is the code that was there. + +`Option` was **not** desugared into a declared union, and that is deliberate: `Option` is generic and no declared +union is. The coat is the part that cannot be taken off until generics exist. + +A union is `Types.Named` exactly as a struct is. One case in `Types.t` covers both, and *which table the name is in* +is the only thing that tells them apart. That is what let a union be a field, a parameter, a return type, a slot and a +copy without a single one of those paths learning that unions exist. + +### The layout, which is the part that has to be exactly right + +``` +%"U" = type { i32, [k x iA] } +%"U.Case" = type { the case's fields, in declaration order } +``` + +A tag, then room for the largest case, with `A` the alignment the *widest member of any case* needs and `k` the size +rounded up to it. Writing the payload as an array of `iA` rather than of `i8` is what makes LLVM align it without an +explicit `align` on a type — and it is what makes the whole thing + +```c +struct { int tag; union { ... } u; } +``` + +byte for byte. **That agreement is the point.** A macro is `[Form] -> Form`, so `Form` has to be the same bytes in +the compiler and in the `dlopen`ed macro, and nothing at run time would notice a disagreement. + +The tag is an `i32` and not an `i8`. With an 8-byte payload alignment the two cost the same, and `i32` is what a C +`enum` field is — the spelling the macro lane will have to write by hand on the other side of the boundary. + +Both the union and one struct per case are emitted as *named* LLVM types, so every reader geps rather than computing +byte offsets of its own. Construction is a `store` of the tag and a `store` of the case struct through a gep into the +payload; `match` is a `load` of the tag and a gep the other way. There is one function that knows how a payload is +read — `case_field_addr` — because there are two readers: a `match` arm's binds and the structural printer. + +The layout goes through the same oracle the DWARF section uses: LLVM's own answer for the emitted type, read back as a +constant-folded `ptrtoint`. Two unions are checked, one whose widest case is a pair of `f64` and one whose cases are +all `i32`, so the payload size and alignment cannot be constants the test agreed with by accident. The `Shape` in +`test/programs/unions.flan` was checked against clang's answer for the same declaration in C: 32 bytes aligned 8 with +the payload at offset 8, and 40/8 for a struct holding one. + +### The surface + +```clojure +(defunion Shape + [Empty + (Dot [x f64 y f64]) + (Rect [w i32 h i32]) + (Tag [name string n u8])]) + +(Shape.Rect {.w 3 .h 3}) ; a value: the type and the case, then the fields +(Shape.Rect {.w 3}) ; ZII, exactly as in a struct literal — .h is 0 +Shape.Empty ; a case with no fields is a whole value, not a call + +(match s + Empty "empty" + (Dot x y) "a dot" ; positional, in declaration order + (Rect w h) "a rect" + (Tag name n) name) +``` + +**Construction is qualified and a pattern is bare.** The two are not inconsistent: at a constructor nothing says which +union is meant, and at a pattern the scrutinee's type already does. The qualified spelling is accepted in a pattern +too, since that is how the value was written and writing it again should not be an error. Two unions may therefore +share a case name, and that is not refused — refusing it would be a restriction with no mechanism behind it. + +Construction needed **no change to `parse.ml`**. `.` is a symbol constituent, so `Shape.Rect` reads as one name; the +struct-literal arm fires on a symbol followed by a map; and the field-access arm needs a *leading* dot, so the two +cannot collide. `(Name {.field value})` is one syntax for both, and which it is, is decided against the tables. + +### Tags are declaration order, so case order is part of the contract + +Tag *n* is the *n*th declared case, from zero. The consequence is that an all-bytes-zero union is **the first declared +case with a zeroed payload** — which is exactly the rule that makes an `Option`'s zero a `None`, and it is what makes +`(defvar u U)` and an omitted union-typed struct field mean something rather than nothing. Reordering a union's cases +is a layout change, the same way reordering a struct's fields is. + +### What is refused, and why each + +- **A non-exhaustive `match`.** Refused, not defaulted, and the message names the cases with no arm. A match that fell + through would have to produce a value of the match's type out of nothing, and the case a union grows tomorrow is + exactly the one a reader wants to be told about today. `_` is how to say "the rest", written where it can be seen. +- **A case pattern binding some of a case's fields.** All of them or none, positionally — binding a prefix reads the + wrong field the moment one is inserted above it. +- **Two arms for one case**, which is a mistake and never an intent. +- **A union with no cases.** No value of it can exist, so a parameter of that type is a function nothing can call. +- **A case field that is move-only**, in the same words a struct field already gets and for the same reason: + recursive teardown arrives with `drop`. +- **A union as a map key.** The payload past the case in hand is indeterminate, so hashing the blob would make two + equal values hash differently. Hashing one properly is a per-case walk driven by a switch — a different shape from + the field list `struct_key_pair` emits, and nothing has wanted it. +- **`uninit` on a union.** This is the one refusal that is *not* the struct rule. Everywhere else `uninit` is an + opt-out from ZII and the bytes are whatever they were: a garbage `f64` is a garbage number. A union's tag steers + control flow — a tag no case names falls past every comparison in a `match` into a block LLVM is entitled to assume + cannot be reached. That is the one place where garbage becomes "the optimiser may do anything". +- **`(.x u)`.** A union's fields belong to a case, and which case is being held is what the tag says. `match` is how + one is opened, and its arms bind the fields of the case they matched. +- **A global initialised with a case.** `(defvar g U (U.B {.x 1}))` would mean serialising the fields into the payload + blob at link time, which is a byte-level encoder this compiler does not have and which could not express a `string` + field at all — that is a pointer the linker has to relocate and a byte array has nowhere to put a relocation. A + *zeroed* global is fine and needs none of it: it is the first declared case. + +### The diagnostics bug, fixed + +`(A {.x 1})` on a case of a union said **"unknown struct A"**, because `env` had no table of case names and could not +tell a case from a misspelling. It has one now, keyed both by the full spelling `U.C` and by the bare `C`. The full +spelling is a *key* rather than something split out of a dotted name at the use site, because a union's own name can +contain a slash — an imported `rl/U` — and string surgery would own an edge this does not have to. + +### Printing + +`render.ml` walks a concrete type for `print`, the REPL inspector and the break buffer's locals, and a union fell +through its `Named` arm to ``. It recovers the case from the tag by a chain of comparisons — the same shape the +enum arm already had, and for the same reason: the name is erased before any backend sees it — and reads the fields of +**that case only**. It prints `(Shape.Dot {.x 1.5 .y -2.5})`, which is what the source would write. + +### What is left + +An **imported** union is still refused by name at `load.ml:312`, so a union is file-local. That is not a blocker for +the macro expander: the prelude is parsed and prepended into the same flat namespace before `collect` runs, so a +`defunion Form` in `prelude.ml` is an ordinary same-file declaration needing no import — verified by declaring one +there and matching it from a program. `dev.ml`'s inspector still says "union values are milestone 6" for a stopped +frame's locals, and `shim.ml`'s "a Flan union has no C layout" is now inaccurate as prose though the refusal it guards +is still right: a union has a C layout and still may not cross to C by value, because the shim flattens aggregates. + ## `defer` may be written in a `let` The whole of this project's resource-cleanup answer, and NEXT.md records `drop` and a `with-cleanup` form as both diff --git a/NEXT.md b/NEXT.md index ef6fc4f..a1a847c 100644 --- a/NEXT.md +++ b/NEXT.md @@ -500,14 +500,25 @@ run one lane at a time; item 4 is disjoint and runs alongside any of them. friction on every keystroke. `defn` parameter lists and `restart-case` clause parameters were the same shape and came with it. What remains in this batch is the break buffer and the inspector, and they are independent. -5. **Union values, then the macro expander, then `Result`/`try`.** Promoted above `Handle` on the author's call — +5. ~~**Union values**~~, then the macro expander, then `Result`/`try`. Promoted above `Handle` on the author's call — macros are the thing most worth wanting, and unions are the only thing between here and them. - **Unions are closer than the milestone number suggests.** `Tast.arm` already carries `acase` (a case name) and - `binds` (the slots a payload binds to) — that is union shape, built and exercised, because `Option` is a two-case - union wearing a special coat and `match` over it works today. What is missing is the declared layout (a tag plus the - largest variant), construction, and letting `match` bind payloads from a user-declared union. `defunion` already - parses and its shape is already checked; `check.ml:312` and `:1075` are the two refusals to remove. + **Union values are done.** See *Unions, and the tag they carry* in [`BUILT.md`](BUILT.md). The diagnosis was right: + `Option` is a two-case union wearing a special coat, so `Tast.arm`'s `acase` and `binds` already were union shape + and `check_match` grew a second subject rather than a second path. A union is `Types.Named` exactly as a struct is, + so every path that merely carries a type learned nothing. + + What the spec did not settle and this lane did: the tag is an `i32` and the payload a blob aligned to the widest + member of any case, so `%"U" = type { i32, [k x iA] }` is C's `struct { int tag; union {...} u; }` byte for byte — + checked against clang's answer for the same declaration. A value is `(U.C {.field value ...})` and construction is + **qualified**; a pattern is bare `(C x y)` and resolves against the scrutinee. Tags are declaration order from + zero, so **case order is part of a union's contract**: a zeroed union is the first declared case. A non-exhaustive + match is **refused**, never defaulted. + + What is left for the macro lane, and it is one thing: **`load.ml:312` refuses an imported union outright**, so a + union is file-local. That is not a blocker for `Form` — the prelude is parsed and prepended into the same flat + namespace before `collect` runs, so a `defunion Form` in `prelude.ml` is an ordinary same-file declaration and + needs no import and no `load.ml` change. Verified by declaring one there and matching it from a program. **Then macros**, which are blocked on exactly this and nothing else: a macro is `[Form] -> Form`, so `Form` has to be a Flan union whose *layout* the compiler and the `dlopen`ed macro agree on byte for byte. `NEXT.md`'s macro section @@ -1068,8 +1079,11 @@ expander last, on 6's unions. with it — `rt_die` is the non-dev path too, where there is no listener and nothing to deadlock against, so whether it should be `_exit` unconditionally or only under `--dev` is a decision rather than a typo. -- **`(A {.x 1})` on a union variant says "unknown struct A"** rather than the union refusal `check_struct` plainly - intends — `env` has no table of variant names. A diagnostics bug, not a backend death. +- ~~**`(A {.x 1})` on a union variant says "unknown struct A"**~~ **Fixed** with union values. `env` now carries a + case table keyed both by the full spelling `U.C`, which is how a value of it is written, and by the bare `C`, which + is how a mistake spells a constructor; the bare entry exists only to say *"A is a case of the union U, not a struct + — a union value names both, as `(U.A {.field value ...})`"*. Two unions may share a case name and that is not + refused: construction is qualified and a pattern resolves against the scrutinee, so both are unambiguous. ### Test blind spots, from a mutation pass