Composites S1
中文标题:复合值
- 阶段: Stage 1
- 状态: 进行中
- ECMAScript 版本: —
- 同步时间: 2026年8月26日
- English original · 官方仓库
该提案引入了一个内置的“Composite”对象,具有基于值的相等性,允许多个值用作 Set 和 Map 的键。它通过基于键值对的驻留机制工作,键被排序,值通过 SameValueZero 进行比较。
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
复合值提案
用于表示一组结构化值的 Map 和 Set 的键。
状态
阶段:1
champions:Ashley Claymore
规范草案:https://tc39.es/proposal-composites
问题
目前 Map 和 Set 总是使用 SameValueZero 来回答“这个值在这个集合中吗?”。
这意味着当涉及到对象时,所有对象都只与自身相等。没有能力覆盖此行为并允许两个不同的对象在集合中被视为相等。
当前的变通方法
在 JavaScript 中绕过此限制的一种方法是将值展平为字符串表示形式。
这种方法的缺点是:
- 很容易构造出错误的字符串。例如
JSON.stringify:- 如果对象的键以不同的顺序枚举,则会生成不同的字符串。
- 会省略没有 JSON 表示形式的值,例如函数和
undefined。 - 在遇到
BigInt或循环引用时会抛出异常。
- 集合现在包含的是字符串而不是结构化对象。要读回这些值,需要对它们进行解析。
或者可以使用两个集合,一个用于跟踪唯一性,另一个用于跟踪值:
这种方法的缺点是:
- 代码需要确保两个集合彼此保持同步。
- 遵循此模式会带来额外的噪音/样板代码。
- 与上述将值展平为字符串的风险相同。
提案
引入具有明确定义相等性的内置“复合值”。
预计会有变化。以下设计是随着讨论继续而演进的起点。
原始提案并非基于驻留,可在此提交中查看 1c8c3f2f。
什么是“复合值”
它是一个对象。
它是命名值的集合。
具有相同命名值集合的两个复合值将是同一个对象(通常称为驻留)。
参数不会被转换为复合值;它只提供值。
参数必须是对象。
只有参数自身的可枚举属性会被使用。继承的和不可枚举的属性将被忽略。
自身的可枚举符号键会抛出异常,因为复合值不能具有符号键(参见 符号键?)。
任何 getter 都会在创建时被立即调用恰好一次,复合值存储的是返回的值,而不是 getter 本身。
它们不是类。
它们被冻结。
它们可以包含任何值...
...除了 -0,它会被 规范化为 0。
什么决定两个输入是否会驻留到同一个复合值?
给定两次调用 Composite(a) 和 Composite(b)。如果满足以下条件,第二次调用将返回与第一次相同的对象:
a和b都是对象- 否则创建将会失败
a和b必须具有相同数量的可枚举字符串键- 对于
b中的每个可枚举键- 该键必须是字符串
- 否则创建将会失败
- 该键也必须存在于
a中(顺序无关紧要) - 该键的值必须根据
SameValueZero与a中该键的值相等
- 该键必须是字符串
相等的复合值:
不相等的复合值:
保证什么样的相等语义?
- 由于复合值是驻留的,因此比较它们仅是指针相等,所以始终终止且不会抛出异常。
- 两个复合值的相等性永不改变。
- 相等性是一种等价关系:
- 自反性:复合值总是等于自身(
c === c)。 - 对称性:如果
c1 === c2,则c2 === c1。 - 传递性:如果
c1 === c2且c2 === c3,则c1 === c3。
- 自反性:复合值总是等于自身(
其他语言
在 Python 中,冻结的 dataclass 具有基于值的相等性并且可哈希:
在 Clojure 中,映射具有不依赖于键顺序的基于值的相等性:
常见问题
如何检查某物是否是复合值?
Composite.isComposite(arg) 仅对复合值返回 true。
以复合值为目标的代理对象不被视为复合值。
性能预期
一旦创建,比较两个复合值的成本是恒定时间;运行时只需要比较它们的内存地址。
创建复合值的成本随其包含的键数量增加而增加,也可能受内部复合值驻留缓存的当前 负载因子 影响。
垃圾回收器(GC)回收不再使用的复合值空间也会有非零成本 - 此成本将取决于 GC 的实现。
复合值是否深度不可变?
不一定。复合值是通用容器,因此可以包含任何值。只有当它们包含的所有内容都深度不可变时,它们才是深度不可变的。
键是否可枚举?
是的,所有键都是:
- 可枚举:true
- 可配置:false
- 可写:false
键是否排序?
是的。复合值的键已排序,因此复合值的枚举顺序不依赖于键在参数中出现的顺序。
这为复合值提供了一个规范形式:Composite({ a: 1, b: 2 }) 和 Composite({ b: 2, a: 1 }) 是同一个对象,具有相同的键顺序。
整数索引键首先出现,按升序数字顺序,就像普通对象一样。随后是剩余的字符串键,按字典顺序排序。
为什么将 -0 规范化为 0?
-0 已经与 0 === 相等,并且在用作 Set 值或 Map 键时会被规范化为 0。因此,根据最少惊讶原则,它在复合值中也被规范化为 0,以便以下成立:
规范化还使存储的值具有确定性。SameValueZero 已认为 0 与 -0 相等,因此 Composite({ v: 0 }) 和 Composite({ v: -0 }) 无论如何都会驻留到同一个对象。如果不对 -0 进行规范化,从 .v 读回的值将取决于两次调用中哪一次恰好先创建了该对象。规范化到 0 消除了这种顺序依赖。
preserveNegativeZero:true
如果应用程序有保留 -0 的用例,可以通过 preserveNegativeZero 选项选择加入:
一旦使用此选项创建了复合值,其值在传递给 Composite 函数时将得到保留:
这意味着以下情况将始终成立:
为什么 NaN 被视为相等?
这源于基于 SameValueZero 的驻留语义([NaN].includes(NaN) === true)。由相同键值对组成的复合值返回相同的对象,而对象与自身相等。
如果说 Composite({ v: NaN }) !== Composite({ v: NaN }) 要么会破坏对象始终与自身相等的规则(事实上 NaN 是唯一一个不等于自身的值,现有代码依赖这一点来检测 NaN)。要么意味着尝试驻留一个包含至少一个 NaN 的复合值将总是返回一个新对象,这不会特别有用,而且可能导致内存泄漏。
那么 WeakMap 和 WeakSet 呢?
复合值不能用于弱位置。它们 不能 用作 WeakMap 的键、WeakSet 的值、WeakRef 的目标,或注册到 FinalizationRegistry。
允许它们在弱位置使用可能会导致内存泄漏。
如果需要跟踪包含常规可跟踪对象的复合值的生命周期,可以通过用户库来实现,该库迭代复合值的组成值并跟踪这些值。
复合值是新的“原始值”吗?
不。复合值是对象。其 typeof 为 "object"。
复合值适用于 === 相等,因为对象已经与自身 ===。
符号键?
复合值不能包含符号键(符号仍可用作值)。
复合值具有字符串键的原因是为键的组成提供一个具体的名称(参见 元组或序数键而非名义键呢?)。
此外,复合值对其键进行排序以产生规范形式(参见 键是否排序?),但没有稳定的、信息隐藏的方式来对符号进行排序。
- 注册符号(
Symbol.for("..."))可以按其注册键排序,但只支持注册符号不会带来显著价值。 - 唯一符号(
Symbol())只有在它们具有不同描述时才能排序,这同样没有多大价值 - 而没有描述或描述重复的符号根本无法排序。- 按创建顺序对唯一符号排序将过于微妙,并会揭示目前秘密的信息。
- 最有价值支持的符号将是众所周知的符号,如
Symbol.iterator。但这些也没有定义的排序顺序,并且不能直接与唯一符号区分。
鉴于这种复杂性,最清晰的规则是不允许任何符号键。这为未来的提案留出了空间,如果出现需求,可以探索支持正确实现符号键的情况。
为什么不采用新的协议?
为什么将相等性限制为仅这些复合值,而不是让任何对象实现新的符号协议?
不暴露哈希值
要使协议对 Map 和 Set 键有效,它需要返回哈希值,但语言不暴露任何现有值的哈希值 - 最明显的是字符串。
无副作用
=== 和 Object.is 都应该是纯函数,不触发用户代码。基于协议的相等性将不适用于这些。
可靠性
要能够作为 Map 键,相等性必须是纯的、稳定的和可靠的。这些保证不能由运行任意代码的协议提供 - 例如,当对象在映射中时,可以为其添加符号协议。
不排除
语言仍然可以添加基于符号协议的集合(例如 ProtocolMap、ProtocolSet)来支持它。本提案不阻止这种情况。
为什么使用命名属性而不是有序键?
一方面,从键是列表而不是字典的提案开始听起来更简单,它可以只是:
相反,我们鼓励复合值的组成被命名,以使代码更易于理解,并避免索引混淆的错误。
那么元组或序数键而非名义键呢?
如果代码确实想要序数键,我们能做的最简单的事情(除了什么都不做)是提供一个用于序数复合值的便捷 API。
也就是说,这可能并不是特别有用,因为生成的复合值:
length将是可枚举的- 没有
Symbol.iterator
此外,由于创建复合值的成本随键数量增长,鼓励类似列表的键可能不明智。根据用例,代码可能更适合使用类似链表的结构。
语法?
可以有语法来使创建复合值更符合人体工程学、更易读。
这样的语法可能为一些运行时优化打开大门。
语法将是单独的后续提案 - 在 Composites API 在生态系统中单独存在一段时间以观察使用情况之后。
能否进行 polyfill?
可以 "./polyfill"。
不过,像所有 JS polyfill 一样,它只有本地内部状态。因此两个独立的 polyfill 不会创建彼此相等的复合值。
复合值是否跨 realm 工作?
是的。原生地,复合值按代理驻留,并且跨 realm 相等(例如,跨同源 iframe),就像从 Symbol.for 获得的符号跨 realm 共享一样。
从一个 realm 的 Composite 函数返回的复合值与创建它的 realm 没有关系 - 其原型为 null。
如上所述,独立的 polyfill 各自拥有本地驻留状态,因此不会产生彼此相等的复合值,因此除非这些 realm 共享相同的 polyfill 实例,否则它们不会跨 realm 相等。
为什么在语言中本地实现?
能够创建多值 Map 和 Set 键是许多应用程序领域的常见需求。
作为语言的一部分意味着由应用程序不同部分创建的键仍然相等,而不会面临使用两个不同驻留库的风险。
此外,该提案的 实验实现 表明,在引擎内部直接访问的情况下本地实现复合值比在纯 JS 中实现有显著优势。例如,引擎可以直接访问字符串的任何现有内部哈希值。
复合值能否形成循环?
复合值在创建时被冻结,只能直接引用在创建之前已经存在的值。因此嵌套的复合值永远不会形成循环。
复合值中持有的非复合对象本身可能引用该复合值,但复合值驻留在非复合对象处停止,因此不会导致复合值循环。
在遍历复合值时,代码可以使用 Composite.isComposite 来确保在到达复合值 树 的 叶子 时停止递归。
这与 proposal-richer-keys 相比如何?
那个提案:
compositeKey接受有序列表,而不是命名属性- 返回的键是不透明的,没有属性
- 至少有一个值必须是对象
本提案:
- 键由命名属性组成
- 返回的键暴露数据
- 对值没有限制
这与 proposal-record-tuple 相比如何?
那个提案:
- Record 是新的原始类型,具有自定义
typeof - Record 只能包含原始值(深度不可变)
本提案:
- 复合值是对象
- 复合值可以包含任何值(浅不可变)