第四章:强制转换 - 宽松等价与严格等价

优质
小牛编辑
130浏览
2023-12-01

宽松等价与严格等价

宽松等价是==操作符,而严格等价是===操作符。两个操作符都被用于比较两个值的“等价性”,但是“宽松”和“严格”暗示着它们行为之间的一个 非常重要 的不同,特别是在它们如何决定“等价性”上。

关于这两个操作符的一个非常常见的误解是:“==检查值的等价性,而===检查值和类型的等价性。”虽然这听起来很好很合理,但是不准确。无数知名的JavaScript书籍和文章都是这么说的,但不幸的是它们都 错了

正确的描述是:“==允许在等价性比较中进行强制转换,而===不允许强制转换”。

等价性的性能

停下来思考一下第一种(不正确的)解释和这第二种(正确的)解释的不同。

在第一种解释中,看起来===明显的要比==做更多工作,因为它还必须检查类型。在第二种解释中,==是要 做更多工作 的,因为它不得不在类型不同时走过强制转换的步骤。

不要像许多人那样落入陷阱中,认为这会与性能有任何关系,虽然在这个问题上==好像要比===慢一些。强制转换确实要花费 一点点 处理时间,但也就是仅仅几微秒(是的,1微秒就是一秒的百万分之一!)。

如果你比较同类型的两个值,=====使用的是相同的算法,所以除了在引擎实现上的一些微小的区别,它们做的应当是相同的工作。

如果你比较两个不同类型的值,性能也不是重要因素。你应当问自己的是:当比较这两个值时,我想要进行强制转换吗?

如果你想要进行强制转换,使用==宽松等价,但如果你不想进行强制转换,就使用===严格等价。

注意: 这里暗示=====都会检查它们的操作数的类型。不同之处在于它们在类型不同时如何反应。

抽象等价性

在ES5语言规范的11.9.3部分中,==操作符的行为被定义为“抽象等价性比较算法”。那里列出了一个详尽但简单的算法,它明确地指出了类型的每一种可能的组合,与对于每一种组合强制转化应当如何发生(如果有必要的话)。

警告: 当(隐含的)强制转换被中伤为太过复杂和缺陷过多而不能成为 有用的,好的部分 时,遭到谴责的正是这些“抽象等价”规则。一般上,它们被认为对于开发者来说过于复杂和不直观而不能实际学习和应用,而且在JS程序中,和改善代码的可读性比起来,它倾向于导致更多的bug。我相信这是一种有缺陷的预断 —— 读者都是整天都在写(而且读,理解)算法(也就是代码)的能干的开发者。所以,接下来的是用简单的词语来直白地解读“抽象等价性”。但我恳请你也去读一下ES5规范的11.9.3部分。我想你将会对它是多么合理而感到震惊。

基本上,它的第一个条款(11.9.3.1)是在说,如果两个被比较的值是同一类型,它们就像你期望的那样通过等价性简单自然地比较。比如,42只和42相等,而"abc"只和"abc"相等。

在一般期望的结果中,有一些例外需要小心:

  • NaN永远不等于它自己(见第二章)
  • +0-0是相等的(见第二章)

条款11.9.3.1的最后一个规定是关于object(包括functionarray)的==宽松相等性比较。这样的两个值仅在它们引用 完全相同的值相等。这里没有强制转换发生。

注意: ===严格等价比较与11.9.3.1的定义一模一样,包括关于两个object的值的规定。很少有人知道,在两个object被比较的情况下,=====的行为相同

11.9.3算法中的剩余部分指出,如果你使用==宽松等价来比较两个不同类型的值,它们两者或其中之一将需要被 隐含地 强制转换。由于这个强制转换,两个值最终归于同一类型,可以使用简单的值的等价性来直接比较它们相等与否。

注意: !=宽松不等价操作是如你预料的那样定义的,它差不多就是==比较操作完整实施,之后对结果取反。这对于!==严格不等价操作也是一样的。

比较:stringnumber

为了展示==强制转换,首先让我们建立本章中早先的stringnumber的例子:

  1. var a = 42;
  2. var b = "42";
  3. a === b; // false
  4. a == b; // true

