The cast that opens a box, written down

This commit is contained in:
Joseph Ferano 2026-09-20 13:58:15 +07:00
parent a45e3806af
commit 34790e78af

92
FIX.org
View File

@ -1033,3 +1033,95 @@ inverting the condition to move the last operand into the else arm costs a
[not] per operand and worse locs than it buys. What would fix it is check_if [not] per operand and worse locs than it buys. What would fix it is check_if
preferring the arm that is not a compiler temp when it decides which one to preferring the arm that is not a compiler temp when it decides which one to
blame — a change in check.ml, which this lane did not own. blame — a change in check.ml, which this lane did not own.
* A numeric cast opens a dyn box, decided 2026-09-20
Every numeric cast — =(f64 x)=, =(i64 x)=, =(u32 x)=, =(f32 x)=, all of
them — takes a dyn operand now. Until this, the cast arm refused it with "f64
converts a number, found dyn", and the only thing in the language that opened
a box was a typed parameter, so a program wanting a number out of a dyn wrote
a one-line function whose parameter slot did the unboxing and called *that*.
A cast is the operator for "convert this to that"; it is the spelling that
should have worked.
Three cases, and the middle one is the author's call.
1. Same kind. The box holds what the cast asks for, so the cast is the unbox
and nothing else. A dyn box only ever holds an i64, an f64 or a bool among
the numbers, so =(f32 d)= on a float box unboxes to f64 and narrows, and
=(u32 d)= on an int box unboxes to i64 and narrows — each by the rule the
same cast already follows on a typed operand.
2. Cross kind — COERCE, with a warning. The author's words were "just coerce
it with a warning". =(f64 int-box)= is 7 -> 7.0 and =(i64 float-box)= is
2.5 -> 2, the truncation toward zero =(i64 2.5)= already does, range-check
and ArithError included. This overrides the tempting rule of matching the
parameter boundary, which traps on a kind mismatch: a cast is already a
conversion operator — =(f64 5)= converts a typed integer — so converting
across the box is the cast doing its job. The warning exists because the
box's kind was not what the program apparently expected, not because the
conversion is in doubt.
3. A box holding a non-number traps: text, nil, keyword, vec, map — and
*bool*, which is not a special case but the parameter boundary's existing
answer mirrored. flan_dyn_need_i64 refuses a dyn holding true at a typed
i64 parameter today, and =(i64 d)= refuses it for the same reason and in
the same voice.
** The warning is once per SITE
These casts sit in per-cell-per-frame loops — sand.flan runs at 120fps — so a
per-occurrence line is a flood and not a diagnostic. check.ml threads the
site's loc text into the runtime call and flan_dyn.c keeps a small table of
sites it has already spoken about, keyed on the loc's *bytes* rather than its
address: the two backends emit their own constants for it and neither promises
that two mentions of one site share a pointer. Sixty-four sites, and past that
it stops deduplicating rather than stops warning — the noisy failure, not the
silent one. The line is:
flan FILE:LINE:COL: (f64 x) found a dyn holding an int, and converted it to f64 — warned once for this site
Both builds warn, dev and release. No precedent was found making a diagnostic
of this kind dev-only: the allocation registry's notes are the one runtime
family a release build drops, and those are a *feature* being disabled, not a
warning being hushed. After the first hit this costs a tag compare and a
linear scan of a handful of entries, which is nothing.
** How it lowers, and why that shape
check.ml's [cast_dyn] builds a branch, not a call that converts:
(let ([s d])
(if (= (flan_dyn_cast_kind s "file:1:2" "u32" 0) 1)
(u32 (flan_dyn_need_f64 s))
(u32 (flan_dyn_need_i64 s))))
flan_dyn_cast_kind answers 1 for a float box and 0 for an int box, traps for
everything else, and warns when the answer disagrees with the target. Each arm
is then an ordinary [Cast] over an ordinary need — *the same node* a typed
operand of that type would have produced.
The alternative was a coercing runtime entry point answering the finished
number, and it was rejected because =(i64 2.5)= is not a bare fptosi in this
compiler: Emit.check_cast range-checks it and signals ArithError when the
value will not fit, and lib/x86.ml does the same. A C function returning an
int64_t would have had to grow its own second opinion about range and NaN, in
a second place, for two backends — a fork of exactly the kind "Arithmetic
semantics do not fork across the two spaces" forbids. With the branch there is
nothing to keep in step, and "x86 tracks LLVM -O0" holds by construction:
programs/dyn-cast.flan prints byte-identical output on both backends,
warnings and trap included.
The generic cast arm — =(t x)= inside a body with ={:where (numeric? $t)}=
did NOT grow a dyn case and did not need one: the operand's type there is what
the bound admits, and numeric? does not admit dyn, so a dyn cannot reach that
arm. Pinned in test_flan.ml.
** What this repeals
test_flan.ml's row "a keyword with no expectation converts as dyn" pinned
=(i64 :space)= as a *check* error. It is a well-typed program now and a
run-time trap instead; the row became an [accepts] saying so. That is the
whole of the behaviour change outside the new feature.
** For the author: the shims in sand.flan can go
sand.flan defines =dyn->f64= and =dyn->u32=, one-line functions whose only
job is that their parameter slot unboxes. Every call site can now write the
cast directly — =(f64 d)=, =(u32 d)= — and the two defns deleted. Not done
here: sand.flan is the author's WIP and this lane did not touch it.