Devirtualize calls by the classes that are constructed (#907)

This commit is contained in:
Chris Tate
2026-10-12 19:06:55 -05:00
committed by GitHub
9 changed files with 661 additions and 41 deletions
+10 -3
View File
@@ -654,9 +654,16 @@ export function emitClassShapes(
nw.push(` call void @scr_obj_lifetime_note(ptr %o)`);
}
if (meta.hierarchy) {
// The vtable word is written once, here, and never changes while the
// object lives: its store and the virtual-call loads carry
// !invariant.group. A recycled block may have held an object of
// another class, so the result is laundered first: every lifetime
// gets a fresh pointer identity for the group.
host.declare(`declare ptr @llvm.launder.invariant.group.p0(ptr)`);
nw.push(
` %vtp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 1`,
` store ptr @${mangleVtInstance(cls.name)}, ptr %vtp`,
` %o.vt = call ptr @llvm.launder.invariant.group.p0(ptr %o)`,
` %vtp = getelementptr inbounds %${struct}, ptr %o.vt, i64 0, i32 1`,
` store ptr @${mangleVtInstance(cls.name)}, ptr %vtp, !invariant.group !{}`,
);
}
if (isEmitterRooted) {
@@ -672,7 +679,7 @@ export function emitClassShapes(
);
}
nw.push(...undefFieldInits(host, meta, nullable));
nw.push(` ret ptr %o`, `}`, ``);
nw.push(` ret ptr ${meta.hierarchy ? "%o.vt" : "%o"}`, `}`, ``);
defs.push(...nw);
if (traced) {
@@ -999,7 +999,8 @@ export function emitCallExpr(
B.line(
`${vtp} = getelementptr inbounds %${classStructSym(e.className)}, ptr ${recv}, i64 0, i32 1`,
);
B.line(`${vt} = load ptr, ptr ${vtp}`);
// Set once at allocation (classes.ts launders each new object).
B.line(`${vt} = load ptr, ptr ${vtp}, !invariant.group !{}`);
B.line(
`${fnp} = getelementptr inbounds %${mangleVtStruct(meta.root.def.name)}, ptr ${vt}, i64 0, i32 ${slotIdx + 1}`,
);
@@ -0,0 +1,230 @@
import * as ts from "../ts7/adapter.js";
import type { IrExpr, IrFunction } from "../../ir/ir.js";
import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
import type { ClassInfo } from "./lower-classes.js";
import type { Lowerer } from "./lowerer.js";
type VirtualCall = IrExpr & { kind: "virtualCall" };
export interface DevirtualizeFacts {
/** Bodies the reachability worklist retained. */
reachable: ReadonlySet<string>;
/** Classes with a constructor edge other than a subclass's super(). */
instantiated: ReadonlySet<string>;
/** LIBRARY mode: exported classes are open to the host. */
library: boolean;
files: readonly ts.SourceFile[];
}
/** Post-reachability devirtualization. A `virtualCall` on static class C
* becomes a direct call when exactly one implementation can run:
*
* - no override of the nearest declaration is reachable below C (the
* original rule), or
* - every class in C's subtree that may be instantiated EXACTLY resolves
* the method to the same declaration. Classes reached only through a
* subclass's super() never carry their own vtable, so an abstract base
* with one implementation, or a concrete base that is never itself
* constructed, dispatches to its single constructed implementation.
*
* Instantiation is conservative: any constructor edge other than super()
* counts (`new`, class values, dynamic and library construction), as does
* every class some retained IR allocates (`new`) or exposes as a class
* value (its whole subtree: a construct thunk exists for it). Those are
* the only native allocation paths; runtime-provided classes always
* count. Subtrees whose classes the compiler cannot see completely
* (decorated classes, dynamic bases, exported library classes) stay
* virtual. Methods replaceable at runtime never reach this pass: their
* calls take the checked property path instead of a virtualCall. */
export function devirtualizeVirtualCalls(
lowerer: Lowerer,
functions: IrFunction[],
facts: DevirtualizeFacts,
): void {
const sites = new ConstructedClasses(lowerer, functions, facts);
for (const fn of functions)
fn.body = transformStmtList(fn.body, {
stmt: (statement) => statement,
expr: (expression) =>
expression.kind === "virtualCall" ? sites.devirtualize(expression) : expression,
});
}
class ConstructedClasses {
private readonly lowered = new Set<string>();
/** Classes some retained IR allocates or exposes as a class value. */
private readonly allocated = new Set<string>();
private readonly exportedNames: ReadonlySet<string>;
private readonly openMemo = new Map<ClassInfo, boolean>();
private readonly subtreeMemo = new Map<ClassInfo, ClassInfo[]>();
constructor(
private readonly lowerer: Lowerer,
functions: readonly IrFunction[],
private readonly facts: DevirtualizeFacts,
) {
const subtree = (name: string): void => {
this.allocated.add(name);
const info = lowerer.classes.get(name);
if (info) for (const c of this.descendants(info)) this.allocated.add(c.def.name);
};
for (const fn of functions) {
this.lowered.add(fn.name);
everyStmtList(fn.body, {
stmt: () => true,
expr: (e) => {
if (e.kind === "new") this.allocated.add(e.className);
else if (e.kind === "classRef") subtree(e.className);
else if (e.kind === "newValue" && e.callee.type.kind === "classval")
subtree(e.callee.type.className);
return true;
},
});
}
this.exportedNames = facts.library ? exportedNames(facts.files) : new Set();
}
devirtualize(e: VirtualCall): IrExpr {
const lowerer = this.lowerer;
const info = lowerer.classes.get(e.className);
const nearest = info && lowerer.findMethodOn(info, e.method);
if (!info || !nearest) return e;
if (
nearest.sig.abstract !== true &&
!this.descendants(info).some(
(candidate) =>
candidate !== info &&
candidate.methods.has(e.method) &&
this.facts.reachable.has(`%${candidate.def.name}.${e.method}`),
)
)
return this.direct(e, info, nearest.declarer);
if (this.open(info)) return e;
let target: ClassInfo | null = null;
for (const candidate of this.descendants(info)) {
if (!this.mayInstantiate(candidate)) continue;
const found = lowerer.findMethodOn(candidate, e.method);
if (!found || found.sig.abstract === true) return e;
if (target !== null && target !== found.declarer) return e;
target = found.declarer;
}
if (target === null || runtimeProvided(target)) return e;
const callee = `%${target.def.name}.${e.method}`;
if (!this.facts.reachable.has(callee) || !this.lowered.has(callee)) return e;
return this.direct(e, info, target);
}
private direct(e: VirtualCall, info: ClassInfo, target: ClassInfo): IrExpr {
const receiver = e.args[0]!;
const typed: IrExpr =
target === info || this.lowerer.isSubclassOf(info.def.name, target.def.name)
? this.lowerer.upcastTo(receiver, target.def.name)
: // Every instance that can reach this site is a `target`: the
// reinterpret is proven by the instantiation set, like a
// checker-narrowed downcast.
{
kind: "downcast",
value: receiver,
type: { kind: "object", className: target.def.name },
loc: receiver.loc,
};
return {
kind: "call",
callee: `%${target.def.name}.${e.method}`,
args: [typed, ...e.args.slice(1)],
type: e.type,
loc: e.loc,
};
}
/** The class and every class below it, in the isSubclassOf sense
* (generic instances included). */
private descendants(info: ClassInfo): ClassInfo[] {
let out = this.subtreeMemo.get(info);
if (out === undefined) {
out = [info];
for (const candidate of this.lowerer.classes.values())
if (this.lowerer.isSubclassOf(candidate.def.name, info.def.name)) out.push(candidate);
this.subtreeMemo.set(info, out);
}
return out;
}
private mayInstantiate(info: ClassInfo): boolean {
return (
this.facts.instantiated.has(info.def.name) ||
this.allocated.has(info.def.name) ||
runtimeProvided(info)
);
}
/** True when C's subtree may hold instances the compiler cannot
* enumerate: a decorator can replace a class, a dynamic base can change
* the chain, and a library host can construct exported classes. */
private open(info: ClassInfo): boolean {
const memo = this.openMemo.get(info);
if (memo !== undefined) return memo;
const open = this.descendants(info).some(
(c) =>
c.classDecorators !== undefined ||
c.callableBase !== undefined ||
c.factoryBaseExpression !== undefined ||
c.localClass?.baseExpression !== undefined ||
(this.facts.library && this.exported(c)),
);
this.openMemo.set(info, open);
return open;
}
private exported(info: ClassInfo): boolean {
const decl = info.decl;
if (!decl) return false;
if ((ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Export) !== 0) return true;
const name =
decl.name?.text ??
(ts.isVariableDeclaration(decl.parent) && ts.isIdentifier(decl.parent.name)
? decl.parent.name.text
: undefined);
if (name === undefined) return false;
if (ts.isVariableDeclaration(decl.parent)) {
const statement = decl.parent.parent?.parent;
if (
statement !== undefined &&
ts.isVariableStatement(statement) &&
(ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export) !== 0
)
return true;
}
return this.exportedNames.has(name);
}
}
function runtimeProvided(info: ClassInfo): boolean {
return (
info.decl === null ||
info.builtinError === true ||
info.builtinEmitter === true ||
info.builtinStream !== undefined
);
}
/** Local names a module exports through `export { ... }` or
* `export default name` (declarations carry their own modifier). */
function exportedNames(files: readonly ts.SourceFile[]): ReadonlySet<string> {
const names = new Set<string>();
for (const sf of files)
for (const statement of sf.statements) {
if (
ts.isExportDeclaration(statement) &&
statement.moduleSpecifier === undefined &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
) {
for (const element of statement.exportClause.elements)
names.add((element.propertyName ?? element.name).text);
} else if (ts.isExportAssignment(statement) && ts.isIdentifier(statement.expression)) {
names.add(statement.expression.text);
}
}
return names;
}
@@ -7594,7 +7594,7 @@ export function superCallStmt(
info.decl ?? info.ctor!,
)[0]!;
}
lowerer.noteEdge(`%${base.def.name}.constructor`);
lowerer.noteSuperConstructorEdge(`%${base.def.name}.constructor`);
return {
kind: "exprStmt",
expr: {
@@ -60,7 +60,7 @@ import {
pathModuleValue,
utilTypesModuleValue,
} from "./lower-builtin-values.js";
import { transformStmtList } from "../../ir/traverse.js";
import { devirtualizeVirtualCalls } from "./devirtualize.js";
import { RuntimeOptionalProvenance } from "./runtime-optional-provenance.js";
import { presentAtUse, reassignedBindings } from "./runtime-optional-guards.js";
import { NarrowingStability } from "./narrowing-stability.js";
@@ -2465,6 +2465,9 @@ export class Lowerer {
* can contain any retained descendant, but importing its declaration
* alone does not make every descendant constructible. */
private constructedClasses: Set<string> | null = null;
/** Set while a subclass constructor's super() call notes its base
* constructor edge: that edge does not instantiate the base exactly. */
private superConstructorEdge = false;
private readonly virtualEdges = new Map<ClassInfo, Set<string>>();
classCanBeConstructed(info: ClassInfo): boolean {
@@ -6050,11 +6053,17 @@ export class Lowerer {
};
const reachable = new Set<string>();
this.constructedClasses = new Set();
// Every constructor edge except super() may create an instance of
// exactly that class (`new C`, class values, library roots, dynamic
// construction, ...).
const instantiated = new Set<string>();
const queue: string[] = [];
const loweredUnits = new Map<string, IrFunction>();
const initFunctions: IrFunction[] = [];
const instanceFunctions: IrFunction[] = [];
this.onEdge = (name: string): void => {
if (!this.superConstructorEdge && name.endsWith(".constructor"))
instantiated.add(name.slice(1, -".constructor".length));
if (reachable.has(name)) return;
reachable.add(name);
if (units.has(name)) queue.push(name);
@@ -6284,39 +6293,14 @@ export class Lowerer {
...this.liftedFns,
...this.implicitFns,
];
// The worklist can leave a virtual site with only its base method.
// The worklist can leave a virtual site with a single implementation.
// Devirtualize after construction/override reachability has settled.
for (const fn of functions)
fn.body = transformStmtList(fn.body, {
stmt: (statement) => statement,
expr: (expression) => {
if (expression.kind !== "virtualCall") return expression;
const info = this.classes.get(expression.className);
const method = info && this.findMethodOn(info, expression.method);
if (
!method ||
method.sig.abstract ||
[...this.classes.values()].some(
(candidate) =>
this.isSubclassOf(candidate.def.name, expression.className) &&
candidate.def.name !== expression.className &&
candidate.methods.has(expression.method) &&
reachable.has(`%${candidate.def.name}.${expression.method}`),
)
)
return expression;
return {
kind: "call",
callee: `%${method.declarer.def.name}.${expression.method}`,
args: [
this.upcastTo(expression.args[0]!, method.declarer.def.name),
...expression.args.slice(1),
],
type: expression.type,
loc: expression.loc,
};
},
});
devirtualizeVirtualCalls(this, functions, {
reachable,
instantiated,
library: extraRoots !== undefined,
files: parts.map((fp) => fp.sf),
});
this.reachableForArtifacts = reachable;
return { reachable, result: this.finishModule(functions) };
}
@@ -6327,6 +6311,17 @@ export class Lowerer {
if (this.onEdge) this.onEdge(name);
}
/** A subclass constructor's super() edge: the base constructor runs, but
* no instance of exactly the base class is created. */
noteSuperConstructorEdge(name: string): void {
this.superConstructorEdge = true;
try {
this.noteEdge(name);
} finally {
this.superConstructorEdge = false;
}
}
/** Discovery hook for virtual dispatch: a virtualCall on `info`'s static
* class reaches the nearest declaration at/above it plus every override
* on a STRICT descendant (receivers of sibling branches can't flow into
@@ -0,0 +1,134 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, compileLibrary } from "../src/index.js";
async function emitLlvm(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-constructed-dispatch-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ll");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" });
expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true);
return await readFile(outPath, "utf8");
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** The vtable slot load a virtual call of `method` emits. */
const slotLoad = (method: string): RegExp => new RegExp(`= load ptr, ptr %t\\d+ ; ${method}\\n`);
const directCall = (cls: string, method: string): RegExp =>
new RegExp(`call double @sc_bf__x25_${cls}_${method}\\(`);
const loop = `
function run(s: Shape, n: number): number {
let t = 0;
for (let i = 0; i < n; i++) t += s.area(i & 1023);
return t;
}
`;
test("an abstract base with one constructed implementation calls it directly", async () => {
const llvm = await emitLlvm(`
abstract class Shape { abstract area(x: number): number; }
class Circle extends Shape { r = 2; area(x: number): number { return x * this.r + 1; } }
class Square extends Shape { s = 3; area(x: number): number { return x * this.s; } }
function make(): Shape { return new Circle(); }
${loop}
console.log(run(make(), 1000));
`);
expect(llvm).toMatch(directCall("Circle", "area"));
expect(llvm).not.toMatch(slotLoad("area"));
});
test("a concrete base reached only through super() calls the subclass directly", async () => {
const llvm = await emitLlvm(`
class Shape { f = 1; area(x: number): number { return x * this.f; } }
class Circle extends Shape { r = 2; area(x: number): number { return x * this.r + 1; } }
function make(): Shape { return new Circle(); }
${loop}
console.log(run(make(), 1000));
`);
expect(llvm).toMatch(directCall("Circle", "area"));
expect(llvm).not.toMatch(slotLoad("area"));
});
test("two constructed implementations keep the vtable call", async () => {
const llvm = await emitLlvm(`
abstract class Shape { abstract area(x: number): number; }
class Circle extends Shape { r = 2; area(x: number): number { return x * this.r + 1; } }
class Square extends Shape { s = 3; area(x: number): number { return x * this.s; } }
${loop}
console.log(run(new Circle(), 1000), run(new Square(), 1000));
`);
expect(llvm).toMatch(slotLoad("area"));
});
test("a constructed concrete base keeps the vtable call", async () => {
const llvm = await emitLlvm(`
class Shape { f = 1; area(x: number): number { return x * this.f; } }
class Circle extends Shape { r = 2; area(x: number): number { return x * this.r + 1; } }
${loop}
console.log(run(new Circle(), 1000), run(new Shape(), 1000));
`);
expect(llvm).toMatch(slotLoad("area"));
});
test("a class value of a subclass keeps the vtable call", async () => {
const llvm = await emitLlvm(`
abstract class Shape { abstract area(x: number): number; }
class Circle extends Shape { r = 2; area(x: number): number { return x * this.r + 1; } }
class Square extends Shape { s = 3; area(x: number): number { return x * this.s; } }
const kinds: unknown[] = [Square];
${loop}
console.log(run(new Circle(), 1000), kinds.length);
`);
expect(llvm).toMatch(slotLoad("area"));
});
test("library mode keeps the vtable call for an exported class", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-constructed-dispatch-lib-"));
try {
await writeFile(
join(dir, "entry.ts"),
`
export abstract class Shape { abstract area(x: number): number; }
export class Circle extends Shape { r = 2; area(x: number): number { return x * this.r + 1; } }
abstract class Inner { abstract size(x: number): number; }
class Leaf extends Inner { size(x: number): number { return x + 1; } }
function measure(s: Inner, x: number): number { return s.size(x); }
${loop}
export function result(): number { return run(new Circle(), 10) + measure(new Leaf(), 1); }
`,
);
const profilePath = join(dir, "profile.json");
await writeFile(
profilePath,
JSON.stringify({
profile_format: 1,
name: "constructed-dispatch",
entry: "entry.ts",
emission: "llvm",
abi: {
prefix: "cd_",
init_symbol: "cd_init",
sink_register_symbol: "cd_sink",
collect_symbol: "cd_collect",
},
exports: [{ export: "result", symbol: "cd_result", params: [], returns: "f64" }],
}),
);
const result = await compileLibrary({ profilePath, outDir: join(dir, "out") });
expect(result.ok, JSON.stringify(result.ok ? [] : result.diagnostics)).toBe(true);
if (!result.ok) return;
const llvm = await readFile(result.llvmPath, "utf8");
expect(llvm).toMatch(slotLoad("area"));
expect(llvm).not.toMatch(slotLoad("size"));
expect(llvm).toMatch(directCall("Leaf", "size"));
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/constructed-class-dispatch.ts"
],
"diags": []
}
+243
View File
@@ -0,0 +1,243 @@
// Method calls through a base-typed receiver when only some classes in the
// hierarchy are ever constructed: an abstract base with one constructed
// implementation, a concrete base reached only through super(), classes
// constructed through class values, generic subclasses, Error subclasses,
// accessors, template methods, and super calls. Classes declared but never
// constructed or referenced stay out of the program.
// Abstract base: Square is declared but never constructed.
abstract class Shape {
abstract area(x: number): number;
describe(): string {
return `area(2)=${this.area(2)}`;
}
}
class Circle extends Shape {
r = 2;
area(x: number): number {
return x * this.r + 1;
}
}
class Square extends Shape {
s = 3;
area(x: number): number {
return x * this.s;
}
}
function makeShape(): Shape {
return new Circle();
}
function sumAreas(s: Shape, n: number): number {
let t = 0;
for (let i = 0; i < n; i++) t += s.area(i & 7);
return t;
}
console.log(sumAreas(makeShape(), 100), makeShape().describe());
// Concrete base never constructed itself: its constructor runs only
// through super().
class Counter {
base = 10;
label: string;
constructor(label: string) {
this.label = label;
}
step(x: number): number {
return x + this.base;
}
get kind(): string {
return "counter";
}
}
class Doubler extends Counter {
constructor() {
super("doubler");
}
step(x: number): number {
return 2 * x + super.step(0);
}
get kind(): string {
return `doubling ${super.kind}`;
}
}
function runCounter(c: Counter): string {
let t = 0;
for (let i = 0; i < 5; i++) t += c.step(i);
return `${c.label} ${c.kind} ${t}`;
}
console.log(runCounter(new Doubler()));
// Middle class inherits the only implementation from the root.
class Animal {
sound(): string {
return "...";
}
}
class Dog extends Animal {
sound(): string {
return "woof";
}
}
class Puppy extends Dog {}
class Cat extends Animal {
sound(): string {
return "meow";
}
}
const animals: Animal[] = [new Puppy(), new Puppy()];
console.log(animals.map((a) => a.sound()).join(" "));
// Two constructed implementations keep dynamic dispatch.
abstract class Op {
abstract apply(x: number): number;
}
class Inc extends Op {
apply(x: number): number {
return x + 1;
}
}
class Neg extends Op {
apply(x: number): number {
return -x;
}
}
const ops: Op[] = [new Inc(), new Neg(), new Inc()];
console.log(ops.reduce((acc, op) => op.apply(acc), 5));
// Construction through a class value counts as construction.
abstract class Greeter {
abstract greet(): string;
}
class Hello extends Greeter {
greet(): string {
return "hello";
}
}
class Hi extends Greeter {
greet(): string {
return "hi";
}
}
function construct(k: typeof Greeter2): Greeter {
return new k();
}
class Greeter2 extends Greeter {
greet(): string {
return "greeter2";
}
}
class Hey extends Greeter2 {
greet(): string {
return "hey";
}
}
function greetWith(g: Greeter): string {
return g.greet();
}
console.log(greetWith(construct(Hey)), greetWith(new Hello()));
// Generic subclasses.
abstract class Box<T> {
value: T;
constructor(value: T) {
this.value = value;
}
abstract show(): string;
}
class NumBox extends Box<number> {
show(): string {
return `n:${this.value}`;
}
}
class StrBox extends Box<string> {
show(): string {
return `s:${this.value}`;
}
}
const boxes: Box<number>[] = [new NumBox(4)];
console.log(boxes.map((b) => b.show()).join());
// Template method: the base loop calls this.step() on the only
// constructed subclass.
abstract class Task {
run(n: number): number {
let t = 0;
for (let i = 0; i < n; i++) t += this.step(i);
return t;
}
abstract step(i: number): number;
}
class SquareTask extends Task {
step(i: number): number {
return i * i;
}
}
class CubeTask extends Task {
step(i: number): number {
return i * i * i;
}
}
console.log(new SquareTask().run(10));
// Error subclasses with an overridden method.
class AppError extends Error {
code(): number {
return 1;
}
}
class NotFound extends AppError {
code(): number {
return 404;
}
}
function codeOf(e: AppError): number {
return e.code();
}
try {
throw new NotFound("missing");
} catch (e) {
if (e instanceof AppError) console.log(codeOf(e), e.message);
}
// Abstract accessor with one constructed implementation.
abstract class Named {
abstract get name(): string;
abstract set name(v: string);
}
class Person extends Named {
private n = "ann";
get name(): string {
return this.n;
}
set name(v: string) {
this.n = v.toUpperCase();
}
}
class Robot extends Named {
get name(): string {
return "robot";
}
set name(_v: string) {}
}
const who: Named = new Person();
who.name = "bob";
console.log(who.name);
// A second implementation constructed only on a path that runs later.
abstract class Reader {
abstract read(): string;
}
class FileReader extends Reader {
read(): string {
return "file";
}
}
class NetReader extends Reader {
read(): string {
return "net";
}
}
function pick(flag: boolean): Reader {
return flag ? new NetReader() : new FileReader();
}
console.log(pick(false).read(), pick(true).read());
+7 -3
View File
@@ -90,7 +90,8 @@ class Dog extends Animal {
}
}
const a: Animal = new Dog("rex");
console.log(a.id(), a.speak());
const b: Animal = new Animal("cat");
console.log(a.id(), a.speak(), b.speak());
`,
);
// The devirtualized call is a direct sc_f_ call of Animal's id...
@@ -157,8 +158,9 @@ class LoudCell extends Cell {
}
}
const c: Cell = new LoudCell();
const plain: Cell = new Cell();
c.v = 3;
console.log(c.v, c.label);
console.log(c.v, c.label, plain.label);
`,
);
// The overridden getter dispatches through its slot...
@@ -248,12 +250,14 @@ class SealedBox extends Box {
}
}
const b: Box = new SealedBox();
const plain: Box = new Box();
try {
b.v = 9;
} catch {
console.log("sealed");
}
console.log(b.v);
plain.v = 2;
console.log(b.v, plain.v);
`,
);
// JS shadowing: the derived class's synthesized setter occupies the