相等比较和相同性

JavaScript 提供三种不同的值比较操作。

  • === — 严格相等(三个等号)
  • == — 松散相等(两个等号)
  • Object.is()

您选择的操作取决于您要执行的比较类型。简而言之

  • 双等号 (==) 在比较两个事物时会执行类型转换,并且会专门处理 NaN-0+0 以符合 IEEE 754(因此 NaN != NaN,并且 -0 == +0);
  • 三等号 (===) 会执行与双等号相同的比较(包括对 NaN-0+0 的特殊处理),但不进行类型转换;如果类型不同,则返回 false
  • Object.is() 不会进行类型转换,也不会对 NaN-0+0 进行特殊处理(使其与 === 的行为相同,除了这些特殊数值)。

它们对应于 JavaScript 中四种相等算法中的三种。

请注意,这些之间的区别都与它们对基本类型的处理有关;它们都没有比较参数在结构上是否概念上相似。对于任何具有相同结构但本身是不同对象的非基本对象 xy,上述所有形式都将评估为 false

使用 === 的严格相等

严格相等比较两个值是否相等。在比较之前,两个值都不会隐式转换为其他值。如果两个值具有不同的类型,则这两个值被认为不相等。如果两个值具有相同的类型,并且不是数字,并且具有相同的值,则它们被认为相等。最后,如果两个值都是数字,如果它们都不同于 NaN 并且具有相同的值,或者如果一个是 +0 并且另一个是 -0,则它们被认为相等。

js
const num = 0;
const obj = new String("0");
const str = "0";

console.log(num === num); // true
console.log(obj === obj); // true
console.log(str === str); // true

console.log(num === obj); // false
console.log(num === str); // false
console.log(obj === str); // false
console.log(null === undefined); // false
console.log(obj === null); // false
console.log(obj === undefined); // false

严格相等几乎总是要使用的正确比较操作。对于除数字之外的所有值,它使用明显的语义:一个值只等于它自己。对于数字,它使用略有不同的语义来掩盖两个不同的边缘情况。第一个是浮点零可以是正号或负号。这在表示某些数学解时很有用,但是由于大多数情况都不关心 +0-0 之间的差异,严格相等将它们视为相同的值。第二个是浮点包括一个非数字值的的概念,NaN,用于表示某些定义不明确的数学问题的解:例如,负无穷大加正无穷大。严格相等将 NaN 视为与所有其他值(包括它自己)不相等。((x !== x)true 的唯一情况是 xNaN。)

除了 === 之外,严格相等还用于包括 Array.prototype.indexOf()Array.prototype.lastIndexOf()TypedArray.prototype.indexOf()TypedArray.prototype.lastIndexOf()case 匹配在内的数组索引查找方法。这意味着您不能使用 indexOf(NaN) 来查找数组中 NaN 值的索引,或者在 switch 语句中使用 NaN 作为 case 值并使其与任何内容匹配。

js
console.log([NaN].indexOf(NaN)); // -1
switch (NaN) {
  case NaN:
    console.log("Surprise"); // Nothing is logged
}

使用 == 的松散相等

松散相等是对称的:对于 AB 的任何值,A == B 的语义始终与 B == A 相同(除了应用转换的顺序)。使用 == 执行松散相等的行为如下

  1. 如果操作数具有相同的类型,则它们将按如下方式比较
    • 对象:仅当两个操作数引用同一个对象时才返回 true
    • 字符串:仅当两个操作数具有相同字符并按相同顺序排列时才返回 true
    • 数字:仅当两个操作数具有相同的值时才返回 true+0-0 被视为相同的值。如果任一操作数是 NaN,则返回 false;因此 NaN 从不等于 NaN
    • 布尔值:仅当操作数均为 true 或均为 false 时才返回 true
    • BigInt:仅当两个操作数具有相同的值时才返回 true
    • Symbol:仅当两个操作数引用同一个符号时才返回 true
  2. 如果其中一个操作数是 nullundefined,则另一个也必须是 nullundefined 才能返回 true。否则返回 false
  3. 如果其中一个操作数是对象,而另一个是基本类型,则将对象转换为基本类型
  4. 在此步骤中,两个操作数都将转换为基本类型(其中之一是字符串、数字、布尔值、符号和 BigInt)。其余转换将逐案进行。
    • 如果它们是相同的类型,则使用步骤 1 对它们进行比较。
    • 如果其中一个操作数是 Symbol,但另一个不是,则返回 false
    • 如果其中一个操作数是布尔值,但另一个不是,则将布尔值转换为数字: true 转换为 1,false 转换为 0。然后再次松散地比较两个操作数。
    • 数字到字符串:将字符串转换为数字。转换失败会导致 NaN,这将保证相等性为 false
    • 数字到 BigInt:通过它们的数值进行比较。如果数字是 ±Infinity 或 NaN,则返回 false
    • 字符串到 BigInt:使用与 BigInt() 构造函数相同的算法将字符串转换为 BigInt。如果转换失败,则返回 false

