wcc: nullable pointer folding for (*T | void)

A tagged union with exactly one `*T` variant and one `void` variant
collapses to a single 8-byte pointer slot, where the null bit
pattern is the void variant and any non-null is the *T variant.
Mirrors Hare's `(*T | null)` ABI optimisation.

Detected in resolve_type when the post-flatten variant list has
exactly two entries of the right shape; Type.nullable = 1 and
size = 8. Codegen branches every tagged-handling site on the flag:

- match: discriminator = pointer-vs-zero, not slot+0 tag word.
  Binding for the *T case copies the same word (the pointer itself)
  rather than slot+8.
- is/as: same ptr-vs-zero discriminator.
- ?: null = error (propagate AX=0 to caller's matching null
  encoding); non-null = success (AX is already the pointer).
- !: null aborts; non-null falls through with AX = pointer.
- let-init / return: spill or set just AX (no tag/value pair).
- call-arg push: push only AX, not the now-unused DX/CX.

Prologue spill already pulled size/8 = 1 arg register via the
existing tagged-arg loop, so no change needed there.

Two existing helpers in cgen.c get nullable-aware spelling:
type_isnullable() and nullable_ptr_tag() (which variant index is
the *T side; the void side is the other one).

The Hare-style `(*T | null)` spelling isn't supported — `null` is
not a type keyword in ww. Callers use `void` instead, which is
already a real type. The result is the same bit-level layout.
This commit is contained in:
2026-05-12 02:53:47 +09:00
parent dc8405429e
commit f4efaac144
5 changed files with 262 additions and 42 deletions

View File

