Read a class branch's own-property bag from its receiver

This commit is contained in:
Chris Tate
2026-10-21 19:31:56 -05:00
parent f8f315cc03
commit 62ddc2fd35
5 changed files with 296 additions and 29 deletions
@@ -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: [] } }],
});
});
@@ -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<IrStmt, { kind: "fieldSet" }>;
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<string, IrFunction>();
private readonly generated = new Set<IrFunction>();
/** 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]:<class>(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<IrStmt, { kind: "fieldSet" }> = {
...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 [
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6943-js-class-branch-bags.js"
],
"diags": []
}
+67
View File
@@ -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(","));
@@ -0,0 +1 @@
{ "plain": 4400, "san": 4900 }