传统上,根据 ECMAScript 的规定,所有原始类型和对象都与 `undefined` 和 `null` 松散不等。但大多数浏览器允许一类非常狭窄的对象(具体来说,对于任何页面,`document.all` 对象),在某些情况下,可以像它们模拟 `undefined` 值一样工作。松散相等就是一个这样的上下文:`null == A` 和 `undefined == A` 当且仅当 A 是模拟 `undefined` 的对象时才计算为真。在所有其他情况下,对象永远不会松散等于 `undefined` 或 `null`。

在大多数情况下,不建议使用松散相等。使用严格相等进行比较的结果更容易预测,并且由于没有类型强制转换,因此可能执行得更快。

以下示例演示了涉及数字原始类型 `0`、bigint 原始类型 `0n`、字符串原始类型 `'0'` 以及 `toString()` 值为 `'0'` 的对象的松散相等比较。

js
const num = 0;
const big = 0n;
const str = "0";
const obj = new String("0");

console.log(num == str); // true
console.log(big == num); // true
console.log(str == big); // true

console.log(num == obj); // true
console.log(big == obj); // true
console.log(str == obj); // true

松散相等仅由 `==` 运算符使用。

使用 Object.is() 的相同值相等

相同值相等确定两个值在所有上下文中是否功能相同。(此用例演示了 Liskov 替换原则 的一个实例。)当尝试修改不可变属性时,会发生这种情况。

js
// Add an immutable NEGATIVE_ZERO property to the Number constructor.
Object.defineProperty(Number, "NEGATIVE_ZERO", {
  value: -0,
  writable: false,
  configurable: false,
  enumerable: false,
});

function attemptMutation(v) {
  Object.defineProperty(Number, "NEGATIVE_ZERO", { value: v });
}

Object.defineProperty 在尝试更改不可变属性时会抛出异常,但如果未请求实际更改,则不会执行任何操作。如果 `v` 为 `-0`,则未请求更改,不会抛出错误。在内部,当重新定义不可变属性时,会使用相同值相等将新指定的值与当前值进行比较。

相同值相等由 Object.is 方法提供。它几乎在需要具有等效标识的值的语言中的所有地方使用。

相同值零相等

类似于相同值相等,但 +0 和 -0 被认为是相等的。

相同值零相等未作为 JavaScript API 公开,但可以使用自定义代码实现。

js
function sameValueZero(x, y) {
  if (typeof x === "number" && typeof y === "number") {
    // x and y are equal (may be -0 and 0) or they are both NaN
    return x === y || (x !== x && y !== y);
  }
  return x === y;
}

相同值零仅通过将 `NaN` 视为等效值与严格相等不同,并且仅通过将 `-0` 视为等效于 `0` 与相同值相等不同。这使其在搜索期间通常具有最合理的行为,尤其是在使用 `NaN` 时。它被 Array.prototype.includes()TypedArray.prototype.includes() 以及 MapSet 方法用于比较键相等。

比较相等方法

人们经常通过说一个是对另一个的“增强”版本来比较双等号和三等号。例如,可以将双等号说成是三等号的扩展版本,因为前者执行后者所做的一切,但对其操作数进行类型转换——例如,`6 == "6"`。或者,可以声称双等号是基线,而三等号是增强版本,因为它要求两个操作数类型相同,因此它添加了一个额外的约束。

但是,这种思维方式意味着相等比较形成一个一维“频谱”,其中“完全严格”位于一端,“完全松散”位于另一端。这个模型不足以说明 Object.is,因为它既不像双等号那样“松散”,也不像三等号那样“严格”,也不适合介于两者之间(即,既比双等号更严格,但比三等号更松散)。我们可以从下面的相同性比较表中看到,这是由于 Object.is 处理 NaN 的方式造成的。请注意,如果 `Object.is(NaN, NaN)` 计算为 `false`,那么我们 *可以* 说它适合松散/严格频谱,作为三等号的一种更严格的形式,一种能够区分 `-0` 和 `+0` 的形式。然而,NaN 处理意味着这是不正确的。不幸的是,Object.is 必须根据其特定特性来考虑,而不是根据其相对于相等运算符的松散或严格性。

