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
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`
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
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