JavaScript Object.is:两个零看起来相同,为什么缓存还需要区分

前天 6阅读

做一个只记住上次输入的缓存,看上去只要用三个等号比较就够了。可当输入从正零变成负零,缓存可能直接复用旧结果;连续两次输入 NaN,却又每次重新计算。两种现象来自同一件事:你选的相等规则与计算语义没有对齐。

Object.is 使用另一种相等规则:两个 NaN 被视为相同,正零和负零则被区分。这不意味着它在任何地方都比严格相等更好,而是提供了一个明确选择。判断是否需要重新计算之前,要先问被计算的函数是否在意这些差别。

用倒数函数暴露负零的差异

完整代码保存为 demo.mjs,运行 node demo.mjs。本文在 Node.js v24.19.0 验证。实验只在内存中使用单项缓存,所有预期都有断言,便于看到比较规则究竟在哪一步改变了结果。

JavaScript Object.is:两个零看起来相同,为什么缓存还需要区分

AI概念插图:用抽象图形表现本文讨论的关系,并非软件截图或真实运行结果。

import assert from 'node:assert/strict';
assert.equal(NaN === NaN, false);
assert.equal(Object.is(NaN, NaN), true);
assert.equal(+0 === -0, true);
assert.equal(Object.is(+0, -0), false);
assert.equal(Object.is('0', 0), false);
console.log('NaN: strict=false Object.is=true');
console.log('signed zero: strict=true Object.is=false');

function memoOne(fn, equal) {
  let hasValue = false, previous, result;
  return value => {
    if (!hasValue || !equal(value, previous)) {
      result = fn(value);
      previous = value;
      hasValue = true;
    }
    return result;
  };
}
const strictReciprocal = memoOne(x => 1 / x, (a, b) => a === b);
const exactReciprocal = memoOne(x => 1 / x, Object.is);
assert.equal(strictReciprocal(+0), Infinity);
assert.equal(strictReciprocal(-0), Infinity);
assert.equal(exactReciprocal(+0), Infinity);
assert.equal(exactReciprocal(-0), -Infinity);
console.log('strict cached -0:', strictReciprocal(-0));
console.log('Object.is cached -0:', exactReciprocal(-0));

let strictCalls = 0, exactCalls = 0;
const strictMissing = memoOne(() => ++strictCalls, (a, b) => a === b);
const exactMissing = memoOne(() => ++exactCalls, Object.is);
strictMissing(NaN); strictMissing(NaN);
exactMissing(NaN); exactMissing(NaN);
assert.equal(strictCalls, 2);
assert.equal(exactCalls, 1);
console.log('repeated NaN calls:', strictCalls, exactCalls);

const labelZero = value => Object.is(value, -0) ? '-0' : String(value);
assert.equal(labelZero(-0), '-0');
assert.equal(String(-0), '0');
console.log('zero labels:', labelZero(+0), labelZero(-0));

同样能与零比较相等,倒数仍有符号

开头两组检查说明,严格相等不把 NaN 与自身判为相同,却把正负零判为相同;Object.is 的结果正好在这两处不同。它仍然不会把字符串零自动转换成数字零,因此不能当成放宽类型要求的比较器。

缓存第一次计算正零的倒数,得到正无穷。第二次传入负零时,使用严格相等的缓存认为输入没变,继续返回正无穷。直接计算负零的倒数本应得到负无穷,所以这个复用决策已经损失了函数关心的信息。

使用 Object.is 的缓存会识别这次变化,重新计算并得到负无穷。例子不需要复杂业务就能证明:能在某种比较规则下相等的输入,不一定能在任意函数里互换。缓存键的等价关系必须尊重函数真正使用的区别。

NaN 的重复状态也要先定义

第三组通过调用次数观察连续两个 NaN。严格相等版本调用两次,Object.is 版本只调用一次。这里的函数专门返回计数,目的是暴露命中路径;它不是建议把带副作用的函数直接包装成通用缓存。

如果 NaN 在应用中表示同一种缺失状态,把重复状态视为未变化可能合理。但若每次缺失都应触发重新读取或错误处理,则不应仅凭值相同跳过工作。比较器只定义值的关系,不知道任务有没有时间变化、外部依赖或必须发生的副作用。

memoOne 另外保存 hasValue,而没有拿 undefined 当作“尚未缓存”的标记。这样第一次输入本身就是 undefined 时,也会实际执行函数。这是缓存状态与业务值分开的必要设计,否则比较规则写对了,首次调用仍可能被错误跳过。

最后的标签函数提醒我们,普通字符串化会把负零显示成零。调试时如果只看文字日志,两份不同的 Number 值可能看起来完全一样。需要保留符号的诊断输出,应先用 Object.is 检测负零,再生成明确标签。

本例没有处理多个参数、异步任务、缓存淘汰或对象内部变化。换成这些需求后,不能只更换比较函数就声称缓存已经正确。先列出函数依赖哪些输入事实,再选择键和失效方式,比一开始追求“更严格”的相等更实用。

给自己的比较逻辑增加正零、负零、重复 NaN、不同类型的零以及首次 undefined 这些样本,可以把隐藏假设写出来。预期是否合适要由具体用途决定,而不是让某个运算符的名字替你作决定。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。