我们所预料的,a === b失败了,因为不允许强制转换,而且值42"42"确实是不同的。

然而,第二个比较a == b使用了宽松等价,这意味着如果类型偶然不同,这个比较算法将会对两个或其中一个值实施 隐含的 强制转换。

那么这里发生的究竟是那种强制转换呢?是a的值变成了一个string,还是b的值"42"变成了一个number

在ES5语言规范中,条款11.9.3.4-5说:

  1. 如果Type(x)是Number而Type(y)是String,
    返回比较x == ToNumber(y)的结果。
  2. 如果Type(x)是String而Type(y)是Number,
    返回比较ToNumber(x) == y的结果。

警告: 语言规范中使用NumberString作为类型的正式名称,虽然这本书中偏好使用numberstring指代基本类型。别让语言规范中首字母大写的NumberNumber()原生函数把你给搞糊涂了。对于我们的目的来说,类型名称的首字母大写是无关紧要的 —— 它们基本上是同一个意思。

显然,语言规范说为了比较,将值"42"强制转换为一个number。这个强制转换如何进行已经在前面将结过了,明确地说就是通过ToNumber抽象操作。在这种情况下十分明显,两个值42是相等的。

比较:任何东西与boolean

当你试着将一个值直接与truefalse相比较时,你会遇到==宽松等价的 隐含 强制转换中最大的一个坑。

考虑如下代码:

  1. var a = "42";
  2. var b = true;
  3. a == b; // false

等一下,这里发生了什么!?我们知道"42"是一个truthy值(见本章早先的部分)。那么它和true怎么不是==宽松等价的?

其中的原因既简单又刁钻得使人迷惑。它是如此的容易让人误解,许多JS开发者从来不会花费足够多的精力来完全掌握它。

让我们再次引用语言规范,条款11.9.3.6-7

  1. 如果Type(x)是Boolean,
    返回比较 ToNumber(x) == y 的结果。
  2. 如果Type(y)是Boolean,
    返回比较 x == ToNumber(y) 的结果。

我们来把它分解。首先:

  1. var x = true;
  2. var y = "42";
  3. x == y; // false

Type(x)确实是Boolean,所以它会实施ToNumber(x),将true强制转换为1。现在,1 == "42"会被求值。这里面的类型依然不同,所以(实质上是递归地)我们再次向早先讲解过的算法求解,它将"42"强制转换为42,而1 == 42明显是false

反过来,我们任然得到相同的结果:

  1. var x = "42";
  2. var y = false;
  3. x == y; // false

这次Type(y)Boolean,所以ToNumber(y)给出0"42" == 0递归地变为42 == 0,这当然是false

换句话说,"42"既不== true也不== false。猛地一看,这看起来像句疯话。一个值怎么可能既不是truthy也不是falsy呢?

但这就是问题所在!你在问一个完全错误的问题。但这确实不是你的错,你的大脑在耍你。

"42"的确是truthy,但是"42" == true根本就 不是在进行一个boolean测试/强制转换,不管你的大脑怎么说,"42" 没有 被强制转换为一个booleantrue),而是true被强制转换为一个1,而后"42"被强制转换为42

不管我们喜不喜欢,ToBoolean甚至都没参与到这里,所以"42"的真假是与==操作无关的!

而有关的是要理解==比较算法对所有不同类型组合如何动作。当==的任意一边是一个boolean值时,boolean总是首先被强制转换为一个number

如果这对你来讲很奇怪,那么你不是一个人。我个人建议永远,永远,不要在任何情况下,使用== true== false。永远。

但时要记住,我在此说的仅与==有关。=== true=== false不允许强制转换,所以它们没有ToNumber强制转换,因而是安全的。

考虑如下代码:

  1. var a = "42";
  2. // 不好(会失败的!):
  3. if (a == true) {
  4. // ..
  5. }
  6. // 也不该(会失败的!):
  7. if (a === true) {
  8. // ..
  9. }
  10. // 足够好(隐含地工作):
  11. if (a) {
  12. // ..
  13. }
  14. // 更好(明确地工作):
  15. if (!!a) {
  16. // ..
  17. }
  18. // 也很好(明确地工作):
  19. if (Boolean( a )) {
  20. // ..
  21. }

