From f4efaac144839ffff113bab47111c7b3a428c05c Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Tue, 12 May 2026 02:53:47 +0900 Subject: [PATCH] wcc: nullable pointer folding for `(*T | void)` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/w6c/cgen.c | 236 +++++++++++++++++++++++++++++++++++-------- cmd/wcc/check.c | 22 ++++ cmd/wcc/ww.h | 4 + test/wcc/300_check.c | 4 + test/wcc/700_e2e.c | 38 +++++++ 5 files changed, 262 insertions(+), 42 deletions(-) diff --git a/cmd/w6c/cgen.c b/cmd/w6c/cgen.c index e0c28b1c..e5d09325 100644 --- a/cmd/w6c/cgen.c +++ b/cmd/w6c/cgen.c @@ -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, diff --git a/cmd/wcc/check.c b/cmd/wcc/check.c index cf744ed7..c8cf0bb8 100644 --- a/cmd/wcc/check.c +++ b/cmd/wcc/check.c @@ -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 diff --git a/cmd/wcc/ww.h b/cmd/wcc/ww.h index 363319a0..62c2a30f 100644 --- a/cmd/wcc/ww.h +++ b/cmd/wcc/ww.h @@ -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; diff --git a/test/wcc/300_check.c b/test/wcc/300_check.c index b6d6a34e..745dde29 100644 --- a/test/wcc/300_check.c +++ b/test/wcc/300_check.c @@ -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 diff --git a/test/wcc/700_e2e.c b/test/wcc/700_e2e.c index 14ca3265..0aef6401 100644 --- a/test/wcc/700_e2e.c +++ b/test/wcc/700_e2e.c @@ -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. */