@@ -118,8 +118,9 @@ struct_arg_size(Type *t)
}
/* Tagged-union arg byte size: 16 (8B variants) or 24 (16B variants).
* Both fit in our 3-register classification. Returns 0 if not a
* tagged union or too large to pass in registers. */
* Nullable-folded `(*T | void)` collapses to 8 bytes (just the
* pointer). Returns 0 if not a tagged union or too large to pass
* in registers. */
static int
tagged_arg_size(Type *t)
{
@@ -130,6 +131,32 @@ tagged_arg_size(Type *t)
return (int)t->size;
}
/* type_isnullable — TY_TAGGED with the (*T | void) one-word fold. */
static int
type_isnullable(Type *t)
{
if (t == NULL) return 0;
if (t->kind == TY_NAMED) t = t->under;
return t && t->kind == TY_TAGGED && t->nullable;
}
/* nullable_ptr_tag — index of the *T variant in a nullable union.
* Returns 0 or 1; the void variant takes the other slot. */
static int
nullable_ptr_tag(Type *t)
{
if (t == NULL) return 0;
if (t->kind == TY_NAMED) t = t->under;
if (t == NULL || t->kind != TY_TAGGED) return 0;
int i = 0;
for (Tparam *p = t->params; p; p = p->next, i++) {
Type *pu = (p->type && p->type->kind == TY_NAMED)
? p->type->under : p->type;
if (pu && pu->kind == TY_PTR) return i;
}
return 0;
}
static int
node_istaggedarg(Node *n)
{
@@ -1713,11 +1740,14 @@ cgexpr(Cg *c, Node *n, Local *locals)
} else if (node_istaggedarg(args[i])) {
/* Tagged-return ABI: AX=tag, DX=val0[, CX=val1].
* Push high-to-low so pop drains tag first (into
* arg-reg[0]), then values into arg-reg[1..]. */
* arg-reg[0]), then values into arg-reg[1..].
* Nullable (sz=8): AX holds the pointer, no
* value-word registers — push just AX. */
int sz = tagged_arg_size(args[i]->type);
if (sz > 16)
ins1(c, A_PUSHQ, areg(D_CX));
ins1(c, A_PUSHQ, areg(D_DX));
if (sz > 8)
ins1(c, A_PUSHQ, areg(D_DX));
ins1(c, A_PUSHQ, areg(D_AX));
} else {
ins1(c, A_PUSHQ, areg(D_AX));
@@ -1838,10 +1868,15 @@ cgexpr(Cg *c, Node *n, Local *locals)
*
* Slot layout: [+0]=tag, [+8]=value0, [+16]=value1. The third
* word is only meaningful for variants whose payload is >8B
* (e.g. str). Bindings sized 16B (str) copy two words. */
* (e.g. str). Bindings sized 16B (str) copy two words.
*
* Nullable folded `(*T | void)`: slot is one 8B word holding
* the pointer; null IS the void variant. Discriminator =
* value, not a separate tag. */
Node *s = n->lhs;
Type *st = s ? s->type : NULL;
Type *su = (st && st->kind == TY_NAMED) ? st->under : st;
int is_nullable = type_isnullable(st);
int slot_size = (su && su->kind == TY_TAGGED) ? (int)su->size : 16;
int sl_off;
if (s->kind == N_IDENT) {
@@ -1877,15 +1912,20 @@ cgexpr(Cg *c, Node *n, Local *locals)
/* Spill non-ident scrutinees (e.g. `match (foo()?)`) into
* a scratch slot so we can index out the tag/value. The
* call ABI for tagged returns is AX=tag, DX=value0,
* CX=value1 — copy each word into the slot. */
* CX=value1 — copy each word into the slot. Nullable
* returns are single-word: AX is the pointer; spill
* only that. */
sl_off = localoff(c, &locals, "@match_spill", slot_size,
cg_frame);
cgexpr(c, s, locals);
ins2(c, A_MOVQ, areg(D_AX), amem(D_BP, sl_off + 0));
ins2(c, A_MOVQ, areg(D_DX), amem(D_BP, sl_off + 8));
if (slot_size > 16)
ins2(c, A_MOVQ, areg(D_CX),
amem(D_BP, sl_off + 16));
if (!is_nullable) {
ins2(c, A_MOVQ, areg(D_DX),
amem(D_BP, sl_off + 8));
if (slot_size > 16)
ins2(c, A_MOVQ, areg(D_CX),
amem(D_BP, sl_off + 16));
}
}
char *end = mklabel(c, "match_end");
for (Node *cs = n->list; cs; cs = cs->next) {
@@ -1893,7 +1933,18 @@ cgexpr(Cg *c, Node *n, Local *locals)
if (cs->type != NULL) {
int tag = cg_tag_for_variant(su, cs->type);
ins2(c, A_MOVQ, amem(D_BP, sl_off + 0), areg(D_AX));
if (cs->list != NULL) {
if (is_nullable) {
/* discriminator = pointer-vs-null.
* *T variant: skip if ptr == 0.
* void variant: skip if ptr != 0. */
int ptr_tag = nullable_ptr_tag(su);
int want_ptr = (tag == ptr_tag);
ins2(c, A_CMPQ, aimm(0), areg(D_AX));
if (want_ptr)
ins1(c, A_JE, abranch(next));
else
ins1(c, A_JNE, abranch(next));
} else if (cs->list != NULL) {
/* Multi-pattern `case T1 | T2 | ... =>`:
* if the tag matches any of the alts,
* jump to body; otherwise to the next
@@ -1923,16 +1974,34 @@ cgexpr(Cg *c, Node *n, Local *locals)
Type *bt = cs->type;
Type *bu = (bt && bt->kind == TY_NAMED)
? bt->under : bt;
int bsz = (bu && bu->kind == TY_STR) ? 16 : 8;
int voff = localoff(c, &locals, cs->str, bsz,
cg_frame);
int nwords = (bsz + 7) / 8;
for (int w = 0; w < nwords; w++) {
ins2(c, A_MOVQ,
amem(D_BP, sl_off + 8 + 8*w),
areg(D_AX));
ins2(c, A_MOVQ, areg(D_AX),
amem(D_BP, voff + 8*w));
if (is_nullable) {
/* Bind *T or void to a local. The
* value IS the slot's pointer word; no
* payload to copy. void binding is
* unusable (size 0), so only emit for
* the *T variant. */
if (bu && bu->kind == TY_PTR) {
int voff = localoff(c, &locals,
cs->str, 8, cg_frame);
ins2(c, A_MOVQ,
amem(D_BP, sl_off + 0),
areg(D_AX));
ins2(c, A_MOVQ, areg(D_AX),
amem(D_BP, voff));
}
} else {
int bsz = (bu && bu->kind == TY_STR)
? 16 : 8;
int voff = localoff(c, &locals, cs->str,
bsz, cg_frame);
int nwords = (bsz + 7) / 8;
for (int w = 0; w < nwords; w++) {
ins2(c, A_MOVQ,
amem(D_BP, sl_off + 8 + 8*w),
areg(D_AX));
ins2(c, A_MOVQ, areg(D_AX),
amem(D_BP, voff + 8*w));
}
}
}
cgstmt(c, cs->body, &locals, cg_frame);
@@ -1948,12 +2017,29 @@ cgexpr(Cg *c, Node *n, Local *locals)
* current function's return (with a tag remap to the
* enclosing fn's variant order). On success, unwrap to the
* success-variant ABI: ≤8B values in AX; str values in
* (AX=ptr, BX=len). */
* (AX=ptr, BX=len).
*
* Nullable: AX is the pointer; *T variant is the success
* (any non-null), void variant is the error (null). The
* enclosing fn's null encoding is the same — RET with AX=0
* if propagating; otherwise leave AX as-is on success. */
cgexpr(c, n->lhs, locals);
Type *u = n->lhs ? n->lhs->type : NULL;
if (u && u->kind == TY_NAMED) u = u->under;
Type *r = cg_ret_type;
if (r && r->kind == TY_NAMED) r = r->under;
if (u && u->kind == TY_TAGGED && u->nullable) {
char *cont = mklabel(c, "tryprop_ok");
ins2(c, A_CMPQ, aimm(0), areg(D_AX));
ins1(c, A_JNE, abranch(cont));
/* null = error: propagate. AX already 0; matches
* the enclosing nullable encoding if it has one. */
ins2(c, A_MOVQ, areg(D_BP), areg(D_SP));
ins1(c, A_POPQ, areg(D_BP));
ins0(c, A_RET);
label(c, cont);
break;
}
int s_tag = cg_tagged_success_tag(u);
Type *succ_t = NULL;
if (u && u->kind == TY_TAGGED) {
@@ -1992,10 +2078,22 @@ cgexpr(Cg *c, Node *n, Local *locals)
break;
}
case N_TRYUNW: {
/* On error variant: exit(1) directly via the syscall. */
/* On error variant: exit(1) directly via the syscall.
* Nullable: null = error; non-null = success (AX is the
* pointer, ready to use). */
cgexpr(c, n->lhs, locals);
Type *u = n->lhs ? n->lhs->type : NULL;
if (u && u->kind == TY_NAMED) u = u->under;
if (u && u->kind == TY_TAGGED && u->nullable) {
char *cont = mklabel(c, "tryunw_ok");
ins2(c, A_CMPQ, aimm(0), areg(D_AX));
ins1(c, A_JNE, abranch(cont));
ins2(c, A_MOVQ, aimm(1), areg(D_DI));
ins2(c, A_MOVQ, aimm(60), areg(D_AX));
ins0(c, A_SYSCALL);
label(c, cont);
break;
}
int s_tag = cg_tagged_success_tag(u);
Type *succ_t = NULL;
if (u && u->kind == TY_TAGGED) {
@@ -2018,19 +2116,28 @@ cgexpr(Cg *c, Node *n, Local *locals)
}
case N_TYPETEST: {
/* `e is T` — Compare scrutinee tag against T's variant index.
* Result is bool (0/1) in AX. We only need the tag, not the
* value words, so cgexpr's AX-only result is enough for both
* register-returning callers (AX=tag) and N_IDENT locals
* (cgexpr loads slot+0 → AX, which is the tag). */
* Result is bool (0/1) in AX. Nullable: discriminator is
* pointer-vs-null, not a tag. */
cgexpr(c, n->lhs, locals);
Type *u = n->lhs ? n->lhs->type : NULL;
if (u && u->kind == TY_NAMED) u = u->under;
Type *vt = n->rhs ? n->rhs->type : NULL;
int tag = cg_tag_for_variant(u, vt);
char *ne = mklabel(c, "is_ne");
char *done = mklabel(c, "is_done");
ins2(c, A_CMPQ, aimm(tag < 0 ? 0 : tag), areg(D_AX));
ins1(c, A_JNE, abranch(ne));
if (u && u->kind == TY_TAGGED && u->nullable) {
int tag = cg_tag_for_variant(u, vt);
int ptr_tag = nullable_ptr_tag(u);
int want_ptr = (tag == ptr_tag);
ins2(c, A_CMPQ, aimm(0), areg(D_AX));
if (want_ptr)
ins1(c, A_JE, abranch(ne));
else
ins1(c, A_JNE, abranch(ne));
} else {
int tag = cg_tag_for_variant(u, vt);
ins2(c, A_CMPQ, aimm(tag < 0 ? 0 : tag), areg(D_AX));
ins1(c, A_JNE, abranch(ne));
}
ins2(c, A_MOVQ, aimm(1), areg(D_AX));
ins1(c, A_JMP, abranch(done));
label(c, ne);
@@ -2047,12 +2154,15 @@ cgexpr(Cg *c, Node *n, Local *locals)
* the value lives at slot+8/+16 — cgexpr's single-MOVQ path
* does not load it. Mirror match's pattern: resolve a slot
* offset (existing local or a fresh @asrt_spill) and index
* out tag/value from memory. */
* out tag/value from memory.
*
* Nullable: the slot's word IS the pointer. *T variant
* asserts non-null; void variant asserts null. The value
* left in AX after the check is the pointer itself. */
Node *s = n->lhs;
Type *st = s ? s->type : NULL;
Type *u = (st && st->kind == TY_NAMED) ? st->under : st;
Type *vt = n->type;
int tag = cg_tag_for_variant(u, vt);
int slot_size = (u && u->kind == TY_TAGGED) ? (int)u->size : 16;
int sl_off = 0;
if (s && s->kind == N_IDENT && s->str) {
@@ -2063,12 +2173,35 @@ cgexpr(Cg *c, Node *n, Local *locals)
slot_size, cg_frame);
cgexpr(c, s, locals);
ins2(c, A_MOVQ, areg(D_AX), amem(D_BP, sl_off + 0));
ins2(c, A_MOVQ, areg(D_DX), amem(D_BP, sl_off + 8));
if (slot_size > 16)
ins2(c, A_MOVQ, areg(D_CX),
amem(D_BP, sl_off + 16));
if (!(u && u->kind == TY_TAGGED && u->nullable)) {
ins2(c, A_MOVQ, areg(D_DX),
amem(D_BP, sl_off + 8));
if (slot_size > 16)
ins2(c, A_MOVQ, areg(D_CX),
amem(D_BP, sl_off + 16));
}
}
char *ok = mklabel(c, "asrt_ok");
if (u && u->kind == TY_TAGGED && u->nullable) {
int tag = cg_tag_for_variant(u, vt);
int ptr_tag = nullable_ptr_tag(u);
int want_ptr = (tag == ptr_tag);
ins2(c, A_MOVQ, amem(D_BP, sl_off + 0), areg(D_AX));
ins2(c, A_CMPQ, aimm(0), areg(D_AX));
if (want_ptr)
ins1(c, A_JNE, abranch(ok));
else
ins1(c, A_JE, abranch(ok));
ins2(c, A_MOVQ, aimm(1), areg(D_DI));
ins2(c, A_MOVQ, aimm(60), areg(D_AX));
ins0(c, A_SYSCALL);
label(c, ok);
/* AX already holds the pointer (or 0 for void
* variant, where the result type has size 0 and
* no consumer reads it). */
break;
}
int tag = cg_tag_for_variant(u, vt);
ins2(c, A_MOVQ, amem(D_BP, sl_off + 0), areg(D_AX));
ins2(c, A_CMPQ, aimm(tag < 0 ? 0 : tag), areg(D_AX));
ins1(c, A_JE, abranch(ok));
@@ -2547,10 +2680,19 @@ cgstmt(Cg *c, Node *n, Local **locals, int *frame)
* 2) rhs is a bare variant value (e.g. `let r: (i64|i32) = 7`).
* Synthesise the tag from rhs's static type and store it
* alongside the value. str-typed rhs flows as
* (AX=ptr, BX=len) so we store both halves. */
* (AX=ptr, BX=len) so we store both halves.
*
* Nullable folded `(*T | void)`: the slot is a single 8B
* pointer. Both the value-from-call and bare-variant paths
* simplify to "spill AX". void variant stores 0; *T variant
* stores the pointer. */
if (n->rhs && lu && lu->kind == TY_TAGGED) {
Type *rt = n->rhs->type;
if (type_istagged(rt)) {
if (lu->nullable) {
cgexpr(c, n->rhs, *locals);
ins2(c, A_MOVQ, areg(D_AX),
amem(D_BP, off + 0));
} else if (type_istagged(rt)) {
cgexpr(c, n->rhs, *locals);
ins2(c, A_MOVQ, areg(D_AX), amem(D_BP, off + 0));
ins2(c, A_MOVQ, areg(D_DX), amem(D_BP, off + 8));
@@ -2780,9 +2922,15 @@ cgstmt(Cg *c, Node *n, Local **locals, int *frame)
Type *rt = cg_ret_type;
if (rt->kind == TY_NAMED) rt = rt->under;
if (rt && rt->kind == TY_TAGGED) {
int tag = cg_tag_for_variant(rt, ty_void);
if (tag < 0) tag = 0;
ins2(c, A_MOVQ, aimm(tag), areg(D_AX));
if (rt->nullable) {
/* bare `return;` is the void/null
* variant: emit AX = 0. */
ins2(c, A_MOVQ, aimm(0), areg(D_AX));
} else {
int tag = cg_tag_for_variant(rt, ty_void);
if (tag < 0) tag = 0;
ins2(c, A_MOVQ, aimm(tag), areg(D_AX));
}
ins2(c, A_MOVQ, areg(D_BP), areg(D_SP));
ins1(c, A_POPQ, areg(D_BP));
ins0(c, A_RET);
@@ -2795,7 +2943,11 @@ cgstmt(Cg *c, Node *n, Local **locals, int *frame)
if (rt && rt->kind == TY_TAGGED) {
Type *vt = n->lhs->type;
cgexpr(c, n->lhs, *locals);
if (!type_istagged(vt)) {
if (rt->nullable) {
/* AX already holds the pointer (or
* 0 if the value was `nil` / `void`).
* No tag word, no shuffle. */
} else if (!type_istagged(vt)) {
int tag = cg_tag_for_variant(rt, vt);
if (type_isstr(vt)) {
/* AX=ptr, BX=len → DX=ptr,

View File

@@ -247,6 +247,28 @@ resolve_type(Checker *c, Node *n)
if (nv == 0) return ty_never;
if (nv == 1 && head) return head->type;
t->params = head;
/* Nullable pointer folding: `(*T | void)` collapses to a
* single 8-byte pointer slot; null bit pattern is the void
* variant. Mirrors Hare's `(*T | null)`. Detected on exact
* two-variant shape with one TY_PTR and one TY_VOID. */
if (nv == 2) {
Tparam *a = head;
Tparam *b = head->next;
Type *au = (a->type && a->type->kind == TY_NAMED)
? a->type->under : a->type;
Type *bu = (b->type && b->type->kind == TY_NAMED)
? b->type->under : b->type;
int aptr = au && au->kind == TY_PTR;
int bptr = bu && bu->kind == TY_PTR;
int avoid = au && au->kind == TY_VOID;
int bvoid = bu && bu->kind == TY_VOID;
if ((aptr && bvoid) || (avoid && bptr)) {
t->nullable = 1;
t->size = 8;
t->align = 8;
return t;
}
}
/* Round value payload up to an 8-byte multiple so the slot
* layout (tag + N value words) stays word-aligned. The reg-
* passing ABI counts size/8 words; 12-byte unions like

View File

@@ -408,6 +408,10 @@ struct Type {
* through NAMED aliases. Variants with
* iserror=1 are the propagation target of
* the `?` operator. */
int nullable;/* TY_TAGGED with exactly `(*T | void)` —
* stored as a single 8-byte pointer; null
* is the void variant. Mirrors Hare's
* `(*T | null)` folding. */
};
extern Type *ty_void, *ty_bool, *ty_rune;

View File

@@ -145,6 +145,10 @@ static const struct row rows[] = {
"fn parse() (invalid | i64 | overflow) = { return 0i64; }; "
"fn caller() i32 = { let v: i64 = parse()?; return v: i32; };",
"enclosing function must return a tagged union" },
/* nullable pointer folding accepts `(*T | void)` */
{ "fn lookup(p: *i32) (*i32 | void) = { return p; };", "ok" },
{ "fn lookup() (*i32 | void) = { return; };", "ok" }, /* bare return → null */
};
int

View File

@@ -538,6 +538,44 @@ static const struct row rows[] = {
" };\n"
" return 0;\n"
"};", 3 },
/* Nullable pointer folding: `(*T | void)` is one 8-byte word
* where null = void variant. match/is/as/?/! all key off the
* pointer-vs-null discriminator instead of a separate tag. */
{ "fn lookup(p: *i32, b: bool) (*i32 | void) = {\n"
" if (b) { return p; };\n"
" return;\n"
"};\n"
"fn use_arg(r: (*i32 | void)) i32 = {\n"
" match (r) {\n"
" case let q: *i32 => return *q;\n"
" case void => return 99;\n"
" };\n"
" return 0;\n"
"};\n"
"fn main() i32 = {\n"
" let x: i32 = 42;\n"
" let ok: i32 = use_arg(lookup(&x, true));\n"
" let no: i32 = use_arg(lookup(&x, false));\n"
" if (ok != 42) { return 1; };\n"
" if (no != 99) { return 2; };\n"
" return 7;\n"
"};", 7 },
/* Nullable with is/as: discriminator is ptr-vs-null. */
{ "fn lookup(p: *i32, b: bool) (*i32 | void) = {\n"
" if (b) { return p; };\n"
" return;\n"
"};\n"
"fn main() i32 = {\n"
" let x: i32 = 42;\n"
" let r1: (*i32 | void) = lookup(&x, true);\n"
" let r2: (*i32 | void) = lookup(&x, false);\n"
" let acc: i32 = 0;\n"
" if (r1 is *i32) { acc += 1; };\n"
" if (r2 is void) { acc += 2; };\n"
" let p: *i32 = r1 as *i32;\n"
" if (*p == 42) { acc += 4; };\n"
" return acc;\n"
"};", 7 },
/* `!`-flagged error variants: success picked by absence of `!`,
* errors picked by presence. Tag remap still works across
* different variant orders between operand and enclosing fn. */