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

    中文标题:复合值

    提案概览
    提案速览

    该提案引入了一个内置的“Composite”对象,具有基于值的相等性,允许多个值用作 Set 和 Map 的键。它通过基于键值对的驻留机制工作,键被排序,值通过 SameValueZero 进行比较。

    Note

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

    复合值提案

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

    状态

    阶段:1

    champions: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(); // 标准 ES Set
    positions.add(pos1);
    positions.has(pos2); // true
    
    const itemAtPosition = new Map(); // 标准 ES Map
    itemAtPosition.set(pos1, "book");
    itemAtPosition.get(Composite({ x: 1, y: 4 })); // "book"
    NOTE

    原始提案并非基于驻留,可在此提交中查看 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); // 抛出 TypeError 💥

    只有参数自身的可枚举属性会被使用。继承的和不可枚举的属性将被忽略。

    const c = Composite({ own: 2, __proto__: { inherited: 1 } });
    "inherited" in c; // false
    c.own;            // 2

    自身的可枚举符号键会抛出异常,因为复合值不能具有符号键(参见 符号键?)。

    Composite({ [Symbol()]: 1 }); // 抛出 TypeError 💥

    任何 getter 都会在创建时被立即调用恰好一次,复合值存储的是返回的值,而不是 getter 本身。

    let calls = 0;
    const c = Composite({
      get x() {
        calls++;
        return 42;
      },
    });
    calls === 1; // true

    它们不是类。

    Object.getPrototypeOf(Composite({})); // null
    new Composite({}); // 抛出 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` 被规范化为 `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 的实现。

    复合值是否深度不可变?

    不一定。复合值是通用容器,因此可以包含任何值。只有当它们包含的所有内容都深度不可变时,它们才是深度不可变的。

    键是否可枚举?

    是的,所有键都是:

    • 可枚举:true
    • 可配置:false
    • 可写: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 }) 无论如何都会驻留到同一个对象。如果不对 -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 - 不同的复合对象

    一旦使用此选项创建了复合值,其值在传递给 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({})); // 抛出 TypeError 💥

    允许它们在弱位置使用可能会导致内存泄漏。

    如果需要跟踪包含常规可跟踪对象的复合值的生命周期,可以通过用户库来实现,该库迭代复合值的组成值并跟踪这些值。

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

    不。复合值是对象。其 typeof"object"

    复合值适用于 === 相等,因为对象已经与自身 ===

    符号键?

    复合值不能包含符号键(符号仍可用作值)。

    复合值具有字符串键的原因是为键的组成提供一个具体的名称(参见 元组或序数键而非名义键呢?)。

    此外,复合值对其键进行排序以产生规范形式(参见 键是否排序?),但没有稳定的、信息隐藏的方式来对符号进行排序。

    • 注册符号(Symbol.for("..."))可以按其注册键排序,但只支持注册符号不会带来显著价值。
    • 唯一符号(Symbol())只有在它们具有不同描述时才能排序,这同样没有多大价值 - 而没有描述或描述重复的符号根本无法排序。
      • 按创建顺序对唯一符号排序将过于微妙,并会揭示目前秘密的信息。
    • 最有价值支持的符号将是众所周知的符号,如 Symbol.iterator。但这些也没有定义的排序顺序,并且不能直接与唯一符号区分。

    鉴于这种复杂性,最清晰的规则是不允许任何符号键。这为未来的提案留出了空间,如果出现需求,可以探索支持正确实现符号键的情况。

    为什么不采用新的协议?

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

    不暴露哈希值

    要使协议对 MapSet 键有效,它需要返回哈希值,但语言不暴露任何现有值的哈希值 - 最明显的是字符串。

    无副作用

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

    可靠性

    要能够作为 Map 键,相等性必须是纯的、稳定的和可靠的。这些保证不能由运行任意代码的协议提供 - 例如,当对象在映射中时,可以为其添加符号协议。

    不排除

    语言仍然可以添加基于符号协议的集合(例如 ProtocolMapProtocolSet)来支持它。本提案不阻止这种情况。

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

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

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

    相反,我们鼓励复合值的组成被命名,以使代码更易于理解,并避免索引混淆的错误。

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

    如果代码确实想要序数键,我们能做的最简单的事情(除了什么都不做)是提供一个用于序数复合值的便捷 API。

    Composite.of("a", "b", "c");
    // 便捷 API 等价于:
    Composite({ 0: "a", 1: "b", 2: "c", length: 3 });

    也就是说,这可能并不是特别有用,因为生成的复合值:

    • length 将是可枚举的
    • 没有 Symbol.iterator

    此外,由于创建复合值的成本随键数量增长,鼓励类似列表的键可能不明智。根据用例,代码可能更适合使用类似链表的结构。

    语法?

    可以有语法来使创建复合值更符合人体工程学、更易读。

    #{ x: 1 };
    // 等价于:
    Composite({ x: 1 });

    这样的语法可能为一些运行时优化打开大门。

    语法将是单独的后续提案 - 在 Composites API 在生态系统中单独存在一段时间以观察使用情况之后。

    能否进行 polyfill?

    可以 "./polyfill"

    不过,像所有 JS polyfill 一样,它只有本地内部状态。因此两个独立的 polyfill 不会创建彼此相等的复合值。

    复合值是否跨 realm 工作?

    是的。原生地,复合值按代理驻留,并且跨 realm 相等(例如,跨同源 iframe),就像从 Symbol.for 获得的符号跨 realm 共享一样。

    从一个 realm 的 Composite 函数返回的复合值与创建它的 realm 没有关系 - 其原型为 null

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

    为什么在语言中本地实现?

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

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

    此外,该提案的 实验实现 表明,在引擎内部直接访问的情况下本地实现复合值比在纯 JS 中实现有显著优势。例如,引擎可以直接访问字符串的任何现有内部哈希值。

    复合值能否形成循环?

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

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

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

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

    那个提案:

    • compositeKey 接受有序列表,而不是命名属性
    • 返回的键是不透明的,没有属性
    • 至少有一个值必须是对象

    本提案:

    • 键由命名属性组成
    • 返回的键暴露数据
    • 对值没有限制

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

    那个提案:

    • Record 是新的原始类型,具有自定义 typeof
    • Record 只能包含原始值(深度不可变)

    本提案:

    • 复合值是对象
    • 复合值可以包含任何值(浅不可变)