ITADN
tc39/proposal-composites
tc39/proposal-composites · 文件 下载 ZIP
文件最后提交记录最后更新时间
README.md
以下内容由 AI 翻译,如有问题请点此提交 issue 反馈

复合类型提案

用于表示一组结构化值的 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 中查看。

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

  • 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:{} });

保证哪些相等性语义?

  • 由于复合体被驻留(interned),比较它们只是指针相等,因此总是终止且从不抛出异常。
  • 两个复合体的相等性永远不会改变。
  • 相等性是一个等价关系:
    • 自反性:一个复合体总是等于自身(c === c)。
    • 对称性:如果 c1 === c2c2 === c1
    • 传递性:如果 c1 === c2c2 === c3c1 === 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 这样的知名符号。但这些符号也没有定义排序顺序,且无法与唯一符号直接区分。

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

为什么不采用新协议?

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

哈希值未暴露

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

无副作用

===Object.is 都被期望为不触发用户代码的纯函数。基于协议的相等性对这些情况不起作用。

可靠性

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

未被排除

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

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

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

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 之间不会相等。

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

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

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

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

复合对象能否形成循环?

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

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

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

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

该提案:

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

本提案:

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

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

该提案:

  • 记录是具有自定义 typeof 的新原语
  • 记录只能包含原语(深度不可变)

本提案:

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