Files
scriptc/tests/corpus/4061-nullable-ref-fields.ts
Malte Ubl 363a9360fd Keep copied nullable field reads and union binding copies in stack storage
A whole-value read of a nullable-pointer field allocated a heap box per
read, so list walks (`p = p.next`) regressed: 0.07 s -> 0.35 s on a 100k
list walked 200 times. Such reads now retain the instance pointer into
the current frame and wrap it in a private stack box with that
independent owner:

- local union storage accepts nullable field reads and copies of another
  binding of the same union as sources (the payload is retained; scalar
  and unit arms own nothing; retains branch on the tag because not every
  retain entry point is NULL-tolerant);
- arguments to projection-only parameters of calls and constructors.

The list walk now takes 0.05 s (baseline 0.07 s). Corpus 4061 adds a copy
that outlives a store to the field and copies across string, number,
-0 and undefined arms.
2026-10-08 15:34:37 -05:00

217 lines
7.0 KiB
TypeScript

// Class fields typed `C | null` / `C | undefined` hold the instance pointer
// itself (no union box). This pins every way such a field is read and
// written: tests, narrowing, `??`, `?.`, equality, truthiness, whole-value
// escapes (arrays, calls, returns, closures), reassignment and self-
// assignment, constructor parameters that are and are not projection-only,
// subclass instances in base-typed fields, shared-field reads across a
// class union, `any` views (reads and writes through the dynamic bridge),
// JSON, cycles for the collector, and a long chain's teardown.
class Node {
next: Node | null = null;
prev?: Node;
readonly id: number;
constructor(id: number) {
this.id = id;
}
}
const a = new Node(1);
const b = new Node(2);
console.log("init", a.next === null, a.prev === undefined, a.next, a.prev);
a.next = b;
b.prev = a;
console.log("linked", a.next === b, a.next?.id, b.prev?.id, b.next?.id, a.prev?.id);
console.log("nullish", (a.next ?? b).id, (b.next ?? a).id, b.prev?.id ?? -1, a.prev?.id ?? -1);
console.log("truthy", !!a.next, !!b.next, !!b.prev, !!a.prev, a.next ? "yes" : "no", b.next ? "yes" : "no");
console.log("eq", a.next === b.next, a.next !== b, b.prev === a, a.prev === b.next as unknown);
// Whole-value escapes: arrays, calls, returns, closures.
function describe(n: Node | null): string {
return n === null ? "null" : `#${n.id}`;
}
function pick(n: Node, first: boolean): Node | null {
return first ? n.next : null;
}
const held: (Node | null)[] = [a.next, b.next, pick(a, true), pick(a, false)];
console.log("held", held.map(describe).join(","), describe(a.next), describe(b.next));
const getNext = (): Node | null => a.next;
a.next = null;
console.log("closure", describe(getNext()), describe(held[0]!), held[0] === b);
a.next = b;
console.log("closure2", describe(getNext()));
// Reassignment releases the old instance; self-assignment keeps it alive.
let chain = new Node(10);
chain.next = new Node(11);
chain.next.next = new Node(12);
chain.next = chain.next;
chain.next.next = chain.next.next;
console.log("self", chain.next?.id, chain.next?.next?.id);
chain.next = chain.next?.next ?? null;
console.log("skip", chain.next?.id, chain.next?.next);
chain = new Node(20);
console.log("fresh", chain.next, chain.id);
// Mutation inside `??` defaults and equality operands.
const m = new Node(30);
m.next = null;
const viaDefault = m.next ?? ((m.next = new Node(31)), m.next);
console.log("default", viaDefault.id, m.next?.id, viaDefault === m.next);
const old = m.next;
function swap(n: Node): Node | null {
n.next = new Node(32);
return n.next;
}
console.log("eq-mutation", m.next === swap(m), old === m.next, m.next?.id, old?.id);
// Constructor parameters: projection-only stores, and stores that also
// escape the parameter elsewhere.
class Tree {
readonly left: Tree | null;
readonly right: Tree | null;
constructor(left: Tree | null, right: Tree | null) {
this.left = left;
this.right = right;
}
count(): number {
return 1 + (this.left === null ? 0 : this.left.count()) + (this.right?.count() ?? 0);
}
}
const seen: (Tree | null)[] = [];
class Logged {
readonly child: Tree | null;
constructor(child: Tree | null) {
seen.push(child);
this.child = child;
}
}
function build(depth: number): Tree {
return depth === 0 ? new Tree(null, null) : new Tree(build(depth - 1), build(depth - 1));
}
let total = 0;
for (let i = 0; i < 200; i++) total += build(8).count();
const logged = [new Logged(build(2)), new Logged(null)];
console.log("trees", total, logged[0]!.child?.count(), logged[1]!.child, seen.length, seen[0] === logged[0]!.child);
// Subclass instances in base-typed fields; virtual calls through them.
class Shape {
parent: Shape | null = null;
area(): number {
return 0;
}
}
class Square extends Shape {
readonly side: number;
constructor(side: number) {
super();
this.side = side;
}
area(): number {
return this.side * this.side;
}
}
class Circle extends Shape {
readonly r: number;
constructor(r: number) {
super();
this.r = r;
}
area(): number {
return Math.round(Math.PI * this.r * this.r);
}
}
const sq = new Square(3);
const ci = new Circle(2);
sq.parent = ci;
ci.parent = new Square(5);
console.log("virtual", sq.parent.area(), sq.parent?.parent?.area(), ci.parent?.parent?.area() ?? "none");
// Shared-field reads across a union of classes.
class Leaf {
readonly kind = "leaf";
owner: Branch | null = null;
}
class Branch {
readonly kind = "branch";
owner: Branch | null = null;
}
const root = new Branch();
const owned = new Leaf();
const inner = new Branch();
owned.owner = root;
inner.owner = root;
const items: (Leaf | Branch)[] = [owned, inner, new Leaf()];
for (const item of items) console.log("owner", item.kind, item.owner === root, item.owner?.kind ?? "none");
// `any` views: reads, writes, JSON, and console output.
class Box {
inner: Box | null = null;
readonly tag: string;
constructor(tag: string) {
this.tag = tag;
}
}
const outer = new Box("outer");
outer.inner = new Box("inner");
const view: any = outer;
console.log("any-read", view.inner.tag, view.inner.inner);
// Read through a helper: TypeScript's assignment narrowing of `outer.inner`
// does not see writes made through the `any` view.
function innerOf(box: Box): Box | null {
return box.inner;
}
view.inner = new Box("replaced");
console.log("any-write", innerOf(outer)?.tag, innerOf(outer)?.inner);
view.inner = null;
console.log("any-null", innerOf(outer), innerOf(outer) === null);
outer.inner = new Box("again");
console.log("json", JSON.stringify(view));
console.log(outer);
// Local copies own their payload: a copied field read survives a later
// store to the field, and copies of other bindings keep scalar arms intact.
const keeper = new Node(40);
keeper.next = new Node(41);
let kept: Node | null = keeper.next;
keeper.next = null;
console.log("kept", describe(kept), keeper.next);
kept = keeper.next;
console.log("kept2", describe(kept));
function mixed(flag: number): string {
const first: string | number | undefined = flag > 1 ? "two" : flag > 0 ? 1 : undefined;
let copy = first;
let out = typeof copy === "string" ? copy.toUpperCase() : String(copy);
const second: string | number | undefined = flag > 0 ? -0 : `s${flag}`;
copy = second;
out += "|" + (typeof copy === "number" ? String(Object.is(copy, -0)) : copy);
copy = first;
return out + "|" + (copy ?? "none");
}
console.log("mixed", mixed(0), mixed(1), mixed(2));
// Cycles through nullable fields are collected; output stays stable.
let survivors = 0;
for (let i = 0; i < 20000; i++) {
const x = new Node(i);
const y = new Node(i + 1);
x.next = y;
y.next = x;
y.prev = x;
if (x.next.next === x) survivors++;
}
console.log("cycles", survivors);
// A long chain tears down without exhausting the native stack.
let head: Node | null = null;
for (let i = 0; i < 300000; i++) {
const n = new Node(i);
n.next = head;
head = n;
}
let length = 0;
for (let p: Node | null = head; p !== null; p = p.next) length++;
console.log("chain", length, head?.id, head?.next?.id);
head = null;
console.log("done", head);