4.5 宽松相等和严格相等
宽松相等(loose equals)== 和严格相等(strict equals)=== 都用来判断两个值是否“相等”,但是它们之间有一个很重要的区别,特别是在判断条件上。
常见的误区是“== 检查值是否相等,=== 检查值和类型是否相等”。听起来蛮有道理,然而还不够准确。很多 JavaScript 的书籍和博客也是这样来解释的,但是很遗憾他们都错了。
正确的解释是:“== 允许在相等比较中进行强制类型转换,而 === 不允许。”
4.5.1 相等比较操作的性能
我们来看一看两种解释的区别。
根据第一种解释(不准确的版本),=== 似乎比 == 做的事情更多,因为它还要检查值的类型。第二种解释中 == 的工作量更大一些,因为如果值的类型不同还需要进行强制类型转换。
有人觉得 == 会比 === 慢,实际上虽然强制类型转换确实要多花点时间,但仅仅是微秒级(百万分之一秒)的差别而已。
如果进行比较的两个值类型相同,则 == 和 === 使用相同的算法,所以除了 JavaScript 引擎实现上的细微差别之外,它们之间并没有什么不同。
如果两个值的类型不同,我们就需要考虑有没有强制类型转换的必要,有就用 == ,没有就用 === ,不用在乎性能。
== 和 === 都会检查操作数的类型。区别在于操作数类型不同时它们的处理方式不同。
4.5.2 抽象相等
ES5 规范 11.9.3 节的“抽象相等比较算法”定义了 == 运算符的行为。该算法简单而又全面,涵盖了所有可能出现的类型组合,以及它们进行强制类型转换的方式。
“抽象相等”(abstract equality)的这些规则正是隐式强制类型转换被诟病的原因。开发人员觉得它们太晦涩,很难掌握和运用,弊(导致 bug)大于利(提高代码可读性)。这种观点我不敢苟同,因为本书的读者都是优秀的开发人员,整天与算法和代码打交道,“抽象相等”对各位来说只是小菜一碟。建议大家看一看 ES5 规范 11.9.3 节,你会发现这些规则其实非常简单明了。
其中第一段(11.9.3.1)规定如果两个值的类型相同,就仅比较它们是否相等。例如,42 等于 42 ,"abc" 等于 "abc" 。
有几个非常规的情况需要注意。
· NaN 不等于 NaN (参见第 2 章)。
· +0 等于 -0 (参见第 2 章)。
11.9.3.1 的最后定义了对象(包括函数和数组)的宽松相等 == 。两个对象指向同一个值 时即视为相等,不发生强制类型转换。
=== 的定义和 11.9.3.1 一样,包括对象的情况。实际上在比较两个对象的时候,== 和 === 的工作原理是一样的。
11.9.3 节中还规定,== 在比较两个不同类型的值时会发生隐式 强制类型转换,会将其中之一或两者都转换为相同的类型后再进行比较。
宽松不相等(loose not-equality)!= 就是 == 的相反值,!== 同理。
1. 字符串和数字之间的相等比较
我们沿用本章前面字符串和数字的例子来解释 == 中的强制类型转换:
var a = 42; var b = "42"; a === b; // false a == b; // true
因为没有强制类型转换,所以 a === b 为 false ,42 和 "42" 不相等。
而 a == b 是宽松相等,即如果两个值的类型不同,则对其中之一或两者都进行强制类型转换。
具体怎么转换?是 a 从 42 转换为字符串,还是 b 从 "42" 转换为数字?
ES5 规范 11.9.3.4-5 这样定义:
(1) 如果 Type(x) 是数字,Type(y) 是字符串,则返回 x == ToNumber(y) 的结果。
(2) 如果 Type(x) 是字符串,Type(y) 是数字,则返回 ToNumber(x) == y 的结果。
规范使用 Number 和 String 来代表数字和字符串类型,而本书使用的是数字(number )和字符串(string )。切勿将规范中的 Number 和原生函数 Number() 混为一谈。本书中类型名的首字符大写和小写是一回事。
根据规范,"42" 应该被强制类型转换为数字以便进行相等比较。相关规则,特别是 ToNumber 抽象操作的规则前面已经介绍过。本例中两个值相等,均为 42 。
2. 其他类型和布尔类型之间的相等比较
== 最容易出错的一个地方是 true 和 false 与其他类型之间的相等比较。
例如:
var a = "42"; var b = true; a == b; // false
我们都知道 "42" 是一个真值(见本章前面部分),为什么 == 的结果不是 true 呢?原因既简单又复杂,让人很容易掉坑里,很多 JavaScript 开发人员对这个地方并未引起足够的重视。
规范 11.9.3.6-7 是这样说的:
(1) 如果 Type(x) 是布尔类型,则返回 ToNumber(x) == y 的结果;
(2) 如果 Type(y) 是布尔类型,则返回 x == ToNumber(y) 的结果。
仔细分析例子,首先:
var x = true; var y = "42"; x == y; // false
Type(x) 是布尔值,所以 ToNumber(x) 将 true 强制类型转换为 1 ,变成 1 == "42" ,二者的类型仍然不同,"42" 根据规则被强制类型转换为 42 ,最后变成 1 == 42 ,结果为 false 。
反过来也一样:
var x = "42"; var y = false; x == y; // false
Type(y) 是布尔值,所以 ToNumber(y) 将 false 强制类型转换为 0 ,然后 "42" == 0 再变成 42 == 0 ,结果为 false 。
也就是说,字符串 "42" 既不等于 true ,也不等于 false 。一个值怎么可以既非真值也非假值,这也太奇怪了吧?
这个问题本身就是错误的,我们被自己的大脑欺骗了。
"42" 是一个真值没错,但 "42" == true 中并没有发生布尔值的比较和强制类型转换。这里不是 "42" 转换为布尔值(true ),而是 true 转换为 1 ,"42" 转换为 42 。
这里并不涉及 ToBoolean ,所以 "42" 是真值还是假值与 == 本身没有关系!
重点是我们要搞清楚 == 对不同的类型组合怎样处理。== 两边的布尔值会被强制类型转换为数字。
很奇怪吧?我个人建议无论什么情况下都不要使用 == true 和 == false 。
请注意,这里说的只是 == ,=== true 和 === false 不允许强制类型转换,所以并不涉及 ToNumber 。
例如:
var a = "42"; // 不要这样用,条件判断不成立: if (a == true) { // .. } // 也不要这样用,条件判断不成立: if (a === true) { // .. } // 这样的显式用法没问题: if (a) { // .. } // 这样的显式用法更好: if (!!a) { // .. } // 这样的显式用法也很好: if (Boolean( a )) { // .. }
避免了 == true 和 == false (也叫作布尔值的宽松相等)之后我们就不用担心这些坑了。
3. null 和 undefined 之间的相等比较
null 和 undefined 之间的 == 也涉及隐式强制类型转换。ES5 规范 11.9.3.2-3 规定:
(1) 如果 x 为 null ,y 为 undefined ,则结果为 true 。
(2) 如果 x 为 undefined ,y 为 null ,则结果为 true 。
在 == 中 null 和 undefined 相等(它们也与其自身相等),除此之外其他值都不存在这种情况。
这也就是说在 == 中 null 和 undefined 是一回事,可以相互进行隐式强制类型转换:
var a = null; var b; a == b; // true a == null; // true b == null; // true a == false; // false b == false; // false a == ""; // false b == ""; // false a == 0; // false b == 0; // false
null 和 undefined 之间的强制类型转换是安全可靠的,上例中除 null 和 undefined 以外的其他值均无法得到假阳(false positive)结果。个人认为通过这种方式将 null 和 undefined 作为等价值来处理比较好。
例如:
var a = doSomething(); if (a == null) { // .. }
条件判断 a == null 仅在 doSomething() 返回非 null 和 undefined 时才成立,除此之外其他值都不成立,包括 0 、false 和 "" 这样的假值。
下面是显式的做法,其中不涉及强制类型转换,个人感觉更繁琐一些(大概执行效率也会更低):
var a = doSomething(); if (a === undefined || a === null) { // .. }
我认为 a == null 这样的隐式强制类型转换在保证安全性的同时还能提高代码可读性。
4. 对象和非对象之间的相等比较
关于对象(对象 / 函数 / 数组)和标量基本类型(字符串 / 数字 / 布尔值)之间的相等比较,ES5 规范 11.9.3.8-9 做如下规定:
(1) 如果 Type(x) 是字符串或数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果;
(2) 如果 Type(x) 是对象,Type(y) 是字符串或数字,则返回 ToPromitive(x) == y 的结果。
这里只提到了字符串和数字,没有布尔值。原因是我们之前介绍过 11.9.3.6-7 中规定了布尔值会先被强制类型转换为数字。
例如:
var a = 42; var b = [ 42 ]; a == b; // true
[ 42 ] 首先调用 ToPromitive 抽象操作(参见 4.2 节),返回 "42" ,变成 "42" == 42 ,然后又变成 42 == 42 ,最后二者相等。
之前介绍过的 ToPromitive 抽象操作的所有特性(如 toString() 、valueOf() )在这里都适用。如果我们需要自定义 valueOf() 以便从复杂的数据结构返回一个简单值进行相等比较,这些特性会很有帮助。
在第 3 章中,我们介绍过“拆封”,即“打开”封装对象(如 new String("abc") ),返回其中的基本数据类型值("abc" )。== 中的 ToPromitive 强制类型转换也会发生这样的情况:
var a = "abc"; var b = Object( a ); // 和new String( a )一样 a === b; // false a == b; // true
a == b 结果为 true ,因为 b 通过 ToPromitive 进行强制类型转换(也称为“拆封”,英文为 unboxed 或者 unwrapped),并返回标量基本类型值 "abc" ,与 a 相等。
但有一些值不这样,原因是 == 算法中其他优先级更高的规则。例如:
var a = null; var b = Object( a ); // 和Object()一样 a == b; // false var c = undefined; var d = Object( c ); // 和Object()一样 c == d; // false var e = NaN; var f = Object( e ); // 和new Number( e )一样 e == f; // false
因为没有对应的封装对象,所以 null 和 undefined 不能够被封装(boxed),Object(null) 和 Object() 均返回一个常规对象。
NaN 能够被封装为数字封装对象,但拆封之后 NaN == NaN 返回 false ,因为 NaN 不等于 NaN (参见第 2 章)。
4.5.3 比较少见的情况
我们已经全面介绍了 == 中的隐式强制类型转换(常规和非常规的情况),现在来看一下那些需要特别注意和避免的比较少见的情况。
首先来看看更改内置原生原型会导致哪些奇怪的结果。
1. 返回其他数字
Number.prototype.valueOf = function() { return 3; }; new Number( 2 ) == 3; // true
2 == 3 不会有这种问题,因为 2 和 3 都是数字基本类型值,不会调用 Number.prototype.valueOf() 方法。而 Number(2) 涉及 ToPrimitive 强制类型转换,因此会调用 valueOf() 。
真是让人头大。这也是强制类型转换和 == 被诟病的原因之一。但问题并非出自 JavaScript,而是我们自己。不要有这样的想法,觉得“编程语言应该阻止我们犯错误”。
还有更奇怪的情况:
if (a == 2 && a == 3) { // .. }
你也许觉得这不可能,因为 a 不会同时等于 2 和 3 。但“同时”一词并不准确,因为 a == 2 在 a == 3 之前执行。
如果让 a.valueOf() 每次调用都产生副作用,比如第一次返回 2 ,第二次返回 3 ,就会出现这样的情况。这实现起来很简单:
var i = 2; Number.prototype.valueOf = function() { return i++; }; var a = new Number( 42 ); if (a == 2 && a == 3) { console.log( "Yep, this happened." ); }
再次强调,千万不要这样,也不要因此而抱怨强制类型转换。对一种机制的滥用并不能成为诟病它的借口。我们应该正确合理地运用强制类型转换,避免这些极端的情况。
2. 假值的相等比较
== 中的隐式强制类型转换最为人诟病的地方是假值的相等比较。
下面分别列出了常规和非常规的情况:
"0" == null; // false "0" == undefined; // false "0" == false; // true -- 晕! "0" == NaN; // false "0" == 0; // true "0" == ""; // false false == null; // false false == undefined; // false false == NaN; // false false == 0; // true -- 晕! false == ""; // true -- 晕! false == []; // true -- 晕! false == {}; // false "" == null; // false "" == undefined; // false "" == NaN; // false "" == 0; // true -- 晕! "" == []; // true -- 晕! "" == {}; // false 0 == null; // false 0 == undefined; // false 0 == NaN; // false 0 == []; // true -- 晕! 0 == {}; // false
以上 24 种情况中有 17 种比较好理解。比如我们都知道 "" 和 NaN 不相等,"0" 和 0 相等。
然而有 7 种我们注释了“晕!”,因为它们属于假阳(false positive)的情况,里面坑很多。 "" 和 0 明显是两个不同的值,它们之间的强制类型转换很容易搞错。请注意这里不存在假阴(false negative)的情况。
3. 极端情况
这还不算完,还有更极端的例子:
[] == ![] // true
事情变得越来越疯狂了。看起来这似乎是真值和假值的相等比较,结果不应该是 true ,因为一个值不可能同时既是真值也是假值!
事实并非如此。让我们看看 ! 运算符都做了些什么?根据 ToBoolean 规则,它会进行布尔值的显式强制类型转换(同时反转奇偶校验位)。所以 [] == ![] 变成了 [] == false 。前面我们讲过 false == [] ,最后的结果就顺理成章了。
再来看看其他情况:
2 == [2]; // true "" == [null]; // true
介绍 ToNumber 时我们讲过,== 右边的值 [2] 和 [null] 会进行 ToPrimitive 强制类型转换,以便能够和左边的基本类型值(2 和 "" )进行比较。因为数组的 valueOf() 返回数组本身,所以强制类型转换过程中数组会进行字符串化。
第一行中的 [2] 会转换为 "2" ,然后通过 ToNumber 转换为 2 。第二行中的 [null] 会直接转换为 "" 。
所以最后的结果就是 2 == 2 和 "" == "" 。
如果还是觉得头大,那么你的困惑可能并非来自强制类型转换,而是 ToPrimitive 将数组转换为字符串这一过程。也许你认为 [2].toString() 返回的不是 "2" ,[null].toString() 返回的也不是 "" 。
但是如果不这样处理的话又能怎样呢?我实在想不出其他更好的办法。或许应该将 [2] 转换为 "[2]" ,但这样的话在别的地方又显得很奇怪。
有人也许会觉得既然 String(null) 返回 "null" ,所以 String([null]) 也应该返回 "null" 。确实有道理,但这就是问题所在。
隐式强制类型转换本身不是问题的根源,因为 [null] 在显式强制类型转换中也是转换为 "" 。问题在于将数组转换为字符串是否合理,具体该如何处理。所以实际上这是 String([..]) 规则的问题。又或者根本就不应该将数组转换为字符串?但这样一来又会导致很多其他问题。
还有一个坑常常被提到:
0 == "\n"; // true
前面介绍过,"" 、"\n" (或者 " " 等其他空格组合)等空字符串被 ToNumber 强制类型转换为 0 。这样处理总没有问题了吧,不然你要怎么办?
或许可以将空字符串和空格转换为 NaN ,这样 " " == NaN 就为 false 了,然而这并没有从根本上解决问题。
0 == "\n" 导致程序出错的几率小之又小,很容易避免。
类型转换总会出现一些特殊情况,并非只有强制类型转换,任何编程语言都是如此。问题出在我们的臆断(有时或许碰巧猜对了?!),但这并不能成为诟病强制类型转换机制的理由。
上述 7 种情况基本涵盖了所有我们可能遇到的坑(除修改 valueOf() 和 toStrign() 的情况以外)。
与前面 24 种情况列表相对应的是下面这个列表:
42 == "43"; // false "foo" == 42; // false "true" == true; // false 42 == "42"; // true "foo" == [ "foo" ]; // true
这些是非假值的常规情况(实际上还可以加上无穷大数字的相等比较),其中涉及的强制类型转换是安全的,也比较好理解。
4. 完整性检查
我们深入介绍了隐式强制类型转换中的一些特殊情况。也难怪大多数开发人员都觉得这太晦涩,唯恐避之不及。
现在回过头来做一下完整性检查(sanity check)。
前面列举了相等比较中的强制类型转换的 7 个坑,不过另外还有至少 17 种情况是绝对安全和容易理解的。
因为 7 棵歪脖树而放弃整片森林似乎有点因噎废食了,所以明智的做法是扬其长避其短。
再来看看那些“短”的地方:
"0" == false; // true -- 晕! false == 0; // true -- 晕! false == ""; // true -- 晕! false == []; // true -- 晕! "" == 0; // true -- 晕! "" == []; // true -- 晕! 0 == []; // true -- 晕!
其中有 4 种情况涉及 == false ,之前我们说过应该避免,应该不难掌握。
现在剩下 3 种:
"" == 0; // true -- 晕! "" == []; // true -- 晕! 0 == []; // true -- 晕!
正常情况下我们应该不会这样来写代码。我们应该不太可能会用 == [] 来做条件判断,而是用 == "" 或者 == 0 ,如:
function doSomething(a) { if (a == "") { // .. } }
如果不小心碰到 doSomething(0) 和 doSomething([]) 这样的情况,结果会让你大吃一惊。又如:
function doSomething(a,b) { if (a == b) { // .. } }
doSomething("",0) 和 doSomething([],"") 也会如此。
这些特殊情况会导致各种问题,我们要多加小心,好在它们并不十分常见。
5. 安全运用隐式强制类型转换
我们要对 == 两边的值认真推敲,以下两个原则可以让我们有效地避免出错。
· 如果两边的值中有 true 或者 false ,千万不要使用 == 。
· 如果两边的值中有 [] 、"" 或者 0 ,尽量不要使用 == 。
这时最好用 === 来避免不经意的强制类型转换。这两个原则可以让我们避开几乎所有强制类型转换的坑。
这种情况下强制类型转换越显式越好,能省去很多麻烦。
所以 == 和 === 选择哪一个取决于是否允许在相等比较中发生强制类型转换。
强制类型转换在很多地方非常有用,能够让相等比较更简洁(比如 null 和 undefined )。
隐式强制类型转换在部分情况下确实很危险,这时为了安全起见就要使用 === 。
有一种情况下强制类型转换是绝对安全的,那就是 typeof 操作。typeof 总是返回七个字符串之一(参见第 1 章),其中没有空字符串。所以在类型检查过程中不会发生隐式强制类型转换。typeof x == "function" 是 100% 安全的,和 typeof x === "function" 一样。事实上两者在规范中是一回事。所 以既不要盲目听命于代码工具每一处都用 === ,更不要对这个问题置若罔闻。我们要对自己的代码负责。
隐式强制类型转换真的那么不堪吗?某些情况下是,但总的来说并非如此。
作为一个成熟负责的开发人员,我们应该学会安全有效地运用强制类型转换(显式和隐式),并对周围的同行言传身教。
Alex Dorey(GitHub 用户名 @dorey)在 GitHub 上制作了一张图表,列出了各种相等比较的情况,如图 4-1 所示。
图 4-1:JavaScript 中的相等比较
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论