Construct through arrays of class values that mix a class and its subclasses (#1868)

This commit is contained in:
Chris Tate
2026-10-24 20:18:35 -05:00
committed by GitHub
11 changed files with 369 additions and 27 deletions
+24
View File
@@ -176,6 +176,10 @@ export interface DynHost extends WalkerHost {
dynPromiseAdapter(t: IrType): string;
isErrorClass(className: string): boolean;
classSubtypes(className: string): readonly string[];
/** The strict subclasses whose class-value constructor takes the same
* arguments as `className`'s (classValueAbiEquals): the class values a
* checked `classval` slot accepts besides its own class. */
classValueSubtypes(className: string): readonly string[];
/** emitter.ts's pendingTestLines: the inline pending-exception test. */
pendingTestLines(dest: string): string[];
}
@@ -1810,6 +1814,26 @@ export class LlDyn {
const B = new BlockBuilder();
if (t.kind === "classval") {
host.declare(`declare ptr @scr_dyn_class_check(ptr, ptr, ptr)`);
// A subclass's class value checks into the base class's slot when
// construction through the slot can call its thunk with the base
// constructor's arguments; any other subclass fails the check.
const subclasses = host.classValueSubtypes(t.className);
if (subclasses.length > 0) {
host.declare(`declare zeroext i1 @scr_dyn_class_is(ptr, ptr)`);
for (const className of subclasses) {
const subKey = host.cstr(typeKey({ kind: "classval", className }));
const matched = B.tmp();
B.line(`${matched} = call zeroext i1 @scr_dyn_class_is(ptr %d, ptr ${subKey})`);
const yes = B.newLabel("sub.yes");
const no = B.newLabel("sub.no");
B.condBr(matched, yes, no);
B.startBlock(yes);
const sub = B.tmp();
B.line(`${sub} = call ptr @scr_dyn_class_check(ptr %d, ptr ${subKey}, ptr %path)`);
B.terminate(`ret ptr ${sub}`);
B.startBlock(no);
}
}
const checked = B.tmp();
B.line(
`${checked} = call ptr @scr_dyn_class_check(ptr %d, ptr ${host.cstr(key)}, ptr %path)`,
+18 -8
View File
@@ -55,6 +55,7 @@ import {
typeKey,
VOID,
classJsName,
classValueAbiEquals,
} from "../../ir/ir.js";
import { moduleRuntimeFeatures, type RuntimeFeatures } from "../../ir/runtime-features.js";
import { captureFreeFunctions, observesStacks } from "../stack-observations.js";
@@ -2053,14 +2054,14 @@ export class LlEmitter {
const meta = this.classMeta.get(t.className);
return meta !== undefined && programLifetimeClass(this.shapeHost, meta);
},
classSubtypes: (name) => {
const target = this.classMetaOf(name);
const intervals = classMembershipIntervals(this.classMeta, target.def.name);
return [...this.classMeta.values()]
.filter((meta) =>
intervals.some((range) => range.pre <= meta.pre && meta.pre <= range.post),
)
.map((meta) => meta.def.name);
classSubtypes: (name) => this.classSubtypes(name),
classValueSubtypes: (name) => {
const target = this.classMetaOf(name).def;
return this.classSubtypes(name).filter(
(sub) =>
sub !== target.name &&
classValueAbiEquals(this.classMetaOf(sub).def, target, this.fnByName),
);
},
pendingTestLines: (dest) => this.pendingTestLines(dest),
virtualEntry: (implFn) => this.virtualEntry(implFn),
@@ -4185,6 +4186,15 @@ export class LlEmitter {
// ── class plumbing ──────────────────────────────────────────────────────
/** `name` and every class whose membership interval falls in its own. */
private classSubtypes(name: string): string[] {
const target = this.classMetaOf(name);
const intervals = classMembershipIntervals(this.classMeta, target.def.name);
return [...this.classMeta.values()]
.filter((meta) => intervals.some((range) => range.pre <= meta.pre && meta.pre <= range.post))
.map((meta) => meta.def.name);
}
classMetaOf(className: string): LlClassMeta {
const meta = this.classMeta.get(className);
if (!meta) throw new InternalCompilerError(`llvm emitter bug: unknown class ${className}`);
@@ -9831,6 +9831,75 @@ function assignedThisFieldType(lowerer: Lowerer, expr: ts.NewExpression): IrType
return lowerer.currentClass?.fields.get(parent.left.name.text) ?? null;
}
/** An array element read that construction narrows to a class value with a
* check (`%new.callee`, which throws "is not a constructor"). */
interface NarrowedConstructorRead {
read: IrExpr;
check: string;
unionT: IrType & { kind: "union" };
calleeT: IrType & { kind: "classval" };
message: IrExpr;
}
/** `new arr[i](args)` through a narrowed element read: an interned lifted
* function takes the read and the completed arguments, which evaluate in
* that order as call arguments, then runs the check and constructs. JS
* evaluates the arguments before it checks that the callee constructs, and
* the arguments stay in the one call, where a spread's argument pack lives
* until every argument has read it. */
function checkedElementConstruction(
lowerer: Lowerer,
info: ClassInfo,
narrowed: NarrowedConstructorRead,
args: IrExpr[],
loc: SrcLoc,
): IrExpr {
const type: IrType = { kind: "object", className: info.def.name };
const key = `newChecked:${narrowed.check}:${info.def.name}:${args.map((a) => typeKey(a.type)).join(",")}`;
let name = lowerer.valueHelpers.get(key);
if (!name) {
name = keyedHelperName(lowerer, "%new.checked", key);
lowerer.valueHelpers.set(key, name);
const params: IrParam[] = [
{ localId: "value", name: "value", type: narrowed.unionT },
{ localId: "message", name: "message", type: STRING },
...args.map((arg, i) => ({ localId: `arg.${i}`, name: `arg${i}`, type: arg.type })),
];
lowerer.liftedFns.push({
name,
params,
returnType: type,
locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })),
body: [
{
kind: "return",
value: {
kind: "newValue",
callee: {
kind: "call",
callee: narrowed.check,
args: [varRef("value", narrowed.unionT, loc), varRef("message", STRING, loc)],
type: narrowed.calleeT,
loc,
},
args: params.slice(2).map((p) => varRef(p.localId, p.type, loc)),
type,
loc,
},
loc,
},
],
loc,
});
}
return {
kind: "call",
callee: name,
args: [narrowed.read, narrowed.message, ...args],
type,
loc,
};
}
/** Map, Set and the error classes construct in lowerNew's identifier
* branch, which a module member alias of one (`new mod.M()`) also takes. */
function isCollectionOrErrorClass(ref: BuiltinClassRef | null): boolean {
@@ -11169,8 +11238,109 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
"constructing through a class value of an uninstantiated generic type (annotate the slot with a concrete instantiation — e.g. 'new (v: number) => Box<number>')",
);
}
const callee = lowerer.lowerExpr(expr.expression);
let callee = lowerer.lowerExpr(expr.expression);
if (callee.type.kind === "dyn") return lowerCheckedConstructor(lowerer, expr, callee);
/** The union-typed element read a narrowing check guards, and the
* check (`%new.callee`) that yields the class value or throws. */
let narrowedCallee: NarrowedConstructorRead | null = null;
// An array element read (`new T[0]()` over `[Dog, Animal]`, whose
// checker type is the common base) keeps the undefined an index past
// the end reads: the class value is narrowed out with a check, which
// throws a TypeError for undefined.
if (callee.type.kind === "union") {
const arms = lowerer.unions.get(callee.type.unionId)?.arms ?? [];
const classArms = arms.filter((arm) => arm.kind === "classval");
if (
classArms.length === 1 &&
classArms[0]!.kind === "classval" &&
classArms[0]!.className === calleeT.className &&
arms.every((arm) => arm.kind === "classval" || arm.kind === "undefinedT")
) {
const unionT = callee.type;
const helper = lowerer.narrowedArmHelper(unionT.unionId, calleeT, callee.loc);
if (helper) {
const loc = callee.loc;
const name = `%new.callee:${unionT.unionId}`;
if (!lowerer.liftedFns.some((fn) => fn.name === name)) {
const undefinedTag = lowerer.armTag(unionT.unionId, UNDEFINED_T);
const value: IrExpr = { kind: "varRef", localId: "value", type: unionT, loc };
lowerer.liftedFns.push({
name,
params: [
{ localId: "value", name: "value", type: unionT },
{ localId: "message", name: "message", type: STRING },
],
locals: [
{ id: "value", name: "value", type: unionT, mutable: false },
{ id: "message", name: "message", type: STRING, mutable: false },
],
returnType: calleeT,
body: [
...(undefinedTag >= 0
? [
{
kind: "if",
cond: {
kind: "unionIsTag",
unionId: unionT.unionId,
tag: undefinedTag,
negated: false,
value,
type: BOOL,
loc,
},
then: [
{
kind: "throw",
value: {
kind: "libCall",
fn: "error.new",
args: [{ kind: "varRef", localId: "message", type: STRING, loc }],
type: { kind: "object", className: "%TypeError" },
loc,
},
loc,
},
],
else_: null,
loc,
} satisfies IrStmt,
]
: []),
{
kind: "return",
value: lowerer.checkedNarrowCall(helper, value, calleeT, loc),
loc,
},
],
loc,
});
}
// JavaScript evaluates the arguments before it checks that the
// callee constructs, so the check runs at the construction
// (checkedElementConstruction).
narrowedCallee = {
read: callee,
check: name,
unionT,
calleeT,
message: {
kind: "strLit",
value: `${expr.expression.getText()} is not a constructor`,
type: STRING,
loc,
},
};
callee = {
kind: "call",
callee: name,
args: [callee, narrowedCallee.message],
type: calleeT,
loc,
};
}
}
}
if (callee.type.kind !== "classval")
lowerer.badType(expr.expression, lowerer.typeOf(expr.expression));
lowerer.noteEdge(classValueConstructor(info.def).fn);
@@ -11210,13 +11380,15 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
const built = constructedFromValue(
lowerer,
info,
{
kind: "newValue",
callee,
args,
type: { kind: "object", className: info.def.name },
loc,
},
narrowedCallee !== null
? checkedElementConstruction(lowerer, info, narrowedCallee, args, loc)
: {
kind: "newValue",
callee,
args,
type: { kind: "object", className: info.def.name },
loc,
},
expr,
);
// A runtime class slot widened from a union (`[Readable, Writable]`'s
+22
View File
@@ -1623,6 +1623,28 @@ export function classValueConstructor(def: { name: string; runtime?: true }): {
: { fn: `%${def.name}.constructor`, firstArg: 1 };
}
/** Whether construction through a class value typed as `sup` may call
* `sub`'s construct thunk: both class-value constructors exist and take the
* same argument types after the receiver. The classval upcast rule and the
* checked class-value boundary both rest on it. */
export function classValueAbiEquals(
sub: { name: string; runtime?: true },
sup: { name: string; runtime?: true },
functions: ReadonlyMap<string, IrFunction>,
): boolean {
const subValueCtor = classValueConstructor(sub);
const supValueCtor = classValueConstructor(sup);
const subCtor = functions.get(subValueCtor.fn);
const supCtor = functions.get(supValueCtor.fn);
if (!subCtor || !supCtor) return false;
const subArgs = subCtor.params.slice(subValueCtor.firstArg);
const supArgs = supCtor.params.slice(supValueCtor.firstArg);
return (
subArgs.length === supArgs.length &&
subArgs.every((p, i) => typeEquals(p.type, supArgs[i]!.type))
);
}
/** The JavaScript `.name` of a class: its jsName, or the runtime-provided
* class's own name (`%TypeError` is TypeError). */
export function classJsName(def: { name: string; runtime?: true; jsName?: string }): string {
+10 -11
View File
@@ -57,7 +57,12 @@ import {
maybePromiseUnionArmsOk,
VOID,
} from "./ir.js";
import { DYN_CLASS_PROPERTIES, callSiteReturnType, classValueConstructor } from "./ir.js";
import {
DYN_CLASS_PROPERTIES,
callSiteReturnType,
classValueAbiEquals,
classValueConstructor,
} from "./ir.js";
import { unionWideningTags } from "./analysis.js";
import { alwaysReturns } from "./control-flow.js";
import { dynamicOperationOf, functionTier } from "./tiers.js";
@@ -2993,19 +2998,13 @@ function validateFunction(
}
const subClass = classes.get(sub);
const supClass = classes.get(sup);
const subValueCtor = subClass ? classValueConstructor(subClass) : null;
const supValueCtor = supClass ? classValueConstructor(supClass) : null;
const subCtor = subValueCtor ? functions.get(subValueCtor.fn) : undefined;
const supCtor = supValueCtor ? functions.get(supValueCtor.fn) : undefined;
if (!subCtor || !supCtor || !subValueCtor || !supValueCtor) {
const subCtor = subClass ? functions.get(classValueConstructor(subClass).fn) : undefined;
const supCtor = supClass ? functions.get(classValueConstructor(supClass).fn) : undefined;
if (!subClass || !supClass || !subCtor || !supCtor) {
err(`classval upcast: "${sub}"/"${sup}" lack constructor functions`, e.loc);
break;
}
const subArgs = subCtor.params.slice(subValueCtor.firstArg);
const supArgs = supCtor.params.slice(supValueCtor.firstArg);
const abiEqual =
subArgs.length === supArgs.length &&
subArgs.every((p, i) => typeEquals(p.type, supArgs[i]!.type));
const abiEqual = classValueAbiEquals(subClass, supClass, functions);
if (!abiEqual) {
err(`classval upcast: "${sub}" and "${sup}" constructor ABIs differ`, e.loc);
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6937-class-value-arrays.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6938-class-value-arrays.ts"
],
"diags": []
}
+53
View File
@@ -0,0 +1,53 @@
// Arrays of class values that mix a base class and its subclasses: the
// elements construct, compare and name like the classes themselves.
class Animal {
speak() {
return "...";
}
}
class Dog extends Animal {
speak() {
return "woof";
}
}
class Cat extends Animal {
speak() {
return "meow";
}
}
const kinds = [Dog, Cat, Animal];
console.log(kinds.length, kinds.map((C) => C.name).join());
console.log(new kinds[0]().speak(), new kinds[1]().speak(), new kinds[2]().speak());
console.log(kinds[0] === Dog, kinds.indexOf(Cat), kinds.includes(Animal));
const made = kinds.map((C) => new C());
console.log(made.map((a) => a.speak()).join(), made[0] instanceof Animal, made[1] instanceof Dog);
try {
new kinds[5]();
} catch (e) {
console.log(e.name, e.message);
}
const byName = { dog: Dog, cat: Cat };
console.log(new byName.dog().speak(), new byName["cat"]().speak());
// The arguments are evaluated before the missing element throws.
class Point {
constructor(x, y) {
this.sum = x + y;
}
}
class Point3 extends Point {}
const points = [Point3, Point];
const evaluated = [];
const arg = (v) => (evaluated.push(v), v);
console.log(new points[0](arg(1), arg(2)).sum, evaluated.join());
try {
new points[2](arg(3), arg(4));
} catch (e) {
console.log(e.name, e.message, evaluated.join());
}
// Spread arguments too, through a valid index and past the end.
console.log(new points[1](...[5, 6]).sum);
try {
new points[3](...[arg(7), arg(8)]);
} catch (e) {
console.log(e.name, e.message, evaluated.join());
}
+48
View File
@@ -0,0 +1,48 @@
// Arrays of class values that mix a base class and its subclasses, with the
// element type the checker infers (the base class): elements construct and
// dispatch to the subclass, and an index past the end throws Node's TypeError.
class Shape {
area(): number {
return 0;
}
}
class Square extends Shape {
area(): number {
return 4;
}
}
class Circle extends Shape {
area(): number {
return 3;
}
}
const shapes = [Square, Circle, Shape];
console.log(shapes.length, new shapes[0]().area(), new shapes[1]().area(), new shapes[2]().area());
let total = 0;
for (const S of shapes) total += new S().area();
console.log(total, shapes[1] === Circle, shapes.map((S) => S.name).join());
try {
new shapes[3]();
} catch (e) {
console.log((e as Error).name, (e as Error).message);
}
// A class value checked into a `typeof Shape` slot: a subclass whose
// constructor takes the same arguments constructs, and one whose constructor
// takes an argument the slot does not pass throws a TypeError (Node's from
// reading the missing argument, the compiled program's from the check).
class Sized extends Shape {
label: string;
constructor(side: number) {
super();
this.label = side.toFixed(1);
}
}
const values: any[] = [Square, Sized, Shape];
for (const value of values) {
try {
const S: typeof Shape = value;
console.log(new S().area());
} catch (e) {
console.log((e as Error).name);
}
}
@@ -0,0 +1 @@
{ "plain": 4211, "san": 6383 }
@@ -0,0 +1 @@
{ "plain": 2399, "san": 4080 }