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/proposal-composites.md.
  • 简体中文
  • Composites S2

    中文标题:Composites 提案

    提案概览
    提案速览

    该提案引入具有明确定义相等性的内置复合值,使 Map 和 Set 的键可以表示结构化的值组。它解决了 Map 和 Set 使用 SameValueZero 所带来的限制:所有对象只等于自身,无法让两个具有相同内容的不同对象在集合中被视为相等。复合值是由参数自身的可枚举字符串键属性创建的对象,不允许 symbol 键,并且会被驻留,因此具有相同具名值的复合值是同一个对象。该设计还涵盖键排序、-0 规范化以及跨 realm 行为。

    Note

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

    Composites 提案

    用于 Map 和 Set 的键,表示结构化的值组。

    状态

    阶段:2

    负责人: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 查看。

    什么是“复合值”

    它是一个对象。

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

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

    • 两次调用的是同一个 Composite 函数(参见 复合值能跨 realm 工作吗?)
    • a 和 b 都是对象
      • 否则创建会失败
    • a 和 b 必须具有相同数量的可枚举字符串键
    • 对于 b 中的每个可枚举键
      • 该键必须是字符串
        • 否则创建会失败
      • 该键也必须存在于 a 中(顺序无关)
      • 根据 SameValueZero,该键的值必须等于 a 中该键的值
      • 如果任一值为 -0,两次调用都必须保留 -0(参见 preserveNegativeZero:true)
    相等的复合值:
    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 === 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。

    以复合值为目标的 proxy 不被视为复合值。

    该检查不限定 realm:对于任何 realm 创建的复合值,它都会返回 true,而不仅仅是调用它的 realm(参见 复合值能跨 realm 工作吗?)。

    性能预期

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

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

    垃圾回收器(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

    仅当参数已经是复合值时,该选项才默认为 true,因为复合值只有在使用该选项启用的情况下创建时才能包含 -0。原始的保留请求会得到尊重。

    显式传入 false 表示选择退出,改为得到规范化后的复合值:

    Composite(realNegZero, { preserveNegativeZero: false }) === zero; // true

    这意味着以下情况对于由同一 realm 创建的复合值始终成立:

    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,例如 Symbol.iterator。但它们也没有定义好的排序顺序,并且无法与唯一 symbol 直接区分。

    鉴于这种复杂性,最简洁的规则是不允许任何 symbol 键。这为未来的提案留出了空间,以便在需要时探索支持那些技术上可以正确实现 symbol 键的情况。

    为什么不使用新协议?

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

    哈希值未被暴露

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

    无副作用

    === 和 Object.is 都应当是纯函数,不会触发用户代码。基于协议的相等性无法适用于这些。

    可靠性

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

    未被排除

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

    为什么使用具名属性而不是有序键?

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

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

    相反,我们鼓励为复合值的组成部分命名,以便代码更容易理解,并避免索引混淆导致的 bug。

    那 元组、或者 序数 键而非 名义 键呢?

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

    复合值能跨 realm 工作吗?

    复合值可以在 realm 之间传递(例如在同源 iframe 之间),并在那里继续工作,但它们不会跨 realm 驻留。

    驻留是按 realm 进行的:每个 realm 有自己的注册表,因此两个 realm 永远不会返回同一个复合值,即使具名值相同。这与通过 Symbol.for 获取的 symbol 不同,后者来自一个由 agent 的所有 realm 共享的注册表。

    const foreignGlobalThis = iframe.contentWindow; // another realm
    const foreignComposite = foreignGlobalThis.Composite({ x: 1 });
    
    Composite({ x: 1 }) !== foreignComposite; // different realms, different objects

    如果需要测试跨 realm 相等性,应使用本地函数重新驻留该值:

    Composite({ x: 1 }) === Composite(foreignComposite); // true

    重新驻留是浅层的,因此在处理嵌套复合值可能需要递归应用。

    _作为_一个复合值不是按 realm 划分的。每个 realm 都识别任何 realm 创建的复合值,因此依赖于_作为_复合值的行为与 realm 无关。这与 Error.isError 一致。

    Composite.isComposite(foreignComposite); // true

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

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

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

    此外,该提案的实验性实现表明,与在纯 JS 中实现相比,利用对引擎内部的直接访问来原生实现复合值具有显著优势。例如,引擎可以直接访问字符串的任何现有内部哈希值。

    复合值会形成环吗?

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

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

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

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

    那个提案:

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

    本提案:

    • 键由具名属性构成
    • 返回的键会暴露数据
    • 对值没有任何限制
    • 键不能用于 WeakMap(或任何弱 API)

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

    那个提案:

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

    本提案:

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