Represent unions of classes as the instance pointer itself

A union of two or more class arms and at most two unit arms (`A | B`,
`A | B | undefined`, sum types over classes) now holds the instance
pointer instead of a reference-counted %ScrUnion box, as unions of
records already do. NULL and the null sentinel encode the unit arms.

The arm of a value is the arm whose preorder interval holds the
instance's class id. On 64-bit targets, hierarchy members already keep
their class id in the high half of the rc word, or in the 32-bit header
of ProgramLifetime classes. A standalone class that is an arm of such a
union now keeps its id the same way and counts in the low half; the
count reaches the immortal all-ones value instead of wrapping, as for
hierarchy members.

Each union has a tag helper that narrowing checks inline. Unions over
the same classes share one retain, release and trace: retain and
release use the shared 32-bit count, and only a release of the last
reference dispatches on the arm to that class's release. Unions over
ProgramLifetime classes retain and release nothing. When one arm
carries a collector header, every class of every arm's hierarchy does.

Arms may not extend one another, and must all be compact or all keep
the rc word, and all be ProgramLifetime classes or none. 32-bit
targets, library builds, lifetime-region programs, debug builds, and
unions used as Map keys or Set elements keep boxes.
This commit is contained in:
Chris Tate
2026-10-27 20:30:01 -05:00
parent a4a844f446
commit 1856713700
23 changed files with 1409 additions and 58 deletions
@@ -0,0 +1,142 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { F64, VOID, type IrModule, type IrType } from "../../ir/ir.js";
import { mangleClassNew, mangleClassRelease, mangleClassRetain } from "../mangle.js";
import { emitLlvmModule } from "./emitter.js";
import { classReleaseFn, classRetainFn, classTagFn } from "./nullable-unions.js";
const loc = { file: "class-unions.ts", start: 0, end: 1 };
const tree: IrType = { kind: "union", unionId: "tree" };
/** `Leaf | Pair`, two standalone classes, and a hierarchy beside them. */
function treeModule(): IrModule {
return {
irVersion: 17,
sourceFile: loc.file,
entry: "main",
functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }],
classes: [
{ name: "Leaf", fields: [{ name: "value", type: F64 }], methods: [], loc },
{
name: "Pair",
fields: [
{ name: "left", type: tree },
{ name: "right", type: tree },
],
methods: [],
loc,
},
{ name: "Other", fields: [{ name: "value", type: F64 }], methods: [], loc },
],
unions: [
{
id: "tree",
arms: [
{ kind: "object", className: "Leaf" },
{ kind: "object", className: "Pair" },
],
},
],
};
}
function definition(llvm: string, symbol: string): string {
const escaped = symbol.replace(/[.$]/g, "\\$&");
const body = new RegExp(`^define internal [^@]*@${escaped}\\([^]*?^}`, "m").exec(llvm)?.[0];
expect(body, symbol).toBeDefined();
return body!;
}
test("standalone arms keep their class id in the rc word's high half", () => {
const llvm = emitLlvmModule(treeModule(), { pointerBits: 64 });
// Arms store the id beside a count of 1; other standalone classes keep a
// plain count.
expect(definition(llvm, mangleClassNew("Leaf"))).toMatch(/store i64 \d{10,}, ptr %o\.vt/);
expect(definition(llvm, mangleClassNew("Other"))).toMatch(/store i64 1, ptr %o/);
expect(definition(llvm, mangleClassRetain("Leaf"))).toContain("load i32, ptr %o");
expect(definition(llvm, mangleClassRetain("Other"))).toContain("load i64, ptr %o");
// The tag reads the id at byte 4 and switches on it.
const tag = definition(llvm, classTagFn("tree").slice(1));
expect(tag).toContain("getelementptr inbounds i8, ptr %o, i64 4");
expect(tag).toMatch(/switch i32 %id/);
// 32-bit targets keep the box and the plain count.
const llvm32 = emitLlvmModule(treeModule(), { pointerBits: 32 });
expect(llvm32).not.toContain(classTagFn("tree"));
expect(definition(llvm32, mangleClassRetain("Leaf"))).toContain("load i32, ptr %o");
expect(definition(llvm32, mangleClassNew("Leaf"))).toMatch(/store i32 1, ptr %o/);
});
test("a tagged class's 32-bit count saturates at the immortal count instead of wrapping", () => {
const llvm = emitLlvmModule(treeModule(), { pointerBits: 64 });
// The class's and the union's retain and release, run on an rc word whose
// count is two below the immortal all-ones count.
const symbols = [
[mangleClassRetain("Leaf"), "retain_leaf"],
[mangleClassRelease("Leaf"), "release_leaf"],
[classRetainFn("tree").slice(1), "retain_tree"],
[classReleaseFn("tree").slice(1), "release_tree"],
] as const;
let ir = "";
for (const [symbol] of symbols) ir += `${definition(llvm, symbol)}\n\n`;
// A last reference never reaches these: the harness aborts if one does.
const last = [
[`${mangleClassRelease("Leaf")}_slow`, "last_leaf"],
[`${classReleaseFn("tree").slice(1)}.last`, "last_tree"],
] as const;
for (const [symbol, name] of [...symbols, ...last])
ir = ir.replaceAll(`@${symbol}(`, `@${name}(`);
ir = ir.replaceAll("define internal", "define").replaceAll(" #0 ", " ");
ir += `declare void @last_leaf(ptr)\ndeclare void @last_tree(ptr)\n`;
const dir = mkdtempSync(join(tmpdir(), "scriptc-class-unions-"));
try {
writeFileSync(join(dir, "rc.ll"), ir);
writeFileSync(
join(dir, "main.c"),
`#include <stdint.h>
#include <stdlib.h>
void *retain_leaf(void *);
void release_leaf(void *);
void *retain_tree(void *);
void release_tree(void *);
void last_leaf(void *o) { (void)o; abort(); }
void last_tree(void *o) { (void)o; abort(); }
static int check(void *(*retain)(void *), void (*release)(void *)) {
const uint64_t id = (uint64_t)42 << 32;
uint64_t word = id | 0xfffffffdu;
retain(&word);
if (word != (id | 0xfffffffeu)) return 1;
retain(&word);
if (word != (id | 0xffffffffu)) return 2;
retain(&word);
if (word != (id | 0xffffffffu)) return 3;
release(&word);
if (word != (id | 0xffffffffu)) return 4;
word = id | 3;
release(&word);
if (word != (id | 2)) return 5;
return 0;
}
int main(void) {
int leaf = check(retain_leaf, release_leaf);
if (leaf != 0) return leaf;
int tree = check(retain_tree, release_tree);
return tree == 0 ? 0 : 10 + tree;
}
`,
);
execFileSync("clang", [
"-Wno-override-module",
"-O1",
join(dir, "rc.ll"),
join(dir, "main.c"),
"-o",
join(dir, "rc"),
]);
execFileSync(join(dir, "rc"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,255 @@
/* Class-union helpers (nullable-unions.ts ClassUnion). A class union's value
* is the instance pointer; its arm is the arm whose preorder interval holds
* the instance's class id, which every arm's hierarchy keeps in its header
* (classes.ts LlClassMeta.idInRc). Each union gets a tag helper, and the
* unions over the same classes share one retain, release and trace. */
import { InternalCompilerError } from "../../errors.js";
import type { IrModule } from "../../ir/ir.js";
import { mangleClassRelease, mangleClassTrace } from "../mangle.js";
import {
classIdLoadLines,
programLifetimeClass,
type ClassHost,
type LlClassMeta,
} from "./classes.js";
import {
NULLABLE_NULL,
classReleaseFn,
classRetainFn,
classTagFn,
classTraceFn,
type ClassUnion,
} from "./nullable-unions.js";
import { FN_ATTRS, cycleRootLines, retainBody } from "./shapes.js";
/** The widest span of class ids a union's arms may cover for its helpers
* to switch on the id; wider unions test each arm's interval. */
const CLASS_TAG_SWITCH_SPAN = 32;
/** One arm's class ids: its class's preorder interval plus 1. */
interface ArmIds {
tag: number;
className: string;
lo: number;
hi: number;
}
function armIds(
union: ClassUnion,
classMeta: ReadonlyMap<string, LlClassMeta>,
): { arms: ArmIds[]; first: LlClassMeta } {
const arms = union.arms.map((arm) => {
const meta = classMeta.get(arm.className);
if (meta === undefined || !meta.idInRc)
throw new InternalCompilerError(
`llvm emitter bug: class-union arm ${arm.className} keeps no class id`,
);
return { tag: arm.tag, className: arm.className, lo: meta.pre + 1, hi: meta.post + 1 };
});
return { arms, first: classMeta.get(union.arms[0]!.className)! };
}
/** Lines that branch from the open block on the class id of the instance
* `%o`: to `%a<tag>` for the arm holding it and to `missing` for none. */
function armBranchLines(arms: readonly ArmIds[], first: LlClassMeta, missing: string): string[] {
const lines = classIdLoadLines(first, "%o", "%id", "");
const span = Math.max(...arms.map((arm) => arm.hi)) - Math.min(...arms.map((arm) => arm.lo)) + 1;
if (span <= CLASS_TAG_SWITCH_SPAN) {
const cases: string[] = [];
for (const arm of arms)
for (let id = arm.lo; id <= arm.hi; id++) cases.push(`i32 ${id}, label %a${arm.tag}`);
lines.push(` switch i32 %id, label %${missing} [ ${cases.join(" ")} ]`);
return lines;
}
arms.forEach((arm, i) => {
const next = i + 1 < arms.length ? `n${arm.tag}` : missing;
lines.push(
` %r${arm.tag} = sub i32 %id, ${arm.lo}`,
` %in${arm.tag} = icmp ult i32 %r${arm.tag}, ${arm.hi - arm.lo + 1}`,
` br i1 %in${arm.tag}, label %a${arm.tag}, label %${next}`,
);
if (next !== missing) lines.push(`${next}:`);
});
return lines;
}
/** Lines that leave for `unit` on NULL and for `nullArm` on the null
* sentinel, and continue in a new block for an instance. */
function unitLines(union: ClassUnion, unit: string, nullArm: string): string[] {
const lines = [
` %isnull = icmp eq ptr %o, null`,
` br i1 %isnull, label %${unit}, label %ref`,
`ref:`,
];
if (union.nullTag >= 0)
lines.push(
` %isnullarm = icmp eq ptr %o, ${NULLABLE_NULL}`,
` br i1 %isnullarm, label %${nullArm}, label %obj`,
`obj:`,
);
return lines;
}
/** A dispatch on the arm of `%o` that runs `call(className)` and returns. */
function armDispatchLines(
arms: readonly ArmIds[],
first: LlClassMeta,
call: (className: string) => string,
): string[] {
return [
...armBranchLines(arms, first, "done"),
...arms.flatMap((arm) => [`a${arm.tag}:`, call(arm.className), ` br label %done`]),
`done:`,
];
}
/** A class union's value is whichever instance it holds, and the collector
* visits it as that instance: when one arm carries a collector header,
* every arm must. Every class of a hierarchy shares its header decision, so
* a header reaches each arm's whole hierarchy, and unions that share a
* hierarchy pass headers on to each other: this repeats until no union
* adds one. ProgramLifetime classes carry none. */
export function classUnionHeaders(
mod: IrModule,
unions: readonly ClassUnion[],
tracedShapes: Set<string>,
tracedUnions: ReadonlySet<string>,
): void {
if (unions.length === 0) return;
const bases = new Map<string, string | undefined>();
for (const cls of mod.classes ?? []) bases.set(cls.name, cls.base);
const rootOf = (name: string): string => {
let root = name;
for (let base = bases.get(root); base !== undefined; base = bases.get(root)) root = base;
return root;
};
const members = new Map<string, string[]>();
for (const cls of mod.classes ?? []) {
const root = rootOf(cls.name);
const list = members.get(root);
if (list) list.push(cls.name);
else members.set(root, [cls.name]);
}
const lifetime = new Set(mod.contracts?.programLifetime ?? []);
for (let changed = true; changed;) {
changed = false;
for (const union of unions) {
if (lifetime.has(rootOf(union.arms[0]!.className))) continue;
const names = union.arms.flatMap((arm) => members.get(rootOf(arm.className)) ?? []);
if (
!tracedUnions.has(union.unionId) &&
!names.some((name) => tracedShapes.has(`object:${name}`))
)
continue;
for (const name of names) {
const key = `object:${name}`;
if (tracedShapes.has(key)) continue;
tracedShapes.add(key);
changed = true;
}
}
}
}
/** The tag helper of every class union, and the retain, release and trace
* of each one that owns them (NullableUnions.helperOwner).
* - Retain and release of unions over ProgramLifetime classes do nothing:
* those instances count nothing.
* - Otherwise every arm counts in the low half of its rc word (idInRc), so
* retain is the shared increment and release the shared decrement; only
* a release of the last reference dispatches on the arm, to the arm's
* own release (which tears the dynamic class down).
* - Trace dispatches on the arm. Every arm carries a collector header when
* one does (the emitter's traced fixpoint). */
export function emitClassUnionHelpers(
host: ClassHost,
classMeta: ReadonlyMap<string, LlClassMeta>,
): string[] {
const out: string[] = [];
for (const union of host.nullableUnions.allClassUnions()) {
const { arms, first } = armIds(union, classMeta);
out.push(
`define internal i32 ${classTagFn(union.unionId)}(ptr %o) alwaysinline ${FN_ATTRS} { ; class union tag`,
`entry:`,
...unitLines(union, "unit", "nullarm"),
...armBranchLines(arms, first, "bad"),
...arms.flatMap((arm) => [`a${arm.tag}:`, ` ret i32 ${arm.tag}`]),
`unit:`,
` ret i32 ${union.unitTag}`,
...(union.nullTag >= 0 ? [`nullarm:`, ` ret i32 ${union.nullTag}`] : []),
`bad:`,
` ret i32 -1`,
`}`,
``,
);
if (host.nullableUnions.helperOwner(union.unionId) !== union.unionId) continue;
const retain = classRetainFn(union.unionId).slice(1);
const release = classReleaseFn(union.unionId);
if (programLifetimeClass(host, first)) {
out.push(
`define internal ptr @${retain}(ptr %o) ${FN_ATTRS} { ; class union retain (immortal)`,
`entry:`,
` ret ptr %o`,
`}`,
``,
`define internal void ${release}(ptr %o) ${FN_ATTRS} { ; class union release (immortal)`,
`entry:`,
` ret void`,
`}`,
``,
);
continue;
}
const traced = host.tracedShapes.has(`object:${first.def.name}`);
out.push(
...retainBody(host, retain, "class union retain", false, "i32"),
``,
`define internal void ${release}(ptr %o) ${FN_ATTRS} { ; class union release (fast path)`,
`entry:`,
...unitLines(union, "done", "done"),
` %rc = load i32, ptr %o`,
` %rcm2 = sub i32 %rc, 2`,
` %shared = icmp ult i32 %rcm2, -3`,
` br i1 %shared, label %dec, label %slow`,
`dec:`,
` %n = sub i32 %rc, 1`,
` store i32 %n, ptr %o`,
...(traced ? cycleRootLines(host, "done") : [` br label %done`]),
`slow:`,
` %imm = icmp eq i32 %rc, -1`,
` br i1 %imm, label %done, label %last`,
`last:`,
` call void ${release}.last(ptr %o)`,
` br label %done`,
`done:`,
` ret void`,
`}`,
``,
`define internal void ${release}.last(ptr %o) noinline ${FN_ATTRS} { ; class union release (last reference)`,
`entry:`,
...armDispatchLines(
arms,
first,
(name) => ` call void @${mangleClassRelease(name)}(ptr %o)`,
),
` ret void`,
`}`,
``,
);
if (traced)
out.push(
`define internal void ${classTraceFn(union.unionId)}(ptr %o, ptr %visit, ptr %ctx) ${FN_ATTRS} { ; class union trace`,
`entry:`,
...unitLines(union, "done", "done"),
...armDispatchLines(
arms,
first,
(name) => ` call void @${mangleClassTrace(name)}(ptr %o, ptr %visit, ptr %ctx)`,
),
` ret void`,
`}`,
``,
);
}
return out;
}
+23 -6
View File
@@ -100,7 +100,9 @@ export class LlClassMeta {
* a vtable word, the rc word holds the class id (classIdRcWord) in its
* high 32 bits (CLASS_ID_OFFSET) and the reference count in its low 32
* bits, and the hierarchy's vtables are one array indexed by the id
* (mangleVtArray). */
* (mangleVtArray). A standalone class that is an arm of a class union
* (nullable-unions.ts taggedClasses) keeps its id the same way, without
* vtables. */
idInRc = false;
/** A ProgramLifetime class with the 32-bit header (assignCompactLayouts):
* the i32 at offset 0 is compactHeaderWord, with the class id in place of
@@ -131,6 +133,7 @@ export function buildClassGraph(
mod: IrModule,
fnByName: Map<string, IrFunction>,
sizeType: "i32" | "i64" = "i64",
tagged: ReadonlySet<string> = new Set(),
): Map<string, LlClassMeta> {
const metaMap = new Map<string, LlClassMeta>();
for (const cls of mod.classes ?? []) {
@@ -161,7 +164,10 @@ export function buildClassGraph(
// EventEmitter, EventTarget, Event and the streams) and 32-bit targets
// have no spare half in the rc word: those keep the vtable word.
for (const meta of metaMap.values())
meta.idInRc = sizeType === "i64" && meta.hierarchy && meta.root.def.runtime !== true;
meta.idInRc =
sizeType === "i64" &&
(meta.hierarchy || tagged.has(meta.def.name)) &&
meta.root.def.runtime !== true;
const slotRoots = new Set<LlClassMeta>();
for (const key of mod.threadSlotKeys ?? []) {
const meta = metaMap.get(key);
@@ -628,10 +634,20 @@ export function classIdRcWord(meta: LlClassMeta, lifetime: boolean): string {
return (BigInt(meta.pre + 1) * 2n ** 32n + (lifetime ? 2n ** 31n : 1n)).toString();
}
/** The rc word of an immortal LlClassMeta.idInRc instance (a frame
* object): the class id in the high half and the immortal count, all ones,
* in the low half, which scr_rc_immortal reads as immortal. */
export function classIdImmortalRcWord(meta: LlClassMeta): string {
if (meta.pre + 1 >= LAYOUT_ID_LIMIT)
throw new InternalCompilerError(`class id ${meta.pre + 1} is not a layout id`);
return (BigInt(meta.pre + 1) * 2n ** 32n + 0xffffffffn).toString();
}
/** The 32-bit header of a compact class instance (LlClassMeta.compact):
* COMPACT_HEADER_TAG, the class id (classIdRcWord's preorder number + 1, 0
* outside a hierarchy) in the low 28 bits, and COMPACT_PUBLISHED once
* publication marks the object. No other object's count reaches
* for a class without LlClassMeta.idInRc) in the low 28 bits, and
* COMPACT_PUBLISHED once publication marks the object. No other object's
* count reaches
* 0x70000000, so the runtime reads a word of 0xf0000000 and up as published
* for every object (scr_rc.h SCR_RC_PUBLISHED). */
export const COMPACT_HEADER_TAG = 0x70000000;
@@ -650,7 +666,7 @@ export function storedHeaderBytes(meta: LlClassMeta): number {
/** The header word a compact instance is created with. */
export function compactHeaderWord(meta: LlClassMeta): number {
return COMPACT_HEADER_TAG | (meta.hierarchy ? meta.pre + 1 : 0);
return COMPACT_HEADER_TAG | (meta.idInRc ? meta.pre + 1 : 0);
}
/** Lines that decode the 32-bit reference `idx` (an i32) of a compressed
@@ -1024,7 +1040,7 @@ export function emitClassShapes(
// instance is the element at its number minus the root's.
for (const name of roots) {
const root = metaMap.get(name)!;
if (!root.idInRc) continue;
if (!root.hierarchy || !root.idInRc) continue;
const members = [...metaMap.values()]
.filter((m) => m.root === root)
.sort((a, b) => a.pre - b.pre);
@@ -1395,6 +1411,7 @@ export function emitClassShapes(
count,
)
: null,
count,
),
``,
);
+30 -13
View File
@@ -364,7 +364,15 @@ import { VirtualBorrows } from "./virtual-borrows.js";
import { planRecordViews, recordViewFieldPtr } from "./record-views.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import { LlWalkers } from "./walkers.js";
import { isObjectArm, NULLABLE_ABSENT, NULLABLE_NULL, NullableUnions } from "./nullable-unions.js";
import {
isObjectArm,
isPayloadTag,
NULLABLE_ABSENT,
NULLABLE_NULL,
NullableUnions,
pointerTagFn,
} from "./nullable-unions.js";
import { classUnionHeaders, emitClassUnionHelpers } from "./class-unions.js";
import { emitNullablePresent, emitUnionPeek, emitUnionTag } from "./union-repr.js";
import {
emitConcatInputs,
@@ -2039,6 +2047,7 @@ export class LlEmitter {
mod,
options.debugSources === undefined,
this.nodeIndex,
options.pointerBits !== 32,
);
this.heapCensus = heapCensusOf(mod, this.nullableUnions);
for (const decl of this.heapCensus?.declarations() ?? []) this.declare(decl);
@@ -2323,6 +2332,13 @@ export class LlEmitter {
}
}
}
// The same holds for a class union's arms (class-unions.ts).
classUnionHeaders(
mod,
this.nullableUnions.allClassUnions(),
this.tracedShapes,
this.tracedUnions,
);
this.shapeHost = {
// Journal hooks only on emitters that can emit ranges (chunk
// emitters, and emitters whose workers take a journal after the
@@ -2488,7 +2504,12 @@ export class LlEmitter {
// The class graph: base/children links, hierarchy membership, the
// whole-program preorder numbering for instanceof over runtime and
// compiled error objects, and the per-hierarchy virtual slot lists.
this.classMeta = buildClassGraph(mod, this.fnByName, this.sizeType);
this.classMeta = buildClassGraph(
mod,
this.fnByName,
this.sizeType,
this.nullableUnions.taggedClasses(),
);
if (mod.lifetimeRegions === true) {
this.regions = new LifetimeRegions(
this.shapeHost,
@@ -2756,6 +2777,7 @@ export class LlEmitter {
...late,
...emitNullableRetainWrappers(this.shapeHost),
...emitShapeUnionHelpers(this.shapeHost),
...emitClassUnionHelpers(this.shapeHost, this.classMeta),
...emitInlineRcHelpers(this.shapeHost),
];
}
@@ -4702,11 +4724,7 @@ export class LlEmitter {
const nullable = this.nullableUnions.get(unionId);
if (nullable) {
// The owned payload IS the nullable union's value.
if (
nullable.repr === "single"
? tag !== nullable.refTag
: !nullable.arms.some((arm) => arm.tag === tag)
)
if (!isPayloadTag(nullable, tag))
throw new InternalCompilerError(
`llvm emitter bug: payload wrap into unit arm ${tag} of ${unionId}`,
);
@@ -7148,7 +7166,10 @@ export class LlEmitter {
const nullable = this.nullableUnions.of(t);
return nullable?.repr === "single" ? nullable.arm : null;
},
shapeUnion: (t) => this.nullableUnions.of(t)?.repr === "shapes",
pointerTagFn: (t) => {
const nullable = this.nullableUnions.of(t);
return nullable === null || nullable.repr === "single" ? null : pointerTagFn(nullable);
},
fieldBase: (shapeId) => this.nullableUnions.fieldBase(shapeId),
classInfo: (className) => {
const meta = this.classMetaOf(className);
@@ -7656,11 +7677,7 @@ export class LlEmitter {
const nullable = this.nullableUnions.get(e.unionId);
if (!nullable) return null;
if (isUnitType(e.value.type)) return e.value.kind === "unitLit" ? "unit" : null;
const refArm =
nullable.repr === "single"
? e.tag === nullable.refTag
: nullable.arms.some((arm) => arm.tag === e.tag);
return refArm && isRefCounted(e.value.type) ? "ref" : null;
return isPayloadTag(nullable, e.tag) && isRefCounted(e.value.type) ? "ref" : null;
}
materializeSplitLocal(localId: string): void {
@@ -74,13 +74,13 @@ export function emitUnionRetype(
const to = host.nullableUnions.get(e.unionId);
if (from && to) {
// Equal encodings make the pointer itself the target value. A shape
// union derives its record arms from the record, so only the unit
// encodings must agree.
// union derives its record arms from the record and a class union its
// class arms from the class id, so only the unit encodings must agree.
const same =
from.repr === "single" && to.repr === "single"
? from.refTag === to.refTag && from.unitTag === to.unitTag && from.nullTag === to.nullTag
: from.repr === "shapes" &&
to.repr === "shapes" &&
: from.repr !== "single" &&
from.repr === to.repr &&
from.unitTag === to.unitTag &&
from.nullTag === to.nullTag;
if (same) return host.own({ name: value.name, type: e.type });
@@ -3,6 +3,7 @@ import {
F64,
NULL_T,
UNDEFINED_T,
type IrClassDef,
type IrModule,
type IrType,
type IrUnionDef,
@@ -146,3 +147,73 @@ test("typed array arms are nullable pointers compared by identity", () => {
expect(isObjectArm(unions.single("iu")!)).toBe(true);
expect(isPointerIdentityArm(unions.single("iu")!)).toBe(true);
});
test("unions of two or more classes become class unions", () => {
const cls = (className: string): IrType => ({ kind: "object", className });
const def = (name: string, base?: string, runtime?: true): IrClassDef => ({
name,
fields: [],
loc,
...(base === undefined ? {} : { base }),
...(runtime === undefined ? {} : { runtime }),
});
const classes = [
def("%Error", undefined, true),
def("%TypeError", "%Error", true),
def("Leaf"),
def("Pair"),
def("Node"),
def("Name", "Node"),
def("Call", "Node"),
def("Sym"),
def("Type"),
];
const unions = [
{ id: "lp", arms: [cls("Leaf"), cls("Pair")] },
{ id: "lpu", arms: [cls("Leaf"), UNDEFINED_T, cls("Pair")] },
{ id: "lpnu", arms: [NULL_T, cls("Leaf"), cls("Pair"), UNDEFINED_T] },
{ id: "siblings", arms: [cls("Name"), cls("Call")] },
{ id: "target", arms: [cls("Sym"), cls("Node"), cls("Type")] },
{ id: "ancestor", arms: [cls("Node"), cls("Name")] },
{ id: "number", arms: [cls("Leaf"), F64] },
{ id: "error", arms: [cls("Leaf"), cls("%TypeError")] },
{ id: "mixed", arms: [cls("Leaf"), cls("Sym")] },
{ id: "rp", arms: [cls("Pair"), cls("Leaf")] },
];
const lifetime = { contracts: { programLifetime: ["Sym", "Type", "Node"] } };
const nullable = new NullableUnions(module(unions, { classes, ...lifetime }));
expect(nullable.classes("lp")).toMatchObject({ unitTag: -1, nullTag: -1 });
expect(nullable.classes("lpu")).toMatchObject({ unitTag: 1, nullTag: -1 });
expect(nullable.classes("lpnu")).toMatchObject({ unitTag: 3, nullTag: 0 });
expect(nullable.classes("lpnu")?.arms).toEqual([
{ tag: 1, className: "Leaf" },
{ tag: 2, className: "Pair" },
]);
expect(nullable.classes("siblings")).not.toBeNull();
expect(nullable.classes("target")).not.toBeNull();
// An arm that extends another, a scalar arm, a runtime-rooted arm, and
// arms of which only some are ProgramLifetime keep the box.
expect(nullable.get("ancestor")).toBeNull();
expect(nullable.get("number")).toBeNull();
expect(nullable.get("error")).toBeNull();
expect(nullable.get("mixed")).toBeNull();
// Standalone arms keep their class id only because they are arms.
expect([...nullable.taggedClasses()].sort()).toEqual(["Leaf", "Pair", "Sym", "Type"]);
expect(nullable.helperOwner("rp")).toBe("lp");
expect(nullable.helperOwner("lpu")).toBe("lp");
expect(nullable.allClassUnions().map((u) => u.unionId)).toEqual([
"lp",
"lpu",
"lpnu",
"siblings",
"target",
"rp",
]);
const plain = { classes, ...lifetime };
// 32-bit targets, debug builds and library builds keep boxes.
expect(new NullableUnions(module(unions, plain), true, undefined, false).get("lp")).toBeNull();
expect(new NullableUnions(module(unions, plain), false).get("lp")).toBeNull();
expect(
new NullableUnions(module(unions, plain), true, undefined, false).taggedClasses().size,
).toBe(0);
});
@@ -54,8 +54,31 @@ export interface ShapeUnion {
arms: readonly { tag: number; shapeId: string }[];
}
/** A union of two or more class arms and at most two unit arms (`A | B`,
* `A | B | undefined`, sum types over classes) whose VALUES are the
* instance pointer itself, like a ShapeUnion: NULL is the unit arm
* (`undefined` when both exist, NULLABLE_NULL is then `null`). No arm is
* an ancestor of another, so the arm of an instance is the arm whose
* preorder interval holds its class id: every instance of an arm's
* hierarchy keeps its class id in its header (classes.ts LlClassMeta.idInRc
* and compactHeaderWord), which standalone arm classes do only because they
* are arms (taggedClasses). The arms are all compact classes or all keep
* the rc word, so one load reads the id of any value (the per-union
* `@sc_ctag.<id>` helper). */
export interface ClassUnion {
repr: "classes";
unionId: string;
/** The unit arm NULL encodes; -1 when the union has no unit arm. */
unitTag: number;
/** The `null` arm of a two-unit-arm union, encoded by NULLABLE_NULL;
* -1 otherwise. */
nullTag: number;
/** The class arms in tag order. */
arms: readonly { tag: number; className: string }[];
}
/** A union whose values are plain pointers (no `%ScrUnion` box). */
export type PointerUnion = NullableUnion | ShapeUnion;
export type PointerUnion = NullableUnion | ShapeUnion | ClassUnion;
/** The struct index of a tagged record shape's hidden shape-number word. */
export const SHAPE_WORD_INDEX = 1;
@@ -79,6 +102,27 @@ export function shapeTraceFn(unionId: string): string {
return `@sc_strace.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`;
}
/** The per-union helper mapping a class-union value to its tag. */
export function classTagFn(unionId: string): string {
return `@sc_ctag.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`;
}
/** The per-union retain, release and trace entry points of a class union. */
export function classRetainFn(unionId: string): string {
return `@sc_cretain.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`;
}
export function classReleaseFn(unionId: string): string {
return `@sc_crelease.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`;
}
export function classTraceFn(unionId: string): string {
return `@sc_ctrace.${unionId.replace(/[^A-Za-z0-9_.]/g, "_")}`;
}
/** The helper mapping a shape- or class-union value to its tag. */
export function pointerTagFn(union: ShapeUnion | ClassUnion): string {
return union.repr === "shapes" ? shapeTagFn(union.unionId) : classTagFn(union.unionId);
}
/** The module-scope immortal sentinel standing for `null` in a
* `C | null | undefined` union (NULL is `undefined` there). */
export const NULLABLE_NULL = "@sc_nullable_null";
@@ -102,17 +146,25 @@ export type NullableRefArm =
/** The arm is a JS object: always truthy. */
export function isObjectArm(nullable: PointerUnion): boolean {
return nullable.repr === "shapes" || nullable.arm.kind !== "string";
return nullable.repr !== "single" || nullable.arm.kind !== "string";
}
/** The arm compares by pointer identity. Function arms compare identity
* roots instead: a signature adapter is the function it adapts. */
export function isPointerIdentityArm(nullable: PointerUnion): boolean {
return (
nullable.repr === "shapes" || (nullable.arm.kind !== "string" && nullable.arm.kind !== "func")
nullable.repr !== "single" || (nullable.arm.kind !== "string" && nullable.arm.kind !== "func")
);
}
/** The arm `tag` of a pointer union holds a reference: the value is that
* payload itself (not a unit encoding). */
export function isPayloadTag(nullable: PointerUnion, tag: number): boolean {
if (nullable.repr === "single") return tag === nullable.refTag;
if (nullable.repr === "shapes") return nullable.arms.some((arm) => arm.tag === tag);
return nullable.arms.some((arm) => arm.tag === tag);
}
/** The representation decision for every union of one module. It is a
* pure function of the module, so serialized-IR and in-memory emission
* agree. */
@@ -128,12 +180,18 @@ export class NullableUnions {
/** Some nullable union encodes `null` with NULLABLE_NULL. */
readonly usesNullSentinel: boolean = false;
/** The shape union whose release and trace helpers each shape union
* uses: the first one over the same record arms. */
* uses: the first one over the same record arms; likewise for class
* unions over the same classes. */
private readonly helperOwners = new Map<string, string>();
/** The standalone classes that are arms of a class union, which keep
* their class id in their header only for that (taggedClasses). */
private readonly tagged = new Set<string>();
/** `shapes`: allow shape unions (debug builds keep boxes, whose
* debug-info layouts describe every arm). */
constructor(mod: IrModule, shapes = true, nodes?: IrNodeIndex) {
/** `shapes`: allow shape and class unions (debug builds keep boxes,
* whose debug-info layouts describe every arm). `classIds`: the target
* keeps class ids in instance headers (64-bit targets), which class
* unions read their arm from. */
constructor(mod: IrModule, shapes = true, nodes?: IrNodeIndex, classIds = true) {
const emitted = new Set<string>();
for (const cls of mod.classes ?? []) if (!cls.runtime) emitted.add(cls.name);
const records = new Set<string>();
@@ -143,6 +201,7 @@ export class NullableUnions {
// boxes) and of programs with lifetime regions (whose walks report a
// reference slot by its type's release entry point).
const allowShapes = shapes && mod.lib === undefined && mod.lifetimeRegions !== true;
const classes = allowShapes && classIds ? new ClassForest(mod) : null;
// A record that is an arm of a boxed union never becomes a shape
// union's arm: conversions between unions over the same records then
// keep one representation.
@@ -154,7 +213,8 @@ export class NullableUnions {
if (boxed.has(def.id)) continue;
const nullable =
nullableShape(def, emitted, records) ??
(allowShapes ? shapeUnion(def, records, boxedRecords) : null);
(allowShapes ? shapeUnion(def, records, boxedRecords) : null) ??
(classes !== null ? classUnion(def, classes) : null);
if (!nullable) continue;
this.byId.set(def.id, nullable);
if (nullable.nullTag >= 0) this.usesNullSentinel = true;
@@ -162,22 +222,34 @@ export class NullableUnions {
for (const arm of nullable.arms)
if (!this.shapeNumbers.has(arm.shapeId))
this.shapeNumbers.set(arm.shapeId, this.shapeNumbers.size + 2);
if (nullable.repr === "classes")
for (const arm of nullable.arms)
if (classes!.standalone(arm.className)) this.tagged.add(arm.className);
}
// Release and trace dispatch on the shape word, which names a record
// shape module-wide, so unions over the same records share them.
// shape module-wide, so unions over the same records share them; class
// unions dispatch on the class id, so the same holds for their arms.
const owners = new Map<string, string>();
for (const nullable of this.byId.values()) {
if (nullable.repr !== "shapes") continue;
const key = nullable.arms
.map((arm) => arm.shapeId)
.sort()
.join(" ");
if (nullable.repr === "single") continue;
const names =
nullable.repr === "shapes"
? nullable.arms.map((arm) => arm.shapeId)
: nullable.arms.map((arm) => arm.className);
const key = `${nullable.repr}:${names.sort().join(" ")}`;
const owner = owners.get(key) ?? nullable.unionId;
owners.set(key, owner);
this.helperOwners.set(nullable.unionId, owner);
}
}
/** Standalone classes (no base, no subclass) that are arms of a class
* union: their instances keep their class id in their header like
* hierarchy members do (classes.ts buildClassGraph). */
taggedClasses(): ReadonlySet<string> {
return this.tagged;
}
/** The shape union whose release and trace helpers this one uses. */
helperOwner(unionId: string): string {
return this.helperOwners.get(unionId) ?? unionId;
@@ -203,6 +275,12 @@ export class NullableUnions {
return nullable?.repr === "shapes" ? nullable : null;
}
/** The class-union form only. */
classes(unionId: string): ClassUnion | null {
const nullable = this.byId.get(unionId);
return nullable?.repr === "classes" ? nullable : null;
}
/** Every shape union, in module order. */
allShapeUnions(): ShapeUnion[] {
const out: ShapeUnion[] = [];
@@ -210,6 +288,13 @@ export class NullableUnions {
return out;
}
/** Every class union, in module order. */
allClassUnions(): ClassUnion[] {
const out: ClassUnion[] = [];
for (const nullable of this.byId.values()) if (nullable.repr === "classes") out.push(nullable);
return out;
}
/** The module-wide number a tagged record shape stores in its shape
* word, or 0 for shapes that carry none. */
shapeNumber(shapeId: string): number {
@@ -312,6 +397,107 @@ function shapeUnion(
return { repr: "shapes", unionId: def.id, unitTag, nullTag, arms };
}
/** The class forest of a module, by base links: what classUnion needs
* before the emitter builds its class graph. */
class ClassForest {
private readonly byName = new Map<string, IrClassDef>();
private readonly hasChildren = new Set<string>();
private readonly lifetimeRoots: ReadonlySet<string>;
private readonly slotRoots = new Set<string>();
constructor(mod: IrModule) {
for (const cls of mod.classes ?? []) {
this.byName.set(cls.name, cls);
if (cls.base !== undefined) this.hasChildren.add(cls.base);
}
this.lifetimeRoots = new Set(mod.contracts?.programLifetime ?? []);
for (const key of mod.threadSlotKeys ?? []) {
const root = this.root(key);
if (root !== undefined) this.slotRoots.add(root.name);
}
}
root(className: string): IrClassDef | undefined {
let cls = this.byName.get(className);
while (cls?.base !== undefined) cls = this.byName.get(cls.base);
return cls;
}
/** `ancestor` is `className` or one of its bases. */
extends(className: string, ancestor: string): boolean {
let cls = this.byName.get(className);
while (cls !== undefined) {
if (cls.name === ancestor) return true;
cls = cls.base === undefined ? undefined : this.byName.get(cls.base);
}
return false;
}
standalone(className: string): boolean {
return this.byName.get(className)?.base === undefined && !this.hasChildren.has(className);
}
/** The class is in a ProgramLifetime hierarchy, whose instances count
* nothing. */
lifetime(className: string): boolean {
const root = this.root(className);
return root !== undefined && this.lifetimeRoots.has(root.name);
}
/** Instances of the class keep their class id in a compact header
* (classes.ts assignCompactLayouts): ProgramLifetime hierarchies without
* a slot word. */
compact(className: string): boolean {
return this.lifetime(className) && !this.slotRoots.has(this.root(className)!.name);
}
/** An emitted class in a program-rooted hierarchy that is no Array
* subclass: one whose instances can carry a class id. */
identifiable(className: string): boolean {
const cls = this.byName.get(className);
const root = this.root(className);
return (
cls !== undefined &&
cls.runtime !== true &&
root !== undefined &&
root.runtime !== true &&
root.arrayElem === undefined
);
}
}
/** Two or more class arms none of which extends another, all compact or
* all keeping the rc word, all ProgramLifetime classes or none, and at most
* two unit arms (both: undefined and null). */
function classUnion(def: IrUnionDef, classes: ClassForest): ClassUnion | null {
const units = def.arms.filter(isUnitType).length;
if (units > 2 || def.arms.length - units < 2) return null;
const arms: { tag: number; className: string }[] = [];
for (let tag = 0; tag < def.arms.length; tag++) {
const arm = def.arms[tag]!;
if (isUnitType(arm)) continue;
if (arm.kind !== "object" || !classes.identifiable(arm.className)) return null;
arms.push({ tag, className: arm.className });
}
const compact = classes.compact(arms[0]!.className);
const lifetime = classes.lifetime(arms[0]!.className);
for (const arm of arms) {
if (classes.compact(arm.className) !== compact) return null;
if (classes.lifetime(arm.className) !== lifetime) return null;
for (const other of arms)
if (other !== arm && classes.extends(arm.className, other.className)) return null;
}
let unitTag = -1,
nullTag = -1;
if (units === 1) unitTag = def.arms.findIndex(isUnitType);
else if (units === 2) {
unitTag = def.arms.findIndex((arm) => arm.kind === "undefinedT");
nullTag = def.arms.findIndex((arm) => arm.kind === "nullT");
if (unitTag < 0 || nullTag < 0) return null;
}
return { repr: "classes", unionId: def.id, unitTag, nullTag, arms };
}
/** Unions the runtime itself inspects as tagged boxes keep that
* representation: Map keys and Set elements hash and compare union keys by
* tag and payload inside the C map. A union of records alone is the
@@ -26,7 +26,6 @@ import { mapOf, STRING, typeKey } from "../../ir/ir.js";
import { mangleClassStruct, mangleRecordStruct } from "../mangle.js";
import { FN_ATTRS } from "./shapes.js";
import { COMPACT_ID_MASK, compactRefLoadLines } from "./classes.js";
import { shapeTagFn } from "./nullable-unions.js";
import type { InlineUnionFields } from "./inline-unions.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -53,8 +52,9 @@ export interface PublishHost {
readonly inlineUnions: InlineUnionFields;
/** The nullable-pointer representation of a union, when it has one. */
nullableArm(t: IrType): IrType | null;
/** The union's values are tagged record pointers (nullable-unions.ts). */
shapeUnion(t: IrType): boolean;
/** The tag helper of a union whose values are tagged record pointers or
* class instance pointers (nullable-unions.ts), or null. */
pointerTagFn(t: IrType): string | null;
/** The struct index of a record shape's first field. */
fieldBase(shapeId: string): number;
/** Class layout: hierarchy membership, preorder interval, field slots
@@ -258,12 +258,13 @@ export class PublishWalkers {
? []
: [{ tag: a.tag, fn: a.fn, compact: this.compactRef(def.arms[a.tag]!) }],
);
if (this.host.shapeUnion(t)) {
// The value is the record itself (or a unit encoding): no box to
// mark; the arm's walker marks and walks the record.
const tagFn = this.host.pointerTagFn(t);
if (tagFn !== null) {
// The value is the record or instance itself (or a unit
// encoding): no box to mark; the arm's walker marks and walks it.
const lines = [head, `entry:`];
lines.push(
` %tag = call i32 ${shapeTagFn(t.unionId)}(ptr %o)`,
` %tag = call i32 ${tagFn}(ptr %o)`,
` switch i32 %tag, label %done [ ${live.map((a) => `i32 ${a.tag}, label %arm${a.tag}`).join(" ")} ]`,
);
for (const a of live)
+22 -2
View File
@@ -47,10 +47,14 @@ import { SCR_ARR_FIELD, llvmCommentText } from "./common.js";
import {
NULLABLE_NULL,
SHAPE_WORD_INDEX,
classReleaseFn,
classRetainFn,
classTraceFn,
shapeReleaseFn,
shapeTagFn,
shapeTraceFn,
shapeWordHeader,
type ClassUnion,
type NullableUnions,
type ShapeUnion,
} from "./nullable-unions.js";
@@ -257,6 +261,7 @@ export { computeTraced } from "../cycle-analysis.js";
export function vAdapters(host: ShapeHost, t: IrType): { retain: string; release: string } {
const nullable = host.nullableUnions.of(t);
if (nullable?.repr === "shapes") return shapeUnionRc(host, nullable);
if (nullable?.repr === "classes") return classUnionRc(host, nullable);
if (nullable) {
const arm = vAdapters(host, nullable.arm);
return { retain: nullableRetainSym(host, nullable.arm), release: arm.release };
@@ -314,6 +319,7 @@ export function retainSym(host: ShapeHost, t: IrType): string {
if (t.kind === "jsval") return jsvalRcSym(host, "retain");
const nullable = host.nullableUnions.of(t);
if (nullable?.repr === "shapes") return shapeUnionRc(host, nullable).retain;
if (nullable?.repr === "classes") return classUnionRc(host, nullable).retain;
if (nullable) return nullableRetainSym(host, nullable.arm);
const stem = runtimeRcStem(t);
if (host.rcHelpers !== null && stem !== null && inlineRcFamily(host, stem, "retain") !== null) {
@@ -335,6 +341,13 @@ function shapeUnionRc(host: ShapeHost, union: ShapeUnion): { retain: string; rel
};
}
/** A class union's RC entry points (class-unions.ts), shared by the unions
* over the same classes. */
function classUnionRc(host: ShapeHost, union: ClassUnion): { retain: string; release: string } {
const owner = host.nullableUnions.helperOwner(union.unionId);
return { retain: classRetainFn(owner), release: classReleaseFn(owner) };
}
/** The widest span of shape numbers a union's arms may cover for its tag
* helper to switch on the shape word (emitShapeUnionHelpers). */
const SHAPE_TAG_SWITCH_SPAN = 32;
@@ -552,6 +565,7 @@ export function releaseSym(host: ShapeHost, t: IrType): string {
if (t.kind === "jsval") return jsvalRcSym(host, "release");
const nullable = host.nullableUnions.of(t);
if (nullable?.repr === "shapes") return shapeUnionRc(host, nullable).release;
if (nullable?.repr === "classes") return classUnionRc(host, nullable).release;
if (nullable) return releaseSym(host, nullable.arm);
const stem = runtimeRcStem(t);
if (host.rcHelpers !== null && stem !== null && inlineRcFamily(host, stem, "release") !== null) {
@@ -1111,6 +1125,11 @@ export function traceAdapter(host: ShapeHost, t: IrType): string | null {
return shapeUnionTraced(host, nullable)
? shapeTraceFn(host.nullableUnions.helperOwner(nullable.unionId))
: null;
// Every arm of a class union carries a collector header when one does.
if (nullable?.repr === "classes")
return host.tracedShapes.has(`object:${nullable.arms[0]!.className}`)
? classTraceFn(host.nullableUnions.helperOwner(nullable.unionId))
: null;
if (nullable) return traceAdapter(host, nullable.arm);
if (!host.tracedUnions.has(t.unionId)) return null;
host.declare(`declare void @scr_union_trace_v(ptr, ptr, ptr)`);
@@ -1754,7 +1773,7 @@ function directDestroyKey(host: ShapeHost, type: IrType): string | null {
const nullable = host.nullableUnions.of(type);
let t = type;
if (nullable !== null) {
if (nullable.repr === "shapes") return null;
if (nullable.repr !== "single") return null;
t = nullable.arm;
}
if (t.kind === "record") return `record:${t.shapeId}`;
@@ -1977,8 +1996,9 @@ export function releaseBody(
segmented = false,
frozen = false,
destroyDefs: readonly string[] | null = null,
count: RcCount = host.sizeType,
): string[] {
const S = host.sizeType;
const S = count;
if (bounded) host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
const destroy = `${fnName}_destroy`;
// A freezable record's count is the word without its frozen bit.
@@ -16,7 +16,7 @@ import {
} from "./record-results.js";
import { everyStmtList } from "../../ir/traverse.js";
import { mangleClassStruct, mangleRecordStruct } from "../mangle.js";
import { classFieldIndex, classUndefinedFieldInits } from "./classes.js";
import { classFieldIndex, classIdImmortalRcWord, classUndefinedFieldInits } from "./classes.js";
import { CYC_BUF, CYC_HDR } from "./alloc.js";
import type { CallLifetimes } from "./call-lifetimes.js";
import { emitCallExpr, emitConstruction } from "./expr-calls.js";
@@ -396,7 +396,10 @@ export function emitStackObject(
B.line(line.trim());
}
}
B.line(`store ${host.sizeType} -1, ptr ${slot}`);
const tagged = type.kind === "object" ? host.classMeta.get(type.className) : undefined;
// A class-union arm keeps its class id beside the immortal count.
if (tagged?.idInRc === true) B.line(`store i64 ${classIdImmortalRcWord(tagged)}, ptr ${slot}`);
else B.line(`store ${host.sizeType} -1, ptr ${slot}`);
if (type.kind === "record") {
// A tagged shape's word names it to every shape union's tag read.
const shapeNumber = host.nullableUnions.shapeNumber(type.shapeId);
@@ -3,10 +3,11 @@
* nullable union (nullable-unions.ts) is the arm pointer itself: NULL for
* its NULL-encoded unit arm and, in `C | null | undefined`, the immortal
* NULLABLE_NULL sentinel for `null`. A shape union's value is likewise the
* record pointer; its tag comes from the record's shape word. Every structural read of a union value
* goes through here. */
* record pointer; its tag comes from the record's shape word. A class
* union's value is the instance pointer; its tag comes from the class id.
* Every structural read of a union value goes through here. */
import type { BlockBuilder } from "./blocks.js";
import { NULLABLE_NULL, shapeTagFn, type PointerUnion } from "./nullable-unions.js";
import { NULLABLE_NULL, pointerTagFn, type PointerUnion } from "./nullable-unions.js";
/** The i32 tag of a union value. `alias` is the tag word's alias
* attachment (", !tbaa !N"), when the caller has one. */
@@ -17,8 +18,8 @@ export function emitUnionTag(
alias = "",
): string {
const t = B.tmp();
if (nullable?.repr === "shapes") {
B.line(`${t} = call i32 ${shapeTagFn(nullable.unionId)}(ptr ${u})`);
if (nullable !== null && nullable.repr !== "single") {
B.line(`${t} = call i32 ${pointerTagFn(nullable)}(ptr ${u})`);
return t;
}
if (nullable) {
@@ -45,8 +46,9 @@ export function emitUnionTag(
* the `null` sentinel). */
export function emitNullablePresent(B: BlockBuilder, nullable: PointerUnion, u: string): string {
const present = B.tmp();
if (nullable.repr === "shapes" && nullable.unitTag < 0) {
// No unit arm: every value is a record (NULL only as a pending dummy).
if (nullable.repr !== "single" && nullable.unitTag < 0) {
// No unit arm: every value is a record or an instance (NULL only as a
// pending dummy).
B.line(`${present} = icmp ne ptr ${u}, null`);
return present;
}
@@ -67,7 +69,7 @@ export function emitNullableIsTag(
tag: number,
negated: boolean,
): string {
if (nullable.repr === "shapes" && tag !== nullable.unitTag && tag !== nullable.nullTag) {
if (nullable.repr !== "single" && tag !== nullable.unitTag && tag !== nullable.nullTag) {
const actual = emitUnionTag(B, nullable, u),
t = B.tmp();
B.line(`${t} = icmp ${negated ? "ne" : "eq"} i32 ${actual}, ${tag}`);
@@ -104,8 +106,8 @@ export function emitUnionPeek(
/** Text-template forms for helpers written as raw LLVM lines: `%out`
* receives the tag / payload of `u`. */
export function unionTagLines(nullable: PointerUnion | null, u: string, out: string): string[] {
if (nullable?.repr === "shapes")
return [` ${out} = call i32 ${shapeTagFn(nullable.unionId)}(ptr ${u})`];
if (nullable !== null && nullable.repr !== "single")
return [` ${out} = call i32 ${pointerTagFn(nullable)}(ptr ${u})`];
if (nullable && nullable.nullTag < 0)
return [
` ${out}.isnull = icmp eq ptr ${u}, null`,
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/class-union-cycles.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/class-union-hierarchies.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/class-union-pointers.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/class-union-program-lifetime.ts"
],
"diags": []
}
+93
View File
@@ -0,0 +1,93 @@
// Cycles through class-union fields: a union whose arms include a class
// that can reach itself is cycle-capable, so every arm carries the cycle
// collector's header, including arms that hold only numbers. Graphs are
// built, linked into cycles, rewired and dropped across many rounds.
class Leaf {
readonly value: number;
constructor(value: number) {
this.value = value;
}
}
class Branch {
left: Tree;
right: Tree;
parent: Tree | undefined = undefined;
constructor(left: Tree, right: Tree) {
this.left = left;
this.right = right;
}
}
class Ref {
target: Tree | null = null;
readonly label: string;
constructor(label: string) {
this.label = label;
}
}
type Tree = Leaf | Branch | Ref;
function sum(t: Tree | null, depth: number): number {
if (t === null || depth > 40) return 0;
if (t instanceof Leaf) return t.value;
if (t instanceof Ref) return 1 + sum(t.target, depth + 1);
return sum(t.left, depth + 1) + sum(t.right, depth + 1);
}
function build(depth: number, seed: number): Tree {
if (depth === 0) return new Leaf(seed % 5);
const b = new Branch(build(depth - 1, seed * 7 + 1), build(depth - 1, seed + 3));
if (b.left instanceof Branch) b.left.parent = b;
if (b.right instanceof Branch) b.right.parent = b;
return b;
}
let checksum = 0;
const kept: Tree[] = [];
for (let round = 0; round < 300; round++) {
const tree = build(5, round);
const ref = new Ref(`r${round}`);
// A cycle: the tree reaches the ref, and the ref reaches the tree.
if (tree instanceof Branch) {
tree.right = ref;
ref.target = tree;
}
checksum += sum(tree, 0);
if (round % 50 === 0) kept.push(tree);
// Rewire: drop the cycle on some rounds and keep it on others.
if (round % 3 === 0) ref.target = new Leaf(round);
}
console.log(checksum, kept.length, kept.map((t) => sum(t, 0)).join(" "));
// A self-referential ref and a two-ref ring, dropped.
for (let i = 0; i < 100; i++) {
const self = new Ref("self");
self.target = self;
const a = new Ref("a");
const b = new Ref("b");
a.target = b;
b.target = a;
checksum += sum(a, 30) + sum(self, 35);
}
console.log(checksum);
// Union values in arrays and maps that form cycles with their holders.
class Holder {
readonly items: Tree[] = [];
readonly named = new Map<string, Tree>();
}
let held = 0;
for (let i = 0; i < 50; i++) {
const h = new Holder();
const ref = new Ref(`h${i}`);
h.items.push(new Leaf(i), ref, build(2, i));
h.named.set("ref", ref);
const branch = new Branch(ref, new Leaf(1));
ref.target = branch;
branch.parent = h.items[2]!;
h.items.push(branch);
held += h.items.length + h.named.size + sum(h.items[3]!, 0);
}
console.log(held);
+161
View File
@@ -0,0 +1,161 @@
// Class-union arms that are hierarchies: an instance of a subclass is in
// its base's arm, sibling subclasses can be arms of one union, virtual
// methods dispatch through a narrowed arm, and thrown instances keep their
// class through a catch.
abstract class Node {
readonly pos: number;
constructor(pos: number) {
this.pos = pos;
}
abstract text(): string;
}
class Identifier extends Node {
readonly name: string;
constructor(pos: number, name: string) {
super(pos);
this.name = name;
}
text(): string {
return this.name;
}
}
class PrivateIdentifier extends Node {
readonly name: string;
constructor(pos: number, name: string) {
super(pos);
this.name = name;
}
text(): string {
return `#${this.name}`;
}
}
class Literal extends Node {
readonly value: number;
constructor(pos: number, value: number) {
super(pos);
this.value = value;
}
text(): string {
return String(this.value);
}
}
class BigLiteral extends Literal {
text(): string {
return `${this.value}n`;
}
}
class Symbol_ {
readonly name: string;
uses = 0;
constructor(name: string) {
this.name = name;
}
}
class Signature {
readonly params: number;
constructor(params: number) {
this.params = params;
}
}
class Type {
readonly id: number;
constructor(id: number) {
this.id = id;
}
}
class ObjectType extends Type {
readonly members: string[] = [];
}
class UnionType extends Type {
readonly types: Type[];
constructor(id: number, types: Type[]) {
super(id);
this.types = types;
}
}
// Sibling subclasses as arms.
type MemberName = Identifier | PrivateIdentifier;
function memberText(name: MemberName): string {
return name instanceof PrivateIdentifier ? `private ${name.name}` : `public ${name.name}`;
}
const names: MemberName[] = [new Identifier(0, "a"), new PrivateIdentifier(1, "b"), new Identifier(2, "c")];
console.log(names.map(memberText).join(", "), names.map((n) => n.text()).join(" "), names.map((n) => n.pos).join(""));
// Whole hierarchies as arms, and a subclass instance in its base's arm.
type Target = Symbol_ | Type | Signature | Node;
function describe(target: Target): string {
if (target instanceof Symbol_) return `symbol ${target.name}`;
if (target instanceof Signature) return `signature/${target.params}`;
if (target instanceof UnionType) return `union ${target.id} of ${target.types.length}`;
if (target instanceof Type) return `${target instanceof ObjectType ? "object" : "type"} ${target.id}`;
return `node ${target.text()} at ${target.pos}`;
}
const targets: Target[] = [];
const sym = new Symbol_("x");
const obj = new ObjectType(2);
targets.push(sym, new Type(1), obj, new UnionType(3, [obj, new Type(4)]), new Signature(2));
targets.push(new Identifier(5, "y"), new Literal(6, 42), new BigLiteral(7, 9), names[1]!);
console.log(targets.map(describe).join("; "));
// A resolution stack keyed by target identity, as a checker keeps one.
const stack: Target[] = [];
const props: number[] = [];
function push(target: Target, prop: number): boolean {
for (let i = stack.length - 1; i >= 0; i--) if (stack[i] === target && props[i] === prop) return false;
stack.push(target);
props.push(prop);
return true;
}
function pop(): void {
stack.pop();
props.pop();
}
let cycles = 0;
for (let round = 0; round < 200; round++) {
const t = targets[round % targets.length]!;
if (!push(t, round % 3)) cycles++;
if (!push(sym, 0)) cycles++;
if (round % 4 === 3) {
pop();
pop();
}
}
console.log(stack.length, cycles, describe(stack[stack.length - 1]!));
// Checked casts out of the union.
function symbolName(t: Target): string {
return (t as Symbol_).name;
}
function typeId(t: Target): number {
return (t as Type).id;
}
console.log(symbolName(sym), typeId(obj), typeId(targets[3]!));
// Unit arms beside hierarchy arms.
type OptionalTarget = Type | Node | undefined;
const optional: OptionalTarget[] = [undefined, new Literal(1, 1), new UnionType(5, []), undefined];
console.log(optional.map((t) => (t === undefined ? "-" : t instanceof Node ? t.text() : `t${t.id}`)).join(" "));
// Throwing and catching union arms.
function fail(target: Target): never {
throw target;
}
for (const t of [sym, new Signature(0), new Literal(3, 3)] as Target[]) {
try {
fail(t);
} catch (e) {
if (e instanceof Symbol_) console.log("caught symbol", e.name);
else if (e instanceof Signature) console.log("caught signature", e.params);
else if (e instanceof Node) console.log("caught node", e.text());
}
}
+182
View File
@@ -0,0 +1,182 @@
// Unions of two or more classes hold the instance pointer itself: wraps,
// narrowing by instanceof and by a discriminant field, unit arms, identity,
// containers, fields, retypes between such unions, captures, and the
// conversions that print or serialize a union value.
class Circle {
readonly kind = "circle";
readonly r: number;
constructor(r: number) {
this.r = r;
}
area(): number {
return 3 * this.r * this.r;
}
}
class Rect {
readonly kind = "rect";
readonly w: number;
readonly h: number;
constructor(w: number, h: number) {
this.w = w;
this.h = h;
}
area(): number {
return this.w * this.h;
}
}
class Tri {
readonly kind = "tri";
readonly b: number;
readonly h: number;
constructor(b: number, h: number) {
this.b = b;
this.h = h;
}
}
type Shape = Circle | Rect | Tri;
type MaybeShape = Shape | undefined;
type NullableShape = Circle | Rect | null | undefined;
function area(s: Shape): number {
if (s instanceof Circle) return s.area();
if (s instanceof Rect) return s.area();
return (s.b * s.h) / 2;
}
function byKind(s: Shape): string {
switch (s.kind) {
case "circle":
return `circle ${s.r}`;
case "rect":
return `rect ${s.w}x${s.h}`;
case "tri":
return `tri ${s.b}/${s.h}`;
}
}
function make(i: number): Shape {
if (i % 3 === 0) return new Circle(i);
if (i % 3 === 1) return new Rect(i, i + 1);
return new Tri(i, 2);
}
const shapes: Shape[] = [];
for (let i = 0; i < 9; i++) shapes.push(make(i));
console.log(shapes.map(area).join(" "));
console.log(shapes.map(byKind).join(", "));
// Unit arms: undefined is NULL, null is a sentinel when both are present.
function describe(s: MaybeShape): string {
if (s === undefined) return "none";
return byKind(s);
}
function describeNullable(s: NullableShape): string {
if (s === null) return "null";
if (s === undefined) return "undefined";
return s instanceof Circle ? `c${s.r}` : `r${s.w}`;
}
const maybe: MaybeShape[] = [undefined, new Circle(2), undefined, new Tri(1, 1)];
console.log(maybe.map(describe).join(" "));
const nullable: NullableShape[] = [null, new Rect(3, 4), undefined, new Circle(5)];
console.log(nullable.map(describeNullable).join(" "));
console.log(nullable.map((s) => s ?? "fallback").map((s) => (typeof s === "string" ? s : s.kind)).join(" "));
console.log(maybe.map((s) => s?.kind ?? "-").join(" "));
// Identity: equality compares instances.
const c = new Circle(1);
const a: Shape = c;
const b: Shape = c;
const other: Shape = new Circle(1);
console.log(a === b, a === other, a !== other, shapes.indexOf(shapes[4]!), shapes.includes(other));
// Fields of class-union type, reassigned and cleared.
class Slot {
current: MaybeShape = undefined;
history: Shape[] = [];
put(s: Shape): void {
if (this.current !== undefined) this.history.push(this.current);
this.current = s;
}
}
const slot = new Slot();
for (const s of shapes.slice(0, 5)) slot.put(s);
console.log(describe(slot.current), slot.history.length, slot.history.map((s) => s.kind).join(","));
slot.current = undefined;
console.log(describe(slot.current));
// Maps and sets of instances, keyed by an arm-independent value.
const byName = new Map<string, Shape>();
for (const s of shapes) byName.set(byKind(s), s);
let total = 0;
for (const [, s] of byName) total += area(s);
console.log(byName.size, total);
const seen = new Set<Circle>();
for (const s of shapes) if (s instanceof Circle) seen.add(s);
console.log(seen.size);
// Retypes: a narrower union widens to a wider one and back.
type Round = Circle | Rect;
function widen(r: Round): MaybeShape {
return r;
}
function narrow(s: MaybeShape): Round | undefined {
if (s === undefined || s instanceof Tri) return undefined;
return s;
}
const round: Round[] = [new Circle(7), new Rect(1, 2)];
console.log(round.map(widen).map(describe).join(" "), describe(narrow(new Tri(3, 3))), describe(narrow(round[1])));
// Captures and callbacks.
function counter(start: Shape): () => string {
let s: Shape = start;
let n = 0;
return () => {
n++;
s = n % 2 === 0 ? new Rect(n, n) : new Circle(n);
return byKind(s);
};
}
const next = counter(new Tri(0, 0));
console.log(next(), next(), next());
console.log(shapes.filter((s) => s instanceof Rect).length, shapes.find((s) => s instanceof Tri)?.kind);
console.log([...shapes].sort((x, y) => area(x) - area(y)).map((s) => area(s)).join(" "));
// Printing and serializing union values.
console.log(shapes[0], shapes[1], shapes[2]);
console.log(JSON.stringify(shapes.slice(0, 3)));
console.log(JSON.stringify({ current: slot.history[0], none: maybe[0] }));
console.log(String(shapes[3] instanceof Circle), `${shapes[4] instanceof Rect}`);
const mixed: (Shape | number)[] = [new Circle(1), 4];
console.log(mixed.map((m) => (typeof m === "number" ? m : m.kind)).join(" "));
// Fresh instances passed straight to functions that only read them.
function width(s: Shape): number {
return s instanceof Rect ? s.w : s instanceof Circle ? s.r * 2 : s.b;
}
let widths = 0;
for (let i = 0; i < 100; i++) widths += width(new Rect(i, 1)) + width(new Circle(i)) + width(new Tri(1, i));
console.log(widths, byKind(new Rect(2, 3)), width(new Circle(4)));
// Conversions between class unions, boxed unions and unknown values.
function boxed(s: Shape): Shape | number | string {
return s;
}
function unboxed(v: Shape | number | string): Shape | undefined {
return typeof v === "number" || typeof v === "string" ? undefined : v;
}
const wide = shapes.slice(0, 3).map(boxed);
wide.push(5, "text");
console.log(wide.map((v) => (typeof v === "object" ? v.kind : typeof v)).join(" "));
console.log(wide.map(unboxed).map(describe).join(" "));
const anyValues: unknown[] = shapes.slice(3, 6);
console.log(anyValues.map((v) => (v instanceof Circle ? "C" : v instanceof Rect ? "R" : v instanceof Tri ? "T" : "?")).join(""));
const back = anyValues[1] as Shape;
console.log(byKind(back), (anyValues[0] as Circle).r, JSON.stringify(anyValues));
try {
console.log((anyValues[2] as Circle).area());
} catch (e) {
console.log((e as Error).name);
}
@@ -0,0 +1,173 @@
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
import { publish } from "@scriptc/threads";
import type { ProgramLifetime } from "@scriptc/types";
// Class unions over ProgramLifetime classes, whose instances keep their
// class id in a 32-bit header and count no references: a checker's
// resolution stack of `Symbol | Type | Signature | Node` targets, union
// fields, and a published graph read by a worker. Receivers read only
// fields, since Node.js clones what it posts.
abstract class Node implements ProgramLifetime {
readonly kind: string;
readonly pos: number;
parent: Node | undefined = undefined;
constructor(kind: string, pos: number) {
this.kind = kind;
this.pos = pos;
}
}
class Identifier extends Node {
readonly text: string;
constructor(pos: number, text: string) {
super("identifier", pos);
this.text = text;
}
}
class PrivateIdentifier extends Node {
readonly text: string;
constructor(pos: number, text: string) {
super("private", pos);
this.text = text;
}
}
class Call extends Node {
readonly callee: Identifier | PrivateIdentifier;
constructor(pos: number, callee: Identifier | PrivateIdentifier) {
super("call", pos);
this.callee = callee;
callee.parent = this;
}
}
class Sym implements ProgramLifetime {
readonly kind = "symbol";
readonly name: string;
declaration: Node | Sym | undefined = undefined;
constructor(name: string) {
this.name = name;
}
}
class Type implements ProgramLifetime {
readonly kind: string = "type";
readonly id: number;
origin: Sym | Type | undefined = undefined;
constructor(id: number) {
this.id = id;
}
}
class ObjectType extends Type {
readonly kind: string = "object";
}
class Signature implements ProgramLifetime {
readonly kind = "signature";
readonly arity: number;
constructor(arity: number) {
this.arity = arity;
}
}
type Target = Sym | Type | Signature | Node;
function label(t: Target | undefined): string {
if (t === undefined) return "-";
switch (t.kind) {
case "symbol":
return `S:${(t as Sym).name}`;
case "type":
case "object":
return `T${(t as Type).id}`;
case "signature":
return `sig${(t as Signature).arity}`;
default:
return `N@${(t as Node).pos}`;
}
}
class Checker {
readonly targets: Target[] = [];
readonly props: number[] = [];
depth = 0;
cycles = 0;
push(target: Target, prop: number): boolean {
for (let i = this.depth - 1; i >= 0; i--)
if (this.targets[i] === target && this.props[i] === prop) {
this.cycles++;
return false;
}
if (this.depth < this.targets.length) {
this.targets[this.depth] = target;
this.props[this.depth] = prop;
} else {
this.targets.push(target);
this.props.push(prop);
}
this.depth++;
return true;
}
pop(): void {
this.depth--;
}
}
function program(prefix: string): Target[] {
const out: Target[] = [];
for (let i = 0; i < 4; i++) {
const sym = new Sym(`${prefix}${i}`);
const id = new Identifier(i * 10, `${prefix}${i}`);
const call = new Call(i * 10 + 1, i % 2 === 0 ? id : new PrivateIdentifier(i * 10 + 2, `${prefix}p${i}`));
sym.declaration = i % 3 === 0 ? call : sym;
const type = i % 2 === 0 ? new Type(i) : new ObjectType(i);
type.origin = i === 0 ? sym : type;
out.push(sym, type, new Signature(i), call, call.callee);
}
return out;
}
function local(): void {
const targets = program("a");
const checker = new Checker();
for (let round = 0; round < 400; round++) {
const t = targets[(round * 7) % targets.length]!;
if (checker.push(t, round % 2)) {
if (round % 5 === 4) checker.pop();
}
if (checker.depth > 12) checker.depth = 2;
}
console.log(checker.depth, checker.cycles, checker.targets.slice(0, 6).map(label).join(" "));
console.log(targets.map(label).join(" "));
console.log(
targets
.filter((t) => t instanceof Sym)
.map((t) => label((t as Sym).declaration))
.join(" "),
targets.filter((t) => t instanceof Type).map((t) => label((t as Type).origin)).join(" "),
);
const member: (Identifier | PrivateIdentifier)[] = [];
for (const t of targets) {
if (t instanceof Identifier) member.push(t);
else if (t instanceof PrivateIdentifier) member.push(t);
}
console.log(member.map((m) => (m instanceof PrivateIdentifier ? `#${m.text}` : m.text)).join(" "));
}
if (isMainThread) {
local();
const shared = publish(program("w"));
console.log("published", shared.map(label).join(" "));
const worker = new Worker(new URL(import.meta.url), { workerData: { shared } });
worker.on("message", (message: unknown) => {
console.log("worker", message as string);
});
} else {
const { shared } = workerData as { shared: Target[] };
const kinds = shared.map((t) => t.kind).join(",");
const names = shared.map((t) => (t.kind === "symbol" ? (t as Sym).name : "")).join("");
parentPort!.postMessage(`${shared.length} ${kinds} ${names}`);
}
@@ -0,0 +1 @@
{ "plain": 4300, "san": 6100 }
@@ -0,0 +1 @@
{ "plain": 5600, "san": 7900 }
@@ -0,0 +1 @@
{ "plain": 8000, "san": 8200 }
@@ -0,0 +1 @@
{ "plain": 5300, "san": 9700 }