如果你在你的代码中一直避免使用== true== false(也就是与boolean的宽松等价),你将永远不必担心这种真/假的思维陷阱。

比较:nullundefined

另一个 隐含 强制转换的例子可以在nullundefined值之间的==宽松等价中看到。又再一次引述ES5语言规范,条款11.9.3.2-3:

  1. 如果x是null而y是undefined,返回true。
  2. 如果x是undefined而y是null,返回true。

当使用==宽松等价比较nullundefined,它们是互相等价(也就是互相强制转换)的,而且在整个语言中不会等价于其他值了。

这意味着nullundefined对于比较的目的来说,如果你使用==宽松等价操作符来允许它们互相 隐含地 强制转换的话,它们可以被认为是不可区分的。

  1. var a = null;
  2. var b;
  3. a == b; // true
  4. a == null; // true
  5. b == null; // true
  6. a == false; // false
  7. b == false; // false
  8. a == ""; // false
  9. b == ""; // false
  10. a == 0; // false
  11. b == 0; // false

nullundefined之间的强制转换是安全且可预见的,而且在这样的检查中没有其他的值会给出测试成立的误判。我推荐使用这种强制转换来允许nullundefined是不可区分的,如此将它们作为相同的值对待。

比如:

  1. var a = doSomething();
  2. if (a == null) {
  3. // ..
  4. }

a == null检查仅在doSomething()返回null或者undefined时才会通过,而在任何其他值的情况下将会失败,即便是0false,和""这样的falsy值。

这个检查的 明确 形式 —— 不允许任何强制转换 —— (我认为)没有必要地难看太多了(而且性能可能有点儿不好!):

  1. var a = doSomething();
  2. if (a === undefined || a === null) {
  3. // ..
  4. }

在我看来,a == null的形式是另一个用 隐含 强制转换增进了代码可读性的例子,而且是以一种可靠安全的方式。

比较:object与非object

如果一个object/function/array被与一个简单基本标量(stringnumber,或boolean)进行比较,ES5语言规范在条款11.9.3.8-9中这样说道:

  1. 如果Type(x)是一个String或者Number而Type(y)是一个Object,
    返回比较 x == ToPrimitive(y) 的结果。
  2. 如果Type(x)是一个Object而Type(y)是String或者Number,
    返回比较 ToPrimitive(x) == y 的结果。

注意: 你可能注意到了,这些条款仅提到了StringNumber,而没有Boolean。这是因为,正如我们早先引述的,条款11.9.3.6-7首先将任何出现的Boolean操作数强制转换为一个Number

考虑如下代码:

  1. var a = 42;
  2. var b = [ 42 ];
  3. a == b; // true

[ 42 ]ToPrimitive抽象操作(见先前的“抽象值操作”部分)被调用,结果为值"42"。这里它就变为42 == "42",我们已经讲解过这将变为42 == 42,所以ab被认为是强制转换地等价。

提示: 我们在本章早先讨论过的ToPrimitive抽象操作的所以奇怪之处(toString()valueOf()),都在这里如你期望的那样适用。如果你有一个复杂的数据结构,而且你想在它上面定义一个valueOf()方法来为等价比较提供一个简单值的话,这将十分有用。

在第三章中,我们讲解了“拆箱”,就是一个基本类型值的object包装器(例如new String("abc")这样的形式)被展开,其底层的基本类型值("abc")被返回。这种行为与==算法中的ToPrimitive强制转换有关:

  1. var a = "abc";
  2. var b = Object( a ); // 与`new String( a )`相同
  3. a === b; // false
  4. a == b; // true

a == btrue是因为b通过ToPrimitive强制转换为它的底层简单基本标量值"abc",它与a中的值是相同的。

然而由于==算法中的其他覆盖规则,有些值是例外。考虑如下代码:

  1. var a = null;
  2. var b = Object( a ); // 与`Object()`相同
  3. a == b; // false
  4. var c = undefined;
  5. var d = Object( c ); // 与`Object()`相同
  6. c == d; // false
  7. var e = NaN;
  8. var f = Object( e ); // 与`new Number( e )`相同
  9. e == f; // false

