diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts index dfb8bd7a5..6923841bd 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts @@ -475,3 +475,85 @@ test("normalizes base capsules before dispatching each derived member once", () ], }); }); + +test("class branches read their exact class's bag, also for classes with subclasses", () => { + const { lowerer, liftedFns } = context(); + const base = { + def: { name: "Base", fields: [{ name: "value", type: F64 }], tracksOwnFields: true }, + fields: new Map([["value", F64]]), + methods: new Map(), + base: null, + subclasses: [], + } as unknown as ClassInfo; + const child = { + def: { + name: "Child", + base: "Base", + fields: [{ name: "value", type: F64 }], + tracksOwnFields: true, + }, + fields: new Map([["value", F64]]), + methods: new Map(), + base, + subclasses: [], + } as unknown as ClassInfo; + base.subclasses.push(child); + lowerer.classes.set("Base", base); + lowerer.classes.set("Child", child); + lowerer.classCanBeConstructed = () => true; + lowerer.isSubclassOf = (name, parent) => name === "Child" && parent === "Base"; + lowerer.coerceToExpected = (value, type) => + type.kind === "dyn" ? { kind: "dynFrom", value, type, loc } : value; + const boxes = fn( + "boxes", + [base, child].map((info) => + statement({ + kind: "dynFrom", + value: { + kind: "varRef", + localId: info.def.name, + type: { kind: "object", className: info.def.name }, + loc, + }, + type: DYN, + loc, + }), + ), + ); + const read = fn("read", [ + statement({ + kind: "dynKeyGet", + value: variable("value"), + key: literal("value"), + type: DYN, + loc, + }), + ]); + const dispatch = new ClassDynamicDispatch(); + for (let pass = 0; pass < 4 && dispatch.process(lowerer, [boxes, read, ...liftedFns]); pass++); + dispatch.finalize(lowerer); + const property = liftedFns.find((helper) => helper.name.startsWith("%dyn.class.property."))!; + // The class branches; the fallback for other receivers keeps the + // dispatch over every class. + const branches = property.body.filter( + (statement) => + statement.kind === "if" && + statement.cond.kind === "libCall" && + statement.cond.fn === "dyn.typedRefIs", + ); + expect(branches).toHaveLength(2); + const text = JSON.stringify(branches); + expect(text).toContain('"callee":"%class.branchPropertiesOrEmpty:Base"'); + expect(text).toContain('"callee":"%class.branchPropertiesOrEmpty:Child"'); + expect(text).not.toContain('"callee":"%dyn.class.readPropertiesOrEmpty"'); + const bag = liftedFns.find((helper) => helper.name === "%class.branchPropertiesOrEmpty:Base")!; + // The bag is the receiver's own field; an absent bag reads as a fresh, + // unstored empty object. + expect(bag.params[0]!.type).toEqual({ kind: "object", className: "Base" }); + expect(JSON.stringify(bag.body)).toContain('"className":"Base","field":"%dynProperties"'); + expect(JSON.stringify(bag.body)).not.toContain('"kind":"fieldSet"'); + expect(bag.body[0]).toMatchObject({ + kind: "if", + then: [{ kind: "return", value: { kind: "dynObjLit", fields: [] } }], + }); +}); diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index c891c14cc..9dd7681a6 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -386,6 +386,26 @@ function readOnlyBagBody(body: IrStmt[], orEmpty = false): IrStmt[] { return body; } +/** `value` with the class branch's checked receiver (`p.0` as a dynamic + * value, possibly cast to the branch's class) replaced by `receiver`, the + * typed receiver of a per-class helper. */ +function withTypedReceiver(value: IrExpr, receiver: IrExpr): IrExpr { + const checked = (e: IrExpr): boolean => + e.kind === "dynCheck" && e.value.kind === "varRef" && e.value.localId === "p.0"; + return transformExpr(value, { + stmt: (stmt) => stmt, + expr: (expr) => + (expr.kind === "downcast" && + checked(expr.value) && + expr.type.kind === "object" && + receiver.type.kind === "object" && + expr.type.className === receiver.type.className) || + checked(expr) + ? receiver + : expr, + }); +} + export function classPropertiesHelper(lowerer: Lowerer, loc: SrcLoc, readOnly = false): IrFunction { const name = readOnly ? READ_PROPERTIES : "%dyn.class.properties"; const existing = lowerer.liftedFnNamed(name); @@ -728,7 +748,11 @@ export class ClassDynamicDispatch { private readonly readPropertyBags: { helper: IrFunction; initialize: Extract; + orEmpty?: boolean; }[] = []; + /** Read-only bags of exactly one class (exactPropertyBag), by class and + * whether an absent bag reads as a fresh empty object. */ + private readonly exactPropertyBags = new Map(); private readonly generated = new Set(); /** Protocol dispatch helpers and callee reads whose receivers normalize. */ private readonly protocols: IrFunction[] = []; @@ -2557,7 +2581,6 @@ export class ClassDynamicDispatch { if (dispatch.classes.has(plan.key) || !matching!.has(info)) continue; dispatch.classes.add(plan.key); const loc = dispatch.source.loc; - const value = varRef("p.0", DYN, loc); const { receiver, condition } = this.propertyReceiver(plan, loc); if (isClassCallback(lowerer, info, dispatch.source.method)) { const prototypeMethod = observedPrototypeMethod( @@ -2567,7 +2590,7 @@ export class ClassDynamicDispatch { loc, receiver, ); - const bag = this.readPropertyBagOrEmpty(lowerer, value, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const methodKey: IrExpr = { kind: "strLit", value: dispatch.source.method, @@ -2825,10 +2848,19 @@ export class ClassDynamicDispatch { dispatch.classes.add(plan.key); const loc = dispatch.fn.loc; const { receiver, condition } = this.propertyReceiver(plan, loc); + // The branch checks its receiver once: each use of the checked + // receiver would repeat the check. + const local = `receiver.${dispatch.fn.locals.length}`; + dispatch.fn.locals.push({ + id: local, + name: "receiver", + type: receiver.type, + mutable: false, + }); const before = lowerer.diags.length; let branch: IrStmt[]; try { - branch = this.propertyBody(lowerer, dispatch, info, receiver); + branch = this.propertyBody(lowerer, dispatch, info, varRef(local, receiver.type, loc)); } catch (error) { if (!(error instanceof PoisonError) || !info.decl) throw error; const fence = lowerer.deferToRuntimeFence(before, info.decl, { kind: "statement" }); @@ -2838,7 +2870,7 @@ export class ClassDynamicDispatch { dispatch.fn.body.unshift({ kind: "if", cond: condition, - then: branch, + then: [{ kind: "varDecl", localId: local, init: receiver, loc }, ...branch], else_: null, loc, }); @@ -2938,11 +2970,7 @@ export class ClassDynamicDispatch { !info.fields.has(name) && observedPrototypeMethod(lowerer, info, name, loc, receiver); if (info.fields.has(name) || (dispatch.probe === "in" && !prototypeMethod)) { if (info.fields.has(name) && info.def.tracksOwnFields) { - const bag = this.readPropertyBagOrEmpty( - lowerer, - lowerer.coerceToExpected(receiver, DYN), - loc, - ); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const symbol = symbolMemberKey(lowerer, info, name, loc); const fn = dispatch.probe === "in" @@ -2968,11 +2996,7 @@ export class ClassDynamicDispatch { { kind: "return", value: { kind: "boolLit", value: true, type: BOOL, loc }, loc }, ]; } - const bag = this.readPropertyBagOrEmpty( - lowerer, - lowerer.coerceToExpected(receiver, DYN), - loc, - ); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const symbol = name.startsWith("sym:") || name.startsWith("%symbol:"); const fn = dispatch.probe === "in" @@ -3042,9 +3066,8 @@ export class ClassDynamicDispatch { } const branches: IrStmt[] = []; if (!dispatch.write && !dispatch.reflect && !info.def.tracksOwnFields) { - const value = varRef("p.0", DYN, loc); const key = varRef("p.key", DYN, loc); - const bag = this.readPropertyBagOrEmpty(lowerer, value, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); branches.push({ kind: "if", cond: { @@ -4070,6 +4093,98 @@ export class ClassDynamicDispatch { return helper; } + /** The read-only bag a class branch reads for its receiver: the + * receiver's own field when the branch selected the receiver's exact + * class, otherwise the dispatch over every class (readPropertyBag, or + * readPropertyBagOrEmpty with `orEmpty`). */ + private branchPropertyBag( + lowerer: Lowerer, + info: ClassInfo, + receiver: IrExpr, + orEmpty: boolean, + loc: SrcLoc, + ): IrExpr { + const helper = this.exactPropertyBag(lowerer, info, receiver, orEmpty, loc); + if (helper) return { kind: "call", callee: helper.name, args: [receiver], type: DYN, loc }; + return orEmpty + ? this.readPropertyBagOrEmpty(lowerer, lowerer.coerceToExpected(receiver, DYN), loc) + : this.readPropertyBag(lowerer, receiver, loc); + } + + /** `%class.branchProperties[OrEmpty]:(receiver)`: the read-only bag of + * an instance of exactly `info`. A class branch of the dispatch helpers + * selects a canonical receiver (normalizeDispatches gives an instance its + * actual class's capsule), so its branch for `info` sees only instances + * of `info`; instances of its subclasses take their own branches. Such a + * receiver needs neither the dispatch over every class nor a boxed copy of + * itself. Null + * when `receiver` is not typed as `info`, when a runtime or builtin class + * in the family keeps its own property contract, or before the class has + * a bag. */ + private exactPropertyBag( + lowerer: Lowerer, + info: ClassInfo, + receiver: IrExpr, + orEmpty: boolean, + loc: SrcLoc, + ): IrFunction | null { + const type = receiver.type; + if (type.kind !== "object" || type.className !== info.def.name) return null; + for (let current: ClassInfo | null = info; current; current = current.base) + if ( + current.def.runtime || + current.builtinEmitter || + current.builtinStream || + current.builtinError + ) + return null; + const key = `${type.className}:${orEmpty}`; + const existing = this.exactPropertyBags.get(key); + if (existing) return existing; + const initializers = this.bagInitializers.get(type.className); + if (!initializers) return null; + const self = varRef("p.0", type, loc); + const bag: IrExpr = { + kind: "fieldGet", + obj: self, + className: type.className, + field: PROPERTY_BAG, + type: DYN, + loc, + }; + const initialize: Extract = { + ...initializers[0]!, + obj: self, + value: withTypedReceiver(initializers[0]!.value, self), + loc, + }; + const helper: IrFunction = { + name: `%class.branchProperties${orEmpty ? "OrEmpty" : ""}:${type.className}`, + params: [{ localId: "p.0", name: "value", type }], + returnType: DYN, + locals: [{ id: "p.0", name: "value", type, mutable: false }], + loc, + body: [ + { + kind: "if", + cond: { kind: "dynTest", test: "undefined", value: bag, type: BOOL, loc }, + then: [initialize], + else_: null, + loc, + }, + { kind: "return", value: bag, loc }, + ], + }; + // Initializers gain their prototype as subclasses become constructible; + // finalize then turns the lazy store into the read's result. + initializers.push(initialize); + this.exactPropertyBags.set(key, helper); + this.readPropertyBags.push({ helper, initialize, orEmpty }); + this.generated.add(helper); + lowerer.liftedFns.push(helper); + return helper; + } + /** The prototype a new property bag of `current` (or of the subclass the * receiver is) starts from, or null when `current` has no prototype data. * A class with constructible subclasses that have prototype data selects @@ -4149,9 +4264,9 @@ export class ClassDynamicDispatch { * prototype-backed bag is produced for this read only. A published object * cannot take the store, and Node's reads never write. */ finalize(lowerer?: Lowerer): void { - for (const { helper, initialize } of this.readPropertyBags) { + for (const { helper, initialize, orEmpty } of this.readPropertyBags) { const absent = helper.body[0]; - if (isEmptyBag(initialize.value)) helper.body.shift(); + if (isEmptyBag(initialize.value) && !orEmpty) helper.body.shift(); else if (absent?.kind === "if") absent.then = [{ kind: "return", value: initialize.value, loc: initialize.loc }]; } @@ -4733,7 +4848,7 @@ export class ClassDynamicDispatch { cond: this.readBagHas( lowerer, "dyn.hasKey", - this.readPropertyBag(lowerer, receiver, loc), + this.branchPropertyBag(lowerer, info, receiver, false, loc), key, loc, ), @@ -4748,7 +4863,7 @@ export class ClassDynamicDispatch { { kind: "return", value: null, loc }, ]; } - const bag = this.readPropertyBag(lowerer, receiver, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc); return [ { kind: "return", @@ -4816,7 +4931,7 @@ export class ClassDynamicDispatch { ) { this.constructors.set(info.def.name, info); const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl), DYN); - const bag = this.readPropertyBag(lowerer, receiver, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc); const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; return [ { @@ -4894,7 +5009,7 @@ export class ClassDynamicDispatch { if (symbol || (isClassOwnEnumerableFieldName(name) && !name.startsWith("sym:"))) { const prototypeMethod = observedPrototypeMethod(lowerer, info, name, loc, receiver); // Selecting the method must not give the object own storage. - const bag = this.readPropertyBag(lowerer, receiver, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc); const key: IrExpr = symbol ?? { kind: "strLit", value: name, type: STRING, loc }; body.push({ kind: "if", @@ -4962,7 +5077,7 @@ export class ClassDynamicDispatch { } if (!field && !accessor) { const instance = lowerer.coerceToExpected(receiver, DYN); - const bag = this.readPropertyBagOrEmpty(lowerer, instance, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; const own: IrExpr = { kind: "libCall", @@ -5009,11 +5124,7 @@ export class ClassDynamicDispatch { (t.kind === "union" && (getUnion(t.unionId)?.arms.some(mutable) ?? false)); if (boxed.kind === "dynFrom" && mutable(boxed.value.type)) boxed.liveRef = true; if (accessor) { - const bag = this.readPropertyBagOrEmpty( - lowerer, - lowerer.coerceToExpected(receiver, DYN), - loc, - ); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; return [ { @@ -5040,7 +5151,7 @@ export class ClassDynamicDispatch { } if (field && info.def.tracksOwnFields) { const instance = lowerer.coerceToExpected(receiver, DYN); - const bag = this.readPropertyBagOrEmpty(lowerer, instance, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const symbol = symbolMemberKey(lowerer, info, name, loc); const key: IrExpr = symbol ?? { kind: "strLit", value: name, type: STRING, loc }; return [ diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6943-js-class-branch-bags.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6943-js-class-branch-bags.js.json new file mode 100644 index 000000000..31675c66e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6943-js-class-branch-bags.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6943-js-class-branch-bags.js" + ], + "diags": [] +} diff --git a/tests/corpus/6943-js-class-branch-bags.js b/tests/corpus/6943-js-class-branch-bags.js new file mode 100644 index 000000000..b3f91f8d8 --- /dev/null +++ b/tests/corpus/6943-js-class-branch-bags.js @@ -0,0 +1,67 @@ +// Untyped reads of class instances in a hierarchy: own properties added at +// runtime shadow methods and accessors, prototype data answers while an +// instance has no own properties, and computed keys and key probes see the +// same members, for the base class and for each subclass. + +class Shape { + constructor(name) { + this.name = name; + this.sides = 0; + } + area() { + return 0; + } + get label() { + return `${this.name}/${this.sides}`; + } +} +class Square extends Shape { + constructor(size) { + super("square"); + this.sides = 4; + this.size = size; + } + area() { + return this.size * this.size; + } +} +class Tile extends Square { + constructor(size, color) { + super(size); + this.color = color; + } +} +Shape.prototype.kind = "shape"; +Square.prototype.corner = "right"; + +function read(value, key) { + return value[key]; +} +function describe(value) { + const parts = []; + for (const key of ["name", "sides", "size", "color", "kind", "corner", "label", "missing"]) + parts.push(`${key}=${String(read(value, key))}`); + parts.push(`in:${"area" in value},${"kind" in value},${"extra" in value}`); + parts.push(`own:${Object.hasOwn(value, "kind")},${Object.hasOwn(value, "size")}`); + return parts.join(" "); +} + +const shapes = [new Shape("dot"), new Square(3), new Tile(2, "red")]; +for (const shape of shapes) console.log(describe(shape), shape.area(), shape.label); + +const untyped = shapes.map((shape) => /** @type {any} */ (shape)); +untyped[1].area = () => -1; +untyped[2].kind = "tile"; +untyped[2].extra = 7; +try { + untyped[0].label = "plain"; +} catch (error) { + console.log(error.name); +} +Object.defineProperty(untyped[0], "label", { value: "plain", enumerable: true }); +for (const shape of untyped) { + console.log(describe(shape), typeof shape.area, shape.area(), shape.kind, shape.extra); + console.log(read(shape, "area") === Shape.prototype.area, read(shape, "label")); +} +const area = untyped[2].area; +console.log(area.call(new Tile(5, "blue")), Object.keys(untyped[2]).join(",")); diff --git a/tests/harness/corpus-costs/6943-js-class-branch-bags.js.json b/tests/harness/corpus-costs/6943-js-class-branch-bags.js.json new file mode 100644 index 000000000..9a69600b1 --- /dev/null +++ b/tests/harness/corpus-costs/6943-js-class-branch-bags.js.json @@ -0,0 +1 @@ +{ "plain": 4400, "san": 4900 }