一文搞懂JavaScript类型和语法(三)

116 阅读13分钟

1 隐式强制类型转换

隐式强制类型转换指的是那些隐蔽的强制类型转换,副作用也不是很明显。换句话说,你自己觉得不够明显的强制类型转换都可以算作隐式强制类型转换。

1.1 字符串和数字之间的隐式强制类型转换

首先尝试理解下面的代码:

var a = [1,2];
var b = [3,4];

a + b; // "1,23,4"

a和b都不是字符串,但是它们都被强制转换为字符串然后进行拼接。原因何在?

扩展: 根据ES5规范,如果某个操作数是字符串或者能够通过以下步骤转换为字符串的话,+将进行拼接操作。如果其中一个操作数是对象(包括数组),则首先对其调用ToPrimitive抽象操作,该抽象操作再调用[[DefaultValue]],以数字作为上下文。

你或许注意到这与ToNumber抽象操作处理对象的方式一样(参见JavaScript类型和语法二)。因为数组的valueOf()操作无法得到简单基本类型值,于是它转而调用toString()。因此上例中的两个数组变成了"1,2"和"3,4"。+将它们拼接后返回"1,23,4"。

简单来说就是,如果+的其中一个操作数是字符串(或者通过以上步骤可以得到字符串),则执行字符串拼接;否则执行数字加法。

案例2:

var a = {
    valueOf: function() { return 42; },
    toString: function() { return 4; }
};

a + "";         // "42"

String( a );    // "4"

a + ""(隐式)和String(a)(显式)之间有一个细微的差别需要注意。根据ToPrimitive抽象操作规则,a + ""会对a调用valueOf()方法,然后通过ToString抽象操作将返回值转换为字符串。而String(a)则是直接调用ToString()。

字符串转数字

var a = "3.14";
var b = a -0;

b; // 3.14

-是数字减法运算符,因此a -0会将a强制类型转换为数字。也可以使用a * 1和a /1,因为这两个运算符也只适用于数字,只不过这样的用法不太常见。

对象的-操作与+类似:

var a = [3];
var b = [1];

a - b; // 2

为了执行减法运算,a和b都需要被转换为数字,它们首先被转换为字符串(通过toString()),然后再转换为数字。

1.2 布尔值到数字的隐式强制类型转换

案例:

如果其中有且仅有一个参数为true,则onlyOne(..)返回true。

let a = true
let b = false

function onlyOne() {
    var sum = 0;
    for (var i=0; i < arguments.length; i++) {
      // 跳过假值,和处理0一样,但是避免了NaN
      if (arguments[i]) {
          sum += arguments[i];
      }
    }
    return sum == 1;
}

var a = true;
var b = false;

onlyOne( b, a );                // true
onlyOne( b, a, b, b, b );       // true
onlyOne( b, b );                // false
onlyOne( b, a, b, b, b, a );    // false

同样的功能也可以通过显式强制类型转换来实现,如下:

function onlyOne() {
    var sum = 0;
    for (var i=0; i < arguments.length; i++) {
      sum += Number( ! ! arguments[i] );
    }
    return sum === 1;
}

!! arguments[i]首先将参数转换为true或false。 通过sum += arguments[i]中的隐式强制类型转换,将真值(true/truthy)转换为1并进行累加。 如果有且仅有一个参数为true,则结果为1;否则不等于1, sum == 1条件不成立。

1.3 隐式强制类型转换为布尔值

相对布尔值,数字和字符串操作中的隐式强制类型转换还算比较明显。下面的情况会发生布尔值隐式强制类型转换。

  • if (..)语句中的条件判断表达式。
  • for ( .. ; .. ; .. )语句中的条件判断表达式(第二个)。
  • while (..)和do..while(..)循环中的条件判断表达式。
  • ? :中的条件判断表达式。
  • 逻辑运算符||(逻辑或)和&&(逻辑与)左边的操作数(作为条件判断表达式)。

以上情况中,非布尔值会被隐式强制类型转换为布尔值,遵循前面介绍过的ToBoolean抽象操作规则,例如:

var a = 42;
var b = "abc";
var c;
var d = null;