nullundefined不能被装箱 —— 它们没有等价的对象包装器 —— 所以Object(null)就像Object()一样,它们都仅仅产生一个普通对象。

NaN可以被封箱到它等价的Number对象包装器中,当==导致拆箱时,比较NaN == NaN会失败,因为NaN永远不会它自己相等(见第二章)。

边界情况

现在我们已经彻底检视了==宽松等价的 隐含 强制转换是如何工作的(从合理与惊讶两个方式),让我们召唤角落中最差劲儿的,最疯狂的情况,这样我们就能看到我们需要避免什么来防止被强制转换的bug咬到。

首先,让我们检视修改内建的原生prototype是如何产生疯狂的结果的:

一个拥有其他值的数字将会……

  1. Number.prototype.valueOf = function() {
  2. return 3;
  3. };
  4. new Number( 2 ) == 3; // true

警告: 2 == 3不会掉到这个陷阱中,这是由于23都不会调用内建的Number.prototype.valueOf()方法,因为它们已经是基本number值,可以直接比较。然而,new Number(2)必须通过ToPrimitive强制转换,因此调用valueOf()

邪恶吧?当然。任何人都不应当做这样的事情。你 可以 这么做,这个事实有时被当成批评强制转换和==的根据。但这种沮丧是被误导的。JavaScript不会因为你能做这样的事情而 不好,是 做这样的事的开发者 不好。不要陷入“我的编程语言应当保护我不受我自己伤害”的谬论。

接下来,让我们考虑另一个刁钻的例子,它将前一个例子的邪恶带到另一个水平:

  1. if (a == 2 && a == 3) {
  2. // ..
  3. }

你可能认为这是不可能的,因为a绝不会 同时 等于23。但是“同时”是不准确的,因为第一个表达式a == 2严格地发生在a == 3 之前

那么,要是我们让a.valueOf()在每次被调用时拥有一种副作用,使它第一次被调用时返回2而第二次被调用时返回3呢?很简单:

  1. var i = 2;
  2. Number.prototype.valueOf = function() {
  3. return i++;
  4. };
  5. var a = new Number( 42 );
  6. if (a == 2 && a == 3) {
  7. console.log( "Yep, this happened." );
  8. }

重申一次,这些都是邪恶的技巧。不要这么做。也不要用它们来抱怨强制转换。潜在地滥用一种机制并不是谴责这种机制的充分证据。避开这些疯狂的技巧,并坚持强制转换的合法与合理的用法就好了。

False-y 比较

关于==比较中 隐含 强制转换的最常见的抱怨,来自于falsy值互相比较时它们如何令人吃惊地动作。

为了展示,让我们看一个关于falsy值比较的极端例子的列表,来瞧瞧哪一个是合理的,哪一个是麻烦的:

  1. "0" == null; // false
  2. "0" == undefined; // false
  3. "0" == false; // true -- 噢!
  4. "0" == NaN; // false
  5. "0" == 0; // true
  6. "0" == ""; // false
  7. false == null; // false
  8. false == undefined; // false
  9. false == NaN; // false
  10. false == 0; // true -- 噢!
  11. false == ""; // true -- 噢!
  12. false == []; // true -- 噢!
  13. false == {}; // false
  14. "" == null; // false
  15. "" == undefined; // false
  16. "" == NaN; // false
  17. "" == 0; // true -- 噢!
  18. "" == []; // true -- 噢!
  19. "" == {}; // false
  20. 0 == null; // false
  21. 0 == undefined; // false
  22. 0 == NaN; // false
  23. 0 == []; // true -- 噢!
  24. 0 == {}; // false

