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.
This commit is contained in:
Joseph Ferano 2026-09-12 17:00:47 +07:00
parent 6c026cb32e
commit d5901e809b
2 changed files with 158 additions and 8 deletions

136
BUILT.md
View File

@ -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 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. 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 `<Shape>`. 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` ## `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 The whole of this project's resource-cleanup answer, and NEXT.md records `drop` and a `with-cleanup` form as both

30
NEXT.md
View File

@ -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 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. 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. 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 **Union values are done.** See *Unions, and the tag they carry* in [`BUILT.md`](BUILT.md). The diagnosis was right:
`binds` (the slots a payload binds to) — that is union shape, built and exercised, because `Option` is a two-case `Option` is a two-case union wearing a special coat, so `Tast.arm`'s `acase` and `binds` already were union shape
union wearing a special coat and `match` over it works today. What is missing is the declared layout (a tag plus the and `check_match` grew a second subject rather than a second path. A union is `Types.Named` exactly as a struct is,
largest variant), construction, and letting `match` bind payloads from a user-declared union. `defunion` already so every path that merely carries a type learned nothing.
parses and its shape is already checked; `check.ml:312` and `:1075` are the two refusals to remove.
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 **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 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 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. 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 - ~~**`(A {.x 1})` on a union variant says "unknown struct A"**~~ **Fixed** with union values. `env` now carries a
intends — `env` has no table of variant names. A diagnostics bug, not a backend death. 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 ### Test blind spots, from a mutation pass