if (a) {
    console.log( "yep" );       // yep
}

while (c) {
    console.log( "nope, never runs" );
}

c = d ? a : b;
c;                               // "abc"

if ((a && d) || c) {
    console.log( "yep" );       // yep
}

1.4 ||和&&

和其他语言不同,在JavaScript中它们返回的并不是布尔值。

它们的返回值是两个操作数中的一个(且仅一个)。即选择两个操作数中的一个,然后返回它的值。

引述ES5规范:&&和||运算符的返回值并不一定是布尔类型,而是两个操作数其中一个的值。

var a = 42;
var b = "abc";
var c = null;

a || b;     // 42
a && b;     // "abc"

c || b;     // "abc"
c && b;     // null

||和&&首先会对第一个操作数(a和c)执行条件判断,如果其不是布尔值(如上例)就先进行ToBoolean强制类型转换,然后再执行条件判断。

对于||来说,如果条件判断结果为true就返回第一个操作数(a和c)的值,如果为false就返回第二个操作数(b)的值。

&&则相反,如果条件判断结果为true就返回第二个操作数(b)的值,如果为false就返回第一个操作数(a和c)的值。

1.4.1 ||

下面是一个十分常见的||的用法,也许你已经用过但并未完全理解:

function foo(a, b) {
  a = a || "hello";
  b = b || "world";

  console.log( a + " " + b );
}

foo();                  // "hello world"
foo( "yeah", "yeah! " ); // "yeah yeah! "

