For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/year/pending/proposal-composites.md.
  • 简体中文
  • Composites S1

    中文标题:复合值提案

    提案概览
    提案速览

    该提案引入了内置的复合值:包含一组命名值的对象,并根据其内容进行驻留(intern)。具有相同可枚举字符串键、且对应值按 SameValueZero 相等的复合对象是同一个对象,因此可以作为 Map 键或 Set 元素使用,具备明确且稳定的相等语义。这样就不再需要将值序列化为字符串或维护两个集合来绕过限制。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    Composites 提案

    为表示一组结构化值的 Map 和 Set 提供键。

    状态

    阶段:1

    提案负责人:Ashley Claymore

    规范草案:https://tc39.es/proposal-composites

    当前问题

    现在 MapSet 总是使用 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 中查看。

    “复合值”是什么

    它是一个对象。

    typeof Composite({}); // "object"

    它是一组命名值的集合。

    const c = Composite({
      x: 42,
      y: -1,
      message: "hello"
    });
    
    c.x;       // 42
    c.y;       // -1
    c.message; // "hello"

    具有相同命名值集合的两个复合值将是同一个对象(通常称为驻留/interning)。

    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 键?)。

    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)。如果满足以下条件,第二次调用将返回与第一次调用相同的对象:

    • ab 都是对象
      • 否则创建会失败
    • ab 必须具有相同数量的可枚举字符串键
    • 对于 b 中的每个可枚举键
      • 该键必须是字符串
        • 否则创建会失败
      • 该键也必须出现在 a 中(顺序无关)
      • 该键的值必须根据 SameValueZeroa 中该键的值相等
    相等的复合值:
    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:{} });

    保证哪些相等语义?

    • 由于复合值会被驻留,比较它们只需进行指针相等比较,因此总能终止且不会抛出异常。
    • 两个复合值之间的相等性永远不会改变。
    • 相等性是一种等价关系:
      • 自反性:复合值总是与自身相等(c === c)。
      • 对称性:如果 c1 === c2,那么 c2 === c1
      • 传递性:如果 c1 === c2c2 === 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 中,映射具有基于值的相等性,且不依赖于键的顺序:

    (def position1 {:x 1 :y 4})
    (def position2 {:y 4 :x 1})
    (count (set [position1 position2])) ; 1

    常见问题

    如何检查某个值是不是复合值?

    Composite.isComposite(arg) 只对复合值返回 true

    以复合值作为其目标对象的代理不会被视作复合值。

    性能预期

    复合值一旦创建,比较两个复合值的成本为常数时间;运行时只需比较它们的内存地址。

    创建复合值的成本会随其包含的键的数量增加而增加,并且还可能受到内部复合值驻留缓存当前负载因子的影响。

    垃圾回收器(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 的复合值时总是会返回一个新对象,这不会特别有用,而且很可能是内存泄漏的来源。

    那么 WeakMap 和 WeakSet 呢?

    复合值不能用在弱位置。它们不能用作 WeakMap 中的键、WeakSet 中的值、WeakRef 的目标,也不能注册到 FinalizationRegistry

    const objs = new WeakSet();
    objs.add(Composite({})); // throws TypeError 💥

    允许它们用在弱位置很可能会产生内存泄漏。

    如果你需要跟踪包含普通可跟踪对象的复合值的生命周期,可以通过用户态库来实现:遍历该复合值包含的各个值并转而跟踪那些值。

    复合值是新的“原始值”吗?

    不是。复合值是对象。它的 typeof"object"

    === 相等性对复合值有效,是因为对象本来就与自身 ===

    Symbol 键?

    复合值不能包含 Symbol 键(Symbol 仍然可以用作值)。

    复合值使用字符串键,是为了给键的组成部分一个具体的名称(参见名义键)。

    此外,复合值会对键进行排序以产生规范形式(参见键会被排序吗?),而 Symbol 没有稳定且不泄露信息的排序方式。

    • 已注册 Symbol(Symbol.for("..."))可以按注册键排序,但只支持已注册 Symbol 并不会带来多少价值。
    • 唯一 Symbol(Symbol())只有在它们具有不同描述时才能排序,而这一点同样没有太大价值——没有描述或描述重复的 Symbol 根本无法排序。
      • 按创建顺序对唯一 Symbol 排序过于隐晦,并且会泄露当前属于秘密的信息。
    • 最值得支持的 Symbol 是像 Symbol.iterator 这样的知名 Symbol。但这些 Symbol 也没有定义好的排序顺序,并且无法与唯一 Symbol 直接区分。

    鉴于这种复杂性,最干净的规则是不允许任何 Symbol 键。这也为未来提案留下了探索空间,以便在确有需要时支持那些技术上可以正确实现 Symbol 键的场景。

    为什么不用新协议?

    为什么要把相等性限制为仅这些复合值,而不是让任何对象实现一个新的 Symbol 协议?

    不会暴露哈希值

    要让该协议对 MapSet 的键有效,它需要返回一个哈希值,但语言并未为任何现有值暴露哈希值——尤其是字符串。

    无副作用

    ===Object.is 都被期望是纯函数,不触发用户代码。基于协议的相等性无法用于这些操作。

    可靠性

    要能作为 Map 的键参与其中,相等性必须是纯粹、稳定且可靠的。运行任意代码的协议无法提供这些保证——例如,对象可能在 map 中时被添加该 Symbol 协议。

    并不排除未来方案

    语言仍然可以添加基于 Symbol 协议并支持它的集合(例如 ProtocolMapProtocolSet)。本提案并不阻止这一点。

    为什么要用命名属性而不是有序键?

    一方面,从键是列表而不是字典的提案开始听起来更简单,可以只是:

    const c = Composite(1, 4);
    c[0]; // 1
    c[1]; // 4

    与此相对,我们鼓励为复合值的组成部分命名,以使代码更容易理解,并避免索引被混淆的错误。

    那么 Tuples(元组)呢,即“序数键”而非“名义键”?

    如果代码确实想要序数键,我们在这里能做的最简单的事情(除了什么都不做之外)就是为序数复合值提供一个便捷 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 不会创建出彼此相等的复合值。

    复合值可以跨 realm 工作吗?

    可以。在原生实现中,复合值按 agent 进行驻留,并跨 realm 相等(例如跨同源 iframe),与通过 Symbol.for 获得的 Symbol 跨 realm 共享的方式相同。

    从一个 realm 的 Composite 函数返回的复合值与该创建它的 realm 没有任何关系——它的原型是 null

    如上所述,不同的 polyfill 各自拥有自己的本地驻留状态,因此不会产生彼此相等的复合值;所以除非这些 realm 共享同一个 polyfill 实例,否则它们在不同 realm 间也不会相等。

    为什么要在语言中原生实现?

    能够创建多值 MapSet 键是许多应用领域的常见需求。

    作为语言的一部分,意味着应用不同部分创建的键仍然相等,而不会因使用两个不同的驻留库而产生风险。

    此外,该提案的实验性实现表明,相比纯 JS 实现,原生实现复合值并直接访问引擎内部结构具有显著优势。例如,引擎可以直接访问字符串已有的内部哈希值。

    复合值能形成循环吗?

    复合值在创建时即被冻结,只能直接引用那些在它创建之前就已存在的值。因此,嵌套的复合值永远不会形成循环。

    复合值中持有的非复合对象本身可能引用回该复合值,但复合值的驻留会在非复合对象处停止,因此不会导致复合值循环。

    在遍历复合值时,代码可以使用 Composite.isComposite 来确保它在到达复合值_树_的_叶子_时会停止递归。

    这与 proposal-richer-keys 相比如何?

    该提案:

    • compositeKey 接受有序列表,而不是命名属性
    • 返回的键是不透明的,没有任何属性
    • 值中至少有一个必须是对象
    • 键可用于 WeakMap(以及其他弱 API)

    本提案:

    • 键由命名属性组成
    • 返回的键会暴露数据
    • 对值必须是什么没有限制
    • 键不能用于 WeakMap(或任何弱 API)

    这与 proposal-record-tuple 相比如何?

    该提案:

    • Record 是新的原始值,具有自定义 typeof
    • Record 只能包含原始值(深度不可变)
    • 有语法 #{ ... }

    本提案:

    • 复合值是对象
    • 复合值可以包含任何值(浅度不可变)
    • 不引入语法