复合类型提案
用于表示一组结构化值的 Map 和 Set 的键。
状态
阶段:1
提案人:Ashley Claymore
规范草案:https://tc39.es/proposal-composites
问题
目前 Map 和 Set 始终使用 SameValueZero 来回答“此值是否在此集合中?”。
new Set([42, 42]).size; // 1
const m = new Map();
m.set("hello", "world");
m.get("hello"); // "world";
这意味着,就对象而言,所有对象仅与自身相等。没有能力覆盖此行为,以允许集合中的两个不同对象被视为相等。
const position1 = Object.freeze({ x: 1, y: 4 });
const position2 = Object.freeze({ x: 1, y: 4 });
const positions = new Set([position1, position2]);
positions.size; // 2
当前变通方案
在 JavaScript 中绕过此限制的一种方法是将值扁平化为字符串表示。
const positions = new Set([JSON.stringify(position1), JSON.stringify(position2)]);
positions.size; // 1
这种方法的缺点在于:
- 很容易构造出错误的字符串。例如
JSON.stringify:- 如果对象的键以不同的顺序枚举,会生成不同的字符串。
- 会省略没有 JSON 表示的值,例如函数和
undefined。 - 遇到
BigInt或循环引用时会抛出异常。
- 集合现在包含的是字符串而不是结构化对象。要读取这些值,需要对其进行解析。
或者可以使用两个集合,一个用于跟踪唯一性,另一个用于跟踪值:
const positions = [];
const positionKeys = new Set();
function add(position) {
const asString = JSON.stringify(position);
if (positionKeys.has(asString)) return;
positions.push(position);
positionKeys.add(asString);
}
此方案的缺点如下:
- 代码需要确保这两个集合保持同步。
- 遵循此模式会带来额外的噪音/样板代码。
- 与上述相同,存在将值扁平化为字符串的风险。
提案
引入具有明确定义相等性的内置“复合值”。
[!IMPORTANT] 预期会有变更。随着讨论的继续,以下设计将作为演进的起点。
const pos1 = Composite({ x: 1, y: 4 });
const pos2 = Composite({ x: 1, y: 4 });
pos1 === pos2; // true
const positions = new Set(); // the standard ES Set
positions.add(pos1);
positions.has(pos2); // true
const itemAtPosition = new Map(); // the standard ES Map
itemAtPosition.set(pos1, "book");
itemAtPosition.get(Composite({ x: 1, y: 4 })); // "book"
[!NOTE] 原始提案并非基于 interning,可在提交 1c8c3f2f 中查看。
什么是 'composite'
它是一个对象。
typeof Composite({}); // "object"
它是一组命名值的集合。
const c = Composite({
x: 42,
y: -1,
message: "hello"
});
c.x; // 42
c.y; // -1
c.message; // "hello"
两个具有相同命名值集合的复合体将是同一个对象(通常称为驻留)。
const c1 = Composite({ a: 1, b: 2 });
const c2 = Composite({ a: 1, b: 2 });
c1 === c2; // true
Object.is(c1, c2); // true
该参数不会被转换为复合值;它仅提供值。
const template = { x: 1 };
Composite(template) !== template; // true
参数必须是一个对象。
Composite(null); // throws TypeError 💥
仅使用参数自身的可枚举属性。继承的和不可枚举的属性将被忽略。
const c = Composite({ own: 2, __proto__: { inherited: 1 } });
"inherited" in c; // false
c.own; // 2
自身可枚举的 symbol 键会抛出异常,因为复合对象不能拥有 symbol 键(参见 Symbol keys?)。
Composite({ [Symbol()]: 1 }); // throws TypeError 💥
任何 getter 都会在创建期间立即调用,且恰好调用一次——组合体存储的是返回的值,而非 getter 本身。
let calls = 0;
const c = Composite({
get x() {
calls++;
return 42;
},
});
calls === 1; // true
它们不是一个类。
Object.getPrototypeOf(Composite({})); // null
new Composite({}); // throws TypeError 💥
它们被冻结了。
Object.isFrozen(Composite({})); // true
它们可以包含任何值...
const d = new Date();
const f = () => {};
const s = Symbol();
const u = undefined;
const c = Composite({ d, f, s, u });
c.d === d; // true
c.f === f; // true
c.s === s; // true
"u" in c; // true
c.u; // undefined
……除了 -0,它被 规范化为 0。
const c = Composite({ zero: -0 });
Object.is(c.zero, -0); // false
Object.is(c.zero, 0); // true
什么决定两个输入是否会被驻留为同一个复合对象?
给定两次调用 Composite(a) 和 Composite(b)。如果满足以下条件,第二次调用将返回与第一次调用相同的对象:
a和b都是对象- 否则创建将会失败
a和b必须具有相同数量的可枚举字符串键- 对于
b中的每个可枚举键- 该键必须是字符串
- 否则创建将会失败
- 该键也必须存在于
a中(顺序无关紧要) - 该键的值必须根据
SameValueZero与a中该键的值相等
- 该键必须是字符串
相等的复合对象:
Composite({}) === Composite({});
Composite({ b:2, a:1 }) === Composite({ a:1, b:2 });
Composite({ v:0 }) === Composite({ v:-0 }); // `-0` is normalized to `0`
Composite({ v:NaN }) === Composite({ v:NaN });
Composite({ c: Composite({}) }) === Composite({ c: Composite({}) });
const someObject = {};
Composite({ v:someObject }) === Composite({ v:someObject });
不等复合体:
Composite({ a:1 }) !== Composite({});
Composite({ a:1 }) !== Composite({ a:1, b:undefined });
Composite({ v:{} }) !== Composite({ v:{} });
保证哪些相等性语义?
- 由于复合体被驻留(interned),比较它们只是指针相等,因此总是终止且从不抛出异常。
- 两个复合体的相等性永远不会改变。
- 相等性是一个等价关系:
- 自反性:一个复合体总是等于自身(
c === c)。 - 对称性:如果
c1 === c2则c2 === c1。 - 传递性:如果
c1 === c2且c2 === c3则c1 === c3。
- 自反性:一个复合体总是等于自身(
其他语言
在 Python 中,一个冻结的 dataclass 具有基于值的相等性并且是可哈希的:
from dataclasses import dataclass
@dataclass(frozen=True)
class Position:
x: int
y: int
position1 = Position(x=1, y=4)
position2 = Position(x=1, y=4)
positions = set()
positions.add(position1)
positions.add(position2)
print(len(positions)) # 1
在 Clojure 中,map 具有基于值的相等性,且不依赖于键的顺序:
(def position1 {:x 1 :y 4})
(def position2 {:y 4 :x 1})
(count (set [position1 position2])) ; 1
常见问题
如何检查某个值是否为复合体?
Composite.isComposite(arg) 仅对复合体返回 true。
以复合体作为目标的代理不被视为复合体。
性能预期
创建完成后,比较两个复合体的成本是常数时间;运行时只需比较它们的内存地址。
创建复合体的成本会随着其包含的键数量增加而增加,并且可能受到内部复合体驻留缓存当前 load factor 的影响。
垃圾回收器 (GC) 回收不再使用的复合体所占用的空间也会产生非零成本 - 此成本取决于 GC 的实现。
复合体是否深度不可变?
不一定。复合体是通用容器,因此可以包含任何值。只有当它们包含的所有内容都是深度不可变时,它们才是深度不可变的。
键是否可枚举?
是的,所有键都是:
- enumerable: true
- configurable: false
- writable: false
键是否已排序?
是的。复合体的键是排序的,因此复合体的枚举顺序不依赖于键在参数中出现的顺序。
Object.keys(Composite({ b: 1, a: 2 })); // ["a", "b"]
这赋予了复合对象一种规范形式:Composite({ a: 1, b: 2 }) 和 Composite({ b: 2, a: 1 }) 是同一个对象,具有相同的键顺序。
整数索引的键排在前面,按数值升序排列,与普通对象相同。随后是其余的字符串键,按字典序排序。
Object.keys(Composite({ x: true, 10: true, 2: true, a: true })); // ["2", "10", "a", "x"]
为什么 -0 会被规范化为 0?
事实上,-0 已经 === 等于 0,并且在用作 Set 值或 Map 键时会被规范化为 0。因此,根据最小惊讶原则,在复合类型中它也会被规范化为 0,从而使得以下情况成立:
const zero = Composite({ v: 0 });
const negZero = Composite({ v: -0 });
zero === negZero;
new Set([zero, negZero]).size === 1;
规范化还确保存储的值是确定性的。SameValueZero 已经将 0 和 -0 视为相等,因此无论哪种情况,Composite({ v: 0 }) 和 Composite({ v: -0 }) 都会驻留到同一个对象。如果没有规范化,从 .v 读回的值将取决于这两个调用中哪一个先创建了该对象。规范化为 0 消除了这种顺序依赖。
preserveNegativeZero:true
如果应用程序有保留 -0 的使用场景,它们可以通过 preserveNegativeZero 选项选择加入:
const realNegZero = Composite({ v: -0 }, { preserveNegativeZero: true });
const zero = Composite({ v: 0 });
Object.is(realNegZero.v, -0); // true
realNegZero === zero; // false - different composite objects
启用此选项创建复合对象后,如果将其值传回 Composite 函数,这些值将被保留:
Object.is(Composite(realNegZero).v, -0); // true
这意味着以下情况始终成立:
if (Composite.isComposite(v)) {
assert(Composite(v) === v);
}
为什么 NaN 被视为相等?
这源于基于 SameValueZero 的驻留语义([NaN].includes(NaN) === true)。由相同键值对组成的复合对象返回同一个对象,且对象与自身相等。
声称 Composite({ v: NaN }) !== Composite({ v: NaN }) 要么会破坏对象始终与自身相等的规则(事实上 NaN 是唯一不与自身相等的值,现有代码依赖此特性来检测 NaN)。要么意味着尝试驻留包含至少一个 NaN 的复合对象时,总是返回一个新对象,这不仅不太有用,而且很可能是内存泄漏的来源。
关于 WeakMaps 和 WeakSets?
复合对象不能用于弱引用位置。它们不能作为 WeakMap 中的键、WeakSet 中的值、WeakRef 的目标,或注册到 FinalizationRegistry 中。
const objs = new WeakSet();
objs.add(Composite({})); // throws TypeError 💥
允许它们在弱引用位置使用可能会导致内存泄漏。
如果需要跟踪包含常规可跟踪对象的复合体的生命周期,可以通过一个用户态库来实现,该库会遍历复合体的组成值并转而跟踪这些值。
复合体是新的“原始类型”吗?
不是。复合体是一个对象。它的 typeof 是 "object"。
=== 相等性对复合体有效,因为对象已经 === 于自身。
Symbol 键?
复合体不能包含 Symbol 键(Symbol 仍可作为值使用)。
复合体使用字符串键的原因是为键的组成部分提供一个具体的名称(参见 nominal keys)。
此外,复合体会对其键进行排序以生成规范形式(参见 Are keys sorted?),而且不存在一种稳定的、隐藏信息的方式来对 Symbol 进行排序。
- 已注册符号(
Symbol.for("..."))可以按其注册键排序,但仅支持已注册符号不会带来显著价值。 - 唯一符号(
Symbol())只有在具有不同描述时才能排序,这同样不提供太多价值——而没有描述或描述重复的符号则完全无法排序。- 按创建顺序对唯一符号进行排序过于微妙,且会泄露目前保密的信息。
- 最有价值的支持对象是诸如
Symbol.iterator这样的知名符号。但这些符号也没有定义排序顺序,且无法与唯一符号直接区分。
鉴于这种复杂性,最简洁的规则是不允许任何符号键。这为未来提案留出了空间,以便在需要时探索支持那些技术上可以正确实现符号键的情况。
为什么不采用新协议?
为什么将相等性限制为仅适用于这些复合值,而不是让任何对象实现新的符号协议?
哈希值未暴露
为了使该协议对 Map 和 Set 键有效,它需要返回一个哈希值,但该语言并未为任何现有值暴露哈希值——尤其是字符串。
无副作用
=== 和 Object.is 都被期望为不触发用户代码的纯函数。基于协议的相等性对这些情况不起作用。
可靠性
要能够作为 Map 键参与,相等性必须是纯粹的、稳定的和可靠的。运行任意代码的协议无法提供这些保证——例如,一个对象可能在位于映射中时被添加符号协议。
未被排除
语言仍然可以添加支持它的基于符号的协议与集合(例如 ProtocolMap、ProtocolSet)。本提案并不阻止这一点。
为什么使用命名属性而不是有序键?
一方面,从一个键是列表而不是字典的提案开始听起来更简单,它可能只是:
const c = Composite(1, 4);
c[0]; // 1
c[1]; // 4
我们反而鼓励为复合体的组成部分命名,以使代码更易于阅读,并避免索引混淆导致的错误。
那么 元组,或者 序数 而非 名义 键呢?
如果代码确实想要序数键,我们在此处能做的最简单的事情(除了什么都不做之外)是为序数复合体提供一个便捷 API。
Composite.of("a", "b", "c");
// Convenience API for:
Composite({ 0: "a", 1: "b", 2: "c", length: 3 });
话虽如此,这可能并非特别有用,因为生成的复合体:
length将是可枚举的- 没有
Symbol.iterator
此外,由于创建复合体的成本会随着键数量的增加而增长,鼓励使用类似列表的键可能并不明智。根据用例,代码可能更适合使用类似链表的结构。
语法?
可以引入语法,使创建复合体更加符合人体工程学且更易读。
#{ x: 1 };
// Syntax for:
Composite({ x: 1 });
此类语法可能为某些运行时优化打开大门。
该语法将作为一项独立的后续提案——在 Composites API 在生态系统中独立运行一段时间并观察其使用情况之后。
这可以被 polyfill 吗?
是的 "./polyfill".
不过,与所有 JS polyfill 一样,它仅具有本地内部状态。因此,两个独立的 polyfill 不会创建彼此相等的 composites。
composites 是否跨 realm 工作?
是的。原生地,composites 按 agent 进行驻留(interned),并且在 realm 之间相等(例如跨同源 iframe),这与从 Symbol.for 获取的 symbols 在 realm 之间共享的方式相同。
从一个 realm 的 Composite 函数返回的 composite 与创建它的 realm 没有任何关系——它的原型是 null。
如上所述,独立的 polyfill 各自拥有自己的本地驻留状态,因此不会生成彼此相等的 composites,除非这些 realm 共享同一个 polyfill 实例,否则它们在 realm 之间不会相等。
为什么要在语言中原生实现?
能够创建多值 Map 和 Set 键是许多应用领域的常见需求。
作为语言的一部分意味着由应用程序不同部分创建的键仍然相等,而没有使用两个不同驻留库的风险。
此外,该提案的实验性实现表明,与在纯 JS 中实现相比,通过直接访问引擎内部机制来原生实现复合对象具有显著优势。例如,引擎可以直接访问字符串的任何现有内部哈希值。
复合对象能否形成循环?
复合对象在创建时即被冻结,并且只能直接引用在创建之前已经存在的值。因此,嵌套的复合对象永远不会形成循环。
复合对象内部持有的非复合对象本身可能反向引用该复合对象,但复合对象的驻留(interning)会在非复合对象处停止,因此不会导致复合对象循环。
在遍历复合对象时,代码可以使用 Composite.isComposite 确保在到达复合对象_树_的_叶子_节点时停止递归。
这与 proposal-richer-keys 相比如何?
该提案:
compositeKey接受有序列表,而非命名属性- 返回的键是不透明的,没有属性
- 至少一个值必须是对象
本提案:
- 键由命名属性组成
- 返回的键暴露数据
- 对值必须是什么没有限制
这与 proposal-record-tuple 相比如何?
该提案:
- 记录是具有自定义
typeof的新原语 - 记录只能包含原语(深度不可变)
本提案:
- 复合对象是对象
- 复合对象可以包含任何值(浅层不可变)