在这24个比较的类表中,17个是十分合理和可预见的。比如,我们知道"""NaN"是根本不可能相等的值,并且它们确实不会强制转换以成为宽松等价的,而"0"0是合理等价的,而且确实强制转换为宽松等价。

然而,这些比较中的7个被标上了“噢!”。作为误判的成立,它们更像是会将你陷进去的坑。""0绝对是有区别的不同的值,而且你很少会将它们作为等价的,所以它们的互相强制转换是一种麻烦。注意这里没有任何误判的不成立。

疯狂的情况

但是我们不必停留在此。我们可以继续寻找更能引起麻烦的强制转换:

  1. [] == ![]; // true

噢,这看起来像是更高层次的疯狂,对吧!?你的大脑可能会欺骗你说,你在将一个truthy和falsy值比较,所以结果true是令人吃惊的,因为我们知道一个值不可能同时为truthy和falsy!

但这不是实际发生的事情。让我们把它分解一下。我们了解!一元操作符吧?它明确地使用ToBoolean规则将操作数强制转换为一个boolean(而且它还会翻转真假性)。所以在[] == ![]执行之前,它实际上已经被翻译为了[] == false。我们已将在上面的列表中见过了这种形式(false == []),所以它的令人吃惊的结果对我们来说并不 新鲜

其它的极端情况呢?

  1. 2 == [2]; // true
  2. "" == [null]; // true

在关于ToNumber的讨论中我们说过,右手边的[2][null]值将会通过一个ToPrimitive强制转换,以使我们可以方便地与左手边的简单基本类型值进行比较。因为array值的valueOf()只是返回array本身,强制转换会退到array的字符串化上。

对于第一个比较的右手边的值来说,[2]将变为"2",然后它会ToNumber强制转换为2[null]就直接变成""

那么,2 == 2"" == ""是完全可以理解的。

如果你的直觉依然不喜欢这个结果,那么你的沮丧实际上与你可能认为的强制转换无关。这其实是在抱怨array值在强制转换为string值时的默认ToPrimitive行为。很可能,你只是希望[2].toString()不返回"2",或者[null].toString()不返回""

但是这些string强制转换到底 应该 得出什么结果?对于[2]string强制转换,除了"2"我确实想不出来其他合适的结果,也许是"[2]" —— 但这可能会在其他的上下文中很奇怪!

你可以正确地制造另一个例子:因为String(null)变成了"null",那么String([null])也应当变成"null"。这是个合理的断言。所以,它才是真正的犯人。

隐含 强制转换在这里并不邪恶。即使一个从[null]string结果为""明确 强制转换也不。真正奇怪的是,array值字符串化为它们内容的等价物是否有道理,和它是如何发生的。所以,应当将你沮丧的原因指向String( [..] )的规则,因为这里才是疯狂起源的地方。也许根本就不应该有array的字符串化强制转换?但这会在语言的其他部分造成许多的缺点。

另一个常被引用的著名的坑是:

  1. 0 == "\n"; // true

正如我们早先讨论的空"""\n"(或" ",或其他任何空格的组合)是通过ToNumber强制转换的,而且结果为0。你还希望空格被转换为其他的什么number值呢?明确的 Number()给出0会困扰你吗?

空字符串和空格字符串可以转换为的,另一个真正唯一合理的number值是NaN。但这 真的 会更好吗?" " == NaN的比较当然会失败,但是不清楚我们是否真的 修正 了任何底层的问题。

真实世界中的JS程序由于0 == "\n"而失败的几率非常之低,而且这样的极端用例很容比避免。

在任何语言中,类型转换 总是 有极端用例 —— 强制转换也不例外。这里讨论的是特定的一组极端用例的马后炮,但不是针对强制转换整体而言的争论。

底线:你可能遇到的几乎所有 普通值 间的疯狂强制转换(除了像早先那样有意而为的valueOf()toString()黑科技),都能归结为我们在上面指出的7中情况的短列表。

对比这24个疑似强制转换的坑,考虑另一个像这样的列表:

  1. 42 == "43"; // false
  2. "foo" == 42; // false
  3. "true" == true; // false
  4. 42 == "42"; // true
  5. "foo" == [ "foo" ]; // true

在这些非falsy,非极端的用例中(而且我们简直可以向这个列表中添加无限多个比较),强制转换完全是安全,合理,和可解释的。

可行性检查

好的,当我们深入观察 隐含的 强制转换时,我确实找到了一些疯狂的东西。难怪大多数开发者声称强制转换是邪恶而且应该避开的,对吧?

但是让我们退一步并做一下可行性检查。

通过大量比较,我们得到了一张7个麻烦的,坑人的强制转换的列表,但我们还得到了另一张(至少17个,但实际上有无限多个)完全正常和可以解释的强制转换的列表。

如果你在寻找一本“把孩子和洗澡水一起泼出去”的教科书,这就是了:由于一个仅有7个坑的列表,而抛弃整个强制转换(安全且有效的行为的无限大列表)。

一个更谨慎的反应是问,“我如何使用强制转换的 好的部分,而避开这几个 坏的部分 呢?”

让我们再看一次这个 列表:

  1. "0" == false; // true -- 噢!
  2. false == 0; // true -- 噢!
  3. false == ""; // true -- 噢!
  4. false == []; // true -- 噢!
  5. "" == 0; // true -- 噢!
  6. "" == []; // true -- 噢!
  7. 0 == []; // true -- 噢!

这个列表中7个项目的4个与== false比较有关,我们早先说过你应当 总是,总是 避免的。

现在这个列表缩小到了3个项目。

  1. "" == 0; // true -- 噢!
  2. "" == []; // true -- 噢!
  3. 0 == []; // true -- 噢!

这些是你在一般的JavaScript程序中使用的合理的强制转换吗?在什么条件下它们会发生?

我不认为你在程序里有很大的可能要在一个boolean测试中使用== [],至少在你知道自己在做什么的情况下。你可能会使用== ""== 0,比如:

  1. function doSomething(a) {
  2. if (a == "") {
  3. // ..
  4. }
  5. }

如果你偶然调用了doSomething(0)doSomething([]),你就会吓一跳。另一个例子:

  1. function doSomething(a,b) {
  2. if (a == b) {
  3. // ..
  4. }
  5. }

再一次,如果你调用doSomething("",0)doSomething([],"")时,它们会失败。

所以,虽然这些强制转换会咬到你的情况 可能 存在,而且你会小心地处理它们,但是它们可能不会在你的代码库中超级常见。

安全地使用隐含强制转换

我能给你的最重要的建议是:检查你的程序,并推理什么样的值会出现在==比较两边。为了避免这样的比较中的问题,这里有一些可以遵循的启发性规则:

  1. 如果比较的任意一边可能出现true或者false值,那么就永远,永远不要使用==
  2. 如果比较的任意一边可能出现[]"",或0这些值,那么认真地考虑不使用==

在这些场景中,为了避免不希望的强制转换,几乎可以确定使用===要比使用==好。遵循这两个简单的规则,可以有效地避免几乎所有可能会伤害你的强制转换的坑。

在这些情况下,使用更加明确/繁冗的方式会减少很多使你头疼的东西。

=====的问题其实可以更加恰当地表述为:你是否应当在比较中允许强制转换?

在许多情况下这样的强制转换会很有用,允许你更简练地表述一些比较逻辑(例如,nullundefined)。

对于整体来说,相对有几个 隐含 强制转换会真的很危险的情况。但是在这些地方,为了安全起见,绝对要使用===

提示: 另一个强制转换保证 不会 咬到你的地方是typeof操作符。typeof总是将返回给你7中字符串之一(见第一章),它们中没有一个是空""字符串。这样,检查某个值的类型时不会有任何情况与 隐含 强制转换相冲突。typeof x == "function"就像typeof x === "function"一样100%安全可靠。从字面意义上将,语言规范说这种情况下它们的算法是相同的。所以,不要只是因为你的代码工具告诉你这么做,或者(最差劲儿的)在某本书中有人告诉你 不要考虑它,而盲目地到处使用===。你掌管着你的代码的质量。

隐含 强制转换是邪恶和危险的吗?在几个情况下,是的,但总体说来,不是。

做一个负责任和成熟的开发者。学习如何有效并安全地使用强制转换(明确的隐含的 两者)的力量。并教你周围的人也这么做。

这里是由Alex Dorey (@dorey on GitHub)制作的一个方便的表格,将各种比较进行了可视化:

宽松等价与严格等价 - 图1

出处:https://github.com/dorey/JavaScript-Equality-Table