mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-08 08:44:37 +08:00
Keep a Map or Set handed to a record view as the collection
A Map or Set is a live collection: a record copy answers the members it copied
once and misses every later write, so `function freeId(seen: { has(k: string):
boolean })` called with a Map must answer through the Map.
The collection now carries identity into the record view, and the view is
registered as a collection view: it keeps checked identity without protocol
dispatch, because there is no stored member to read. Its method calls dispatch
on the collection at runtime (`scr_native_map_invoke`), and `size` reads the
collection's own member.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8e495686e1
commit
b716775730
@@ -190,6 +190,9 @@ function protocolSignature(
|
||||
): ProtocolSignature | null {
|
||||
// A guarded optional step's receiver still carries its nullish arms.
|
||||
const recvTs = lowerer.checker.getNonNullableType(lowerer.typeOf(access.expression));
|
||||
// A record view of a Map or Set has no stored member to read: the call
|
||||
// dispatches on the collection at runtime instead.
|
||||
if (lowerer.classCollectionView(recvTs)) return null;
|
||||
const property = lowerer.checker.getPropertyOfType(recvTs, access.name.text);
|
||||
if (!property) return null;
|
||||
const declarations = property.declarations;
|
||||
|
||||
@@ -13,6 +13,11 @@ export class ClassProtocols {
|
||||
/** The value types of the string-keyed dictionaries that keep checked
|
||||
* identity (see markDictionaryViews). */
|
||||
private readonly dictionaryViews = new Set<ts.Type>();
|
||||
/** The structural types a lib Map or Set flows into while it answers one
|
||||
* of their members (see markIdentityFlows). They keep checked identity
|
||||
* without protocol dispatch: their members are invoked on the collection
|
||||
* itself, not read from a property bag. */
|
||||
private readonly collectionViews = new Set<ts.Symbol>();
|
||||
|
||||
constructor(
|
||||
private readonly checker: ts.TypeChecker,
|
||||
@@ -425,9 +430,16 @@ export class ClassProtocols {
|
||||
const added = [...marked].filter((symbol) => !before.has(symbol));
|
||||
// A copy of an instance drops the methods its class defines, so a view
|
||||
// that exposes one of them stays live wherever such an instance may
|
||||
// arrive.
|
||||
// arrive. A record view of a collection needs no protocol: its checked
|
||||
// identity is the collection itself, and its members are the
|
||||
// collection's own methods, which protocol dispatch would read from a
|
||||
// property bag instead of invoking.
|
||||
const kept = new Set(
|
||||
added.filter((symbol) => methodViews.has(symbol) || this.needsView(symbol, marked, facts)),
|
||||
added.filter(
|
||||
(symbol) =>
|
||||
!this.collectionViews.has(symbol) &&
|
||||
(methodViews.has(symbol) || this.needsView(symbol, marked, facts)),
|
||||
),
|
||||
);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
@@ -635,6 +647,14 @@ export class ClassProtocols {
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether one of `names` is a member the collection type itself
|
||||
* declares (`has`, `get`, `size`, `keys`, ...). */
|
||||
private exposesCollectionMember(collection: ts.Type, names: ReadonlySet<string>): boolean {
|
||||
return this.checker
|
||||
.getPropertiesOfType(collection)
|
||||
.some((property) => names.has(ts.symbolName(property)));
|
||||
}
|
||||
|
||||
/** Whether class `symbol` declares a get or set accessor for one of
|
||||
* `names`. */
|
||||
private hasAccessorFor(symbol: ts.Symbol, names: ReadonlySet<string>): boolean {
|
||||
@@ -919,6 +939,41 @@ export class ClassProtocols {
|
||||
.find((index) => (index.keyType.flags & ts.TypeFlags.String) !== 0)?.type;
|
||||
}
|
||||
|
||||
/** A lib Map, Set, ReadonlyMap or ReadonlySet: a live collection. Handed
|
||||
* to a structural type that spells one of its members, a copy would answer
|
||||
* from its own fields and miss later writes to the collection, so the view
|
||||
* stays the collection itself (checked-dynamic identity). */
|
||||
private isBuiltinCollection(type: ts.Type): boolean {
|
||||
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
|
||||
const target = type.isTypeReference() ? type.getTarget() : type;
|
||||
const symbol = target?.getSymbol() ?? type.getSymbol();
|
||||
if (symbol === undefined) return false;
|
||||
const name = ts.symbolName(symbol);
|
||||
if (name !== "Map" && name !== "Set" && name !== "ReadonlyMap" && name !== "ReadonlySet")
|
||||
return false;
|
||||
return (
|
||||
(symbol.flags & ts.SymbolFlags.Class) === 0 &&
|
||||
symbol.declarations.length > 0 &&
|
||||
symbol.declarations.every(
|
||||
(declaration) => ts.getSourceFileOfNode(declaration).isDeclarationFile,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** A builtin collection with an `any` or `unknown` type argument is
|
||||
* UNTYPED (`new Map()`). It cannot back a structural view narrower than
|
||||
* the values it may yield — `get` answers `any`, which the view declares
|
||||
* as its own member type — so it keeps the record-shape refusal rather
|
||||
* than carrying identity into the view. */
|
||||
private isUntypedCollection(type: ts.Type): boolean {
|
||||
if (!type.isTypeReference()) return true;
|
||||
const args = this.checker.getTypeArguments(type);
|
||||
return (
|
||||
args.length === 0 ||
|
||||
args.some((arg) => (arg.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0)
|
||||
);
|
||||
}
|
||||
|
||||
private isProgramClassInstance(type: ts.Type): boolean {
|
||||
const symbol = type.getSymbol();
|
||||
return (
|
||||
@@ -941,6 +996,11 @@ export class ClassProtocols {
|
||||
return element !== undefined && this.carriesIdentity(element, marked);
|
||||
}
|
||||
if (this.isProgramClassInstance(type)) return true;
|
||||
// A Map or Set is a live collection: a record built from it answers the
|
||||
// members it copies once and misses every later write. An untyped
|
||||
// `new Map()` cannot back a narrower view (see isUntypedCollection), so
|
||||
// only a fully typed collection carries identity into the view.
|
||||
if (this.isBuiltinCollection(type)) return !this.isUntypedCollection(type);
|
||||
// A live Pick or Omit view of a program class holds the instance itself.
|
||||
if (this.pickOfProgramClass(type) && this.usesCheckedIdentity(type)) return true;
|
||||
const symbol = type.getSymbol();
|
||||
@@ -1013,6 +1073,17 @@ export class ClassProtocols {
|
||||
if (from === symbol) return false;
|
||||
const facts = this.facts;
|
||||
if (facts !== null) {
|
||||
// A Map or Set that reaches the view, which answers one of its
|
||||
// members: a record copy has no live backing behind `has`, `get` or
|
||||
// `size`, and Node's answer changes with every later write. The view
|
||||
// keeps checked identity, but not protocol dispatch: its members are
|
||||
// the collection's own methods.
|
||||
if (this.isBuiltinCollection(source)) {
|
||||
const names = new Set(
|
||||
this.checker.getPropertiesOfType(target).map((property) => ts.symbolName(property)),
|
||||
);
|
||||
if (this.exposesCollectionMember(source, names)) this.collectionViews.add(symbol);
|
||||
}
|
||||
// The flow: the source's classes and marked type reach `symbol`.
|
||||
const origins = facts.origins.get(symbol) ?? new Set<ts.Symbol>();
|
||||
for (const origin of this.originsOf(source, facts)) origins.add(origin);
|
||||
@@ -1029,6 +1100,18 @@ export class ClassProtocols {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether `type` is a structural view that a Map or Set reaches and that
|
||||
* answers through the collection's own methods (see markIdentityFlows).
|
||||
* Such a view keeps checked identity but never protocol dispatch: there
|
||||
* is no stored member to read, and the collection answers the call. */
|
||||
collectionView(type: ts.Type): boolean {
|
||||
this.index();
|
||||
if (type.isUnionType() || type.isIntersectionType())
|
||||
return ts.constituentTypes(type).some((part) => this.collectionView(part));
|
||||
const symbol = type.getSymbol();
|
||||
return symbol !== undefined && this.collectionViews.has(symbol);
|
||||
}
|
||||
|
||||
/** Class-backed protocols require live receiver dispatch. Other interfaces,
|
||||
* including factories backed by static methods and ordinary records of
|
||||
* closures, retain their fixed native layouts. */
|
||||
@@ -1048,9 +1131,12 @@ export class ClassProtocols {
|
||||
// it (a literal whose methods read `this`) or when it receives existing
|
||||
// objects (markDictionaryViews).
|
||||
const dictionaryValue = this.dictionaryValueType(type);
|
||||
const view = type.getSymbol();
|
||||
const answer =
|
||||
this.protocol(type) ||
|
||||
(dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue));
|
||||
(dictionaryValue !== undefined && this.dictionaryViews.has(dictionaryValue)) ||
|
||||
// A record view of a Map or Set answers through the collection itself.
|
||||
(view !== undefined && this.collectionViews.has(view));
|
||||
this.answers.set(type, answer);
|
||||
return answer;
|
||||
}
|
||||
|
||||
@@ -5119,6 +5119,13 @@ export class Lowerer {
|
||||
return this.classProtocols.usesCheckedIdentity(type);
|
||||
}
|
||||
|
||||
/** A record view a Map or Set answers: it keeps protocol identity but
|
||||
* calls the collection's own methods, never a stored member. */
|
||||
classCollectionView(type: ts.Type): boolean {
|
||||
this.classProtocols ??= new ClassProtocols(this.checker, this.moduleOrder);
|
||||
return this.classProtocols.collectionView(type);
|
||||
}
|
||||
|
||||
/** Declared class fields may store a DYN value only when its static type
|
||||
* is `unknown` or `object` (also with `null` or `undefined`, or an array
|
||||
* of those), names checked protocol identity, is an owned runtime
|
||||
|
||||
@@ -131,3 +131,23 @@ console.log(view.tick());
|
||||
`);
|
||||
expect(kinds["view"]).toBe("dyn");
|
||||
});
|
||||
|
||||
test("a Map received as a record with one of its members stays the collection", async () => {
|
||||
const kinds = await globalKinds(`
|
||||
type Exists = { has(key: string): boolean };
|
||||
const byId = new Map<string, number>();
|
||||
const view: Exists = byId;
|
||||
console.log(view.has("a"), byId.has("a"));
|
||||
`);
|
||||
expect(kinds["view"]).toBe("dyn");
|
||||
});
|
||||
|
||||
test("a Set received as a record with one of its members stays the collection", async () => {
|
||||
const kinds = await globalKinds(`
|
||||
type Exists = { has(key: string): boolean };
|
||||
const seen = new Set<string>();
|
||||
const view: Exists = seen;
|
||||
console.log(view.has("a"), seen.has("a"));
|
||||
`);
|
||||
expect(kinds["view"]).toBe("dyn");
|
||||
});
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6956-collection-record-views.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// A Map or Set handed to a record type that spells one of its members stays
|
||||
// the collection itself: later writes are visible through the view, and the
|
||||
// view casts back to the same collection.
|
||||
type Exists = { has(key: string): boolean };
|
||||
|
||||
function freeId(existing: Exists): string {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = "id" + i;
|
||||
if (!existing.has(id)) return id;
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
const byId = new Map<string, number>();
|
||||
byId.set("id0", 1);
|
||||
console.log(freeId(byId));
|
||||
byId.set("id1", 2);
|
||||
console.log(freeId(byId));
|
||||
|
||||
const seen = new Set<string>();
|
||||
seen.add("id0");
|
||||
seen.add("id1");
|
||||
seen.add("id2");
|
||||
console.log(freeId(seen));
|
||||
|
||||
// The view is live: entries added after the conversion are visible.
|
||||
const view: Exists = byId;
|
||||
byId.set("id3", 3);
|
||||
console.log(view.has("id3"), byId.has("id3"));
|
||||
console.log((view as unknown as Map<string, number>) === byId);
|
||||
const setView: Exists = seen;
|
||||
seen.add("id3");
|
||||
console.log(setView.has("id3"), (setView as unknown as Set<string>) === seen);
|
||||
|
||||
// A record literal of the same shape keeps working.
|
||||
console.log(freeId({ has: (key: string) => key === "id0" }));
|
||||
|
||||
// A member Map answers without a stored field.
|
||||
type Sized = { readonly size: number };
|
||||
const sized: Sized = byId;
|
||||
byId.set("id4", 4);
|
||||
console.log(sized.size, byId.size);
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 2600, "san": 5500 }
|
||||
Reference in New Issue
Block a user