x y == === Object.is SameValueZero
undefined undefined ✅ true ✅ true ✅ true ✅ true
null null ✅ true ✅ true ✅ true ✅ true
true true ✅ true ✅ true ✅ true ✅ true
false false ✅ true ✅ true ✅ true ✅ true
'foo' 'foo' ✅ true ✅ true ✅ true ✅ true
0 0 ✅ true ✅ true ✅ true ✅ true
+0 -0 ✅ true ✅ true ❌ false ✅ true
+0 0 ✅ true ✅ true ✅ true ✅ true
-0 0 ✅ true ✅ true ❌ false ✅ true
0n -0n ✅ true ✅ true ✅ true ✅ true
0 false ✅ true ❌ false ❌ false ❌ false
"" false ✅ true ❌ false ❌ false ❌ false
"" 0 ✅ true ❌ false ❌ false ❌ false
'0' 0 ✅ true ❌ false ❌ false ❌ false
'17' 17 ✅ true ❌ false ❌ false ❌ false
[1, 2] '1,2' ✅ true ❌ false ❌ false ❌ false
new String('foo') 'foo' ✅ true ❌ false ❌ false ❌ false
null undefined ✅ true ❌ false ❌ false ❌ false
null false ❌ false ❌ false ❌ false ❌ false
undefined false ❌ false ❌ false ❌ false ❌ false
{ foo: 'bar' } { foo: 'bar' } ❌ false ❌ false ❌ false ❌ false
new String('foo') new String('foo') ❌ false ❌ false ❌ false ❌ false
0 null ❌ false ❌ false ❌ false ❌ false
0 NaN ❌ false ❌ false ❌ false ❌ false
'foo' NaN ❌ false ❌ false ❌ false ❌ false
NaN NaN ❌ false ❌ false ✅ true ✅ true

何时使用 Object.is() 与三等号

通常,只有在追求某些元编程方案(尤其是在属性描述符方面)时,Object.is 对零的特殊行为才可能引起关注,当希望你的工作反映 Object.defineProperty 的一些特性时。如果你的用例不需要这样做,建议避免使用 Object.is,而使用 ===。即使你的需求涉及使两个 NaN 值之间的比较计算为 `true`,通常对 NaN 检查进行特殊情况处理(使用从以前版本的 ECMAScript 中提供的 isNaN 方法)比弄清楚周围的计算如何影响你比较中遇到的任何零的符号要容易得多。

以下是非详尽的内置方法和运算符列表,这些方法和运算符可能会导致 `-0` 和 `+0` 之间的区别在你的代码中显现出来。

-(一元否定)

考虑以下示例

js
const stoppingForce = obj.mass * -obj.velocity;

如果 `obj.velocity` 为 `0`(或计算为 `0`),则在该位置引入一个 `-0`,并传播到 `stoppingForce` 中。

Math.atan2Math.ceilMath.powMath.round

在某些情况下,即使没有 `-0` 作为参数之一,也可能将 `-0` 作为这些方法的返回值引入表达式中。例如,使用 Math.pow-Infinity 提升到任何负奇数指数的幂,计算结果为 `-0`。请参阅各个方法的文档。

Math.floorMath.maxMath.minMath.sinMath.sqrtMath.tan

在某些情况下,当 `-0` 作为参数之一存在时,可以从这些方法中获得 `-0` 返回值。例如,`Math.min(-0, +0)` 计算结果为 `-0`。请参阅各个方法的文档。

~, <<, >>

每个运算符在内部使用 ToInt32 算法。由于内部 32 位整数类型中只有一个表示形式为 0,因此 `-0` 在逆运算后不会经过往返。例如,`Object.is(~~(-0), -0)` 和 `Object.is(-0 << 2 >> 2, -0)` 都计算为 `false`。

当未考虑零的符号时,依赖 Object.is 可能很危险。当然,当意图是区分 `-0` 和 `+0` 时,它恰好能按预期执行。

警告:Object.is() 和 NaN

Object.is 规范将所有 NaN 实例视为同一对象。但是,由于 类型化数组 可用,因此我们可以拥有 `NaN` 的不同浮点表示形式,它们在所有上下文中并不完全相同。例如

js
const f2b = (x) => new Uint8Array(new Float64Array([x]).buffer);
const b2f = (x) => new Float64Array(x.buffer)[0];
// Get a byte representation of NaN
const n = f2b(NaN);
// Change the first bit, which is the sign bit and doesn't matter for NaN
n[0] = 1;
const nan2 = b2f(n);
console.log(nan2); // NaN
console.log(Object.is(nan2, NaN)); // true
console.log(f2b(NaN)); // Uint8Array(8) [0, 0, 0, 0, 0, 0, 248, 127]
console.log(f2b(nan2)); // Uint8Array(8) [1, 0, 0, 0, 0, 0, 248, 127]

另请参阅