返回介绍

第一部分 类型和语法

第二部分 异步和性能

4.5 宽松相等和严格相等

发布于 2023-05-24 16:38:21 字数 13166 浏览 0 评论 0 收藏 0

宽松相等(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. nullundefined 之间的相等比较

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 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文