a = a || "hello"(又称为C#的“空值合并运算符”的JavaScript版本)检查变量a,如果还未赋值(或者为假值),就赋予它一个默认值("hello")。

需要注意:

foo( "That's it! ", "" ); // "That's it! world" <-- 晕!

第二个参数""是一个假值(falsy value),因此b = b || "world"条件不成立,返回默认值"world"。

1.4.2 &&

有一种用法对开发人员不常见,然而JavaScript代码压缩工具常用。就是如果第一个操作数为真值,则&&运算符“选择”第二个操作数作为返回值,这也叫作“守护运算符”,即前面的表达式为后面的表达式“把关”:

function foo() {
  console.log( a );
}

var a = 42;

a && foo(); // 42

foo()只有在条件判断a通过时才会被调用。如果条件判断未通过,a && foo()就会悄然终止(也叫作“短路”, short circuiting), foo()不会被调用。

1.5 符号的强制类型转换

ES6中引入了符号类型,它的强制类型转换有一个坑,在这里有必要提一下。ES6允许从符号到字符串的显式强制类型转换,然而隐式强制类型转换会产生错误

var s1 = Symbol( "cool" );
String( s1 );     // "Symbol(cool)"

var s2 = Symbol( "not cool" );
s2 + "";      // TypeError

符号不能够被强制类型转换为数字(显式和隐式都会产生错误),但可以被强制类型转换为布尔值(显式和隐式结果都是true)。

2 宽松相等和严格相等

宽松相等(loose equals)==和严格相等(strict equals)===都用来判断两个值是否“相等”,但是它们之间有一个很重要的区别,特别是在判断条件上。

常见的误区是“==检查值是否相等,===检查值和类型是否相等”。听起来蛮有道理,然而还不够准确。 很多JavaScript的书籍和博客也是这样来解释的,但是很遗憾他们都错了。正确的解释是:“==允许在相等比较中进行强制类型转换,而===不允许。”

根据第一种解释(不准确的版本), ===似乎比==做的事情更多,因为它还要检查值的类型。第二种解释中==的工作量更大一些,因为如果值的类型不同还需要进行强制类型转换。

==和===都会检查操作数的类型。区别在于操作数类型不同时它们的处理方式不同。

2.1 抽象相等

ES5规范定义了对象(包括函数和数组)的宽松相等==。两个对象指向同一个值时即视为相等,不发生强制类型转换。在比较两个对象的时候; ==和===的工作原理是一样的。

ES5规范中还规定,==在比较两个不同类型的值时会发生隐式强制类型转换,会将其中之一或两者都转换为相同的类型后再进行比较。

2.2 字符串和数字之间的相等比较

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的结果。

根据规范,"42"应该被强制类型转换为数字以便进行相等比较。

2.3 其他类型和布尔类型之间的相等比较

var a = "42";
var b = true;

a == b; // false

我们都知道"42"是一个真值(见本章前面部分),为什么==的结果不是true呢?

规范11.9.3.6-7是这样说的: (1) 如果Type(x)是布尔类型,则返回ToNumber(x) == y的结果; (2) 如果Type(y)是布尔类型,则返回x == ToNumber(y)的结果。

Type(x)是布尔值,所以ToNumber(x)将true强制类型转换为1,变成1 == "42",二者的类型仍然不同,"42"根据规则被强制类型转换为42,最后变成1 == 42,结果为false。

也就是说,字符串"42"既不等于true,也不等于false。一个值怎么可以既非真值也非假值,这也太奇怪了吧?

"42"是一个真值没错,但"42" == true中并没有发生布尔值的比较和强制类型转换。 这里不是"42"转换为布尔值(true),而是true转换为1, "42"转换为42。

这里并不涉及ToBoolean,所以"42"是真值还是假值与==本身没有关系!

重点是我们要搞清楚==对不同的类型组合怎样处理。==两边的布尔值会被强制类型转换为数字。很奇怪吧? 个人建议无论什么情况下都不要使用== true和== false。

2.4 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可以进行隐性强制类型转换,但除此之外,与其他任何假值(falsey value)比较,都会返回false,如上例中的'', 0, false

null和undefined之间的强制类型转换是安全可靠的,上例中除null和undefined以外的其他值均无法得到假阳(false positive)结果。个人认为通过这种方式将null和undefined作为等价值来处理比较好。


再看下面的例子:

var a = doSomething();

if (a == null) {
    // ..
}

条件判断a == null仅在doSomething()返回null和undefined时才成立,除此之外其他值都不成立,包括0、false和""这样的假值。

2.5 对象和非对象之间的相等比较

关于对象(对象/函数/数组)和标量基本类型(字符串/数字/布尔值)之间的相等比较, ES5规范11.9.3.8-9做如下规定: (1) 如果Type(x)是字符串或数字,Type(y)是对象,则返回x == ToPrimitive(y)的结果; (2) 如果Type(x)是对象,Type(y)是字符串或数字,则返回ToPrimitive(x) == y的结果。

这里只提到了字符串和数字,没有布尔值。原因是我们之前介绍过11.9.3.6-7中规定了布尔值会先被强制类型转换为数字。

即布尔值和任意类型的值比较时,都会先将布尔值强转为数字。

var a = 42;
var b = [ 42 ];

a == b; // true

[ 42 ]首先调用ToPrimitive抽象操作,返回"42",变成"42" == 42,然后又变成42== 42,最后二者相等。

var a = "abc";
var b = Object( a );    // 和new String( a )一样

a === b;                // false
a == b;                 // true

a == b结果为true,因为b通过ToPrimitive进行强制类型转换(也称为“拆封”,英文为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.6 比较少见的情况

2.6.1 返回其他数字

Number.prototype.valueOf = function() {
    return 3;
};

new Number( 2 ) == 3;   // true

2 == 3不会有这种问题,因为2和3都是数字基本类型值,不会调用Number.prototype.valueOf()方法。 而Number(2)涉及ToPrimitive强制类型转换,因此会调用valueOf()。

还有更奇怪的现象:

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.6.2 假值的相等比较

==中的隐式强制类型转换最为人诟病的地方是假值的相等比较。

下面分别列出了常规和非常规的情况:

"0" == null;           // false
"0" == undefined;      // false
"0" == false;          // true -- 晕! -> '0' == 0 ->  0 == 0
"0" == NaN;            // false
"0" == 0;              // true
"0" == "";             // false

false == null;         // false
false == undefined;    // false
false == NaN;          // false
false == 0;            // true -- 晕! -> 0 == 0
false == "";           // true -- 晕! -> 0 == '' -> 0 == 0(注意:Number('') = 0 )
false == [];           // true -- 晕! -> 0 == '' -> 0 == 0( 注意:[].toString() = '' )
false == {};           // false

"" == null;            // false
"" == undefined;       // false
"" == NaN;             // false
"" == 0;               // true -- 晕! -> 0 == 0
"" == [];              // true -- 晕! -> '' == ''
"" == {};              // false

0 == null;             // false
0 == undefined;        // false
0 == NaN;              // false
0 == [];               // true -- 晕!-> 0 == '' -> 0 == 0
0 == {};               // false

2.6.3 极端情况

这还不算完,还有更极端的例子:

[] == ! []   // true  

转换过程:

  1. 首先[]调用toString()转为 '', ![]会将[]强转为布尔值,[]是对象,所以![]false 变成了:'' == false

  2. 接着false会转成数字0(布尔值只能转数字) 变成了:'' == 0

  3. 最后 '' 转为数字0 , 变成了:0 == 0


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

[2]会先调用数组的valueOf()方法,发现返回的时数组[2],并非基本数据类型,进而调用数组的toString()方法,返回'2','2' == 2 自然返回true

[null]同样调用valueOf()未果后,尝试调用toString(),将[null]转为'', '' == '' 也自然为true


0 == "\n";  // true

""、"\n"(或者" "等其他空格组合)等空字符串被ToNumber强制类型转换为0,所以返回true

2.6.4 安全运用隐式强制类型转换

我们要对==两边的值认真推敲,以下两个原则可以让我们有效地避免出错。

  • 如果两边的值中有true或者false,千万不要使用==。
  • 如果两边的值中有[]、""或者0,尽量不要使用==。

这时最好用===来避免不经意的强制类型转换。这两个原则可以让我们避开几乎所有强制类型转换的坑。

3 抽象关系比较

a < b中涉及的隐式强制类型转换不太引人注意,不过还是很有必要深入了解一下。

ES5规范11.8.5节定义了“抽象关系比较”(abstract relational comparison),分为两个部分: 比较双方都是字符串(后半部分)和其他情况(前半部分)。

比较双方首先调用ToPrimitive,如果结果出现非字符串,就根据ToNumber规则将双方强制类型转换为数字来进行比较。

var a = [ 42 ];
var b = [ "43" ];
a < b;  // true
b < a;  // false

如果比较双方都是字符串,则按字母顺序来进行比较:

var a = [ "42" ];
var b = [ "043" ];

a < b;  // false

a和b并没有被转换为数字,因为ToPrimitive返回的是字符串,所以这里比较的是"42"和"043"两个字符串,它们分别以"4"和"0"开头。因为"0"在字母顺序上小于"4",所以最后结果为false。

同理:

var a = [ 4, 2 ];
var b = [ 0, 4, 3 ];

a < b;  // false

a转换为"4, 2", b转换为"0, 4, 3",同样是按字母顺序进行比较。

再比如:

var a = { b: 42 };
var b = { b: 43 };

a < b;  // ? ?

结果还是false,因为a是[object Object], b也是[object Object],所以按照字母顺序a < b并不成立。

下面的例子就有些奇怪了:

var a = { b: 42 };
var b = { b: 43 };

a < b;  // false
a == b; // false
a > b;  // false

a <= b; // true
a >= b; // true

为什么a == b的结果不是true?它们的字符串值相同(同为"[object Object]"),按道理应该相等才对?实际上不是这样,你可以回忆一下前面讲过的对象的相等比较(只有两个对象指向同一块内存区域才相等,二者比较期间不存在ToPrimitive)。

但是如果a < b和a == b结果为false,为什么a <= b和a >= b的结果会是true呢?

因为根据规范a <= b被处理为b < a,然后将结果反转。因为b < a的结果是false,所以a <= b的结果是true。

这可能与我们设想的大相径庭,即<=应该是“小于或者等于”。实际上JavaScript中<=是“不大于”的意思(即!(a > b),处理为!(b < a))。同理,a >= b处理为b <= a。

相等比较有严格相等,关系比较却没有“严格关系比较”(strict relational comparison)。也就是说如果要避免a < b中发生隐式强制类型转换,我们只能确保a和b为相同的类型,除此之外别无他法。

想要了解更详细的内容,见《你不知道的JS》中卷第一部分第四章