值的类型转换

112 阅读13分钟

序言

值的类型转换是非常有争议的一点,因为这会产生一些问题,并且令人无法理解。但正因为如此,我们更要认识它,理解它。

值类型转换

JS中的类型转换统称:强制类型转换。其中区分“隐式强制类型转换”和“显示强制类型转换”

var a = 42;
var b = a + ""; // 隐式强制类型转换 
var c = String( a ); // 显式强制类型转换

由于 +运算符的其中一个操作数是字符串,因此是字符串拼接的操作,这里发生了隐式强制。后者直接使用了String(),则是显示强制。

抽象值操作

  • ToString

    ToString负责处理非字符串到字符串的强制类型转换,如果对象有自己的toString()方法,字符串化时会调用该方法并使用起返回值。

let a = 123;
a.toString() // '123'

let b = [1,2,3]
b.toString() // '1,2,3'

数字会直接返回该值的字符串形式,数组则会返回用“,”拼接的结果。它可以主动调用,也可以内部自动调用。

JSON 字符串化

工具函数 JSON.stringify(..) 在将 JSON 对象序列化为字符串时也用到了 ToString。虽然JSON 字符串化并非严格意义上的强制类型转换。它只是部分转换机制和 toString()相同。

所有安全的JSON值都可以使用JSON.stringify()字符串化。那么什么是不安全的呢,就是undefined、function、symbol和包含循环引用的对象都不符合JSON结构标准。

JSON.stringify( undefined ); //undefined
JSON.stringify( function(){} ); //undefined
JSON.stringify(
    [1,undefined,function(){},4] //[1,null,null,4]
);
JSON.stringify(
{ a:2, b:function(){} } // { a:2 } 
}

JSON.stringify(..) 在对象中遇到 undefined、function 和 symbol 时会自动将其忽略,在 数组中则会返回 null(以保证单元位置不变)。如果对象中定义了 toJSON() 方法,JSON 字符串化时会首先调用该方法,然后用它的返回 值来进行序列化。

 var a = {
    val: [1,2,3],
    toJSON: function(){
        return this.val.slice( 1 );
    }
};
JSON.stringify( a ); // "[2,3]"

toJSON() 应该是“返回一个能够被字符串化的安全的 JSON 值”。当然需要我们来定义它的返回

JSON.stringify(..) 还可以传递一个可选参数 replacer,它可以是数组或者函数,用来指定对象序列化过程中哪些属性应该被处理,哪些应该被排除。

如果是数组则必须是字符串数组,其中包含要被处理的对象,除此之外的其他属性将被忽略。
如何是函数,它会先调用对象一次,然后它会对对象中的各个属性调用一次,每次带两个参数,分别是键和值

var a = { 
    b: 42,
    c: "42",
    d: [1,2,3] 
};
JSON.stringify( a, ["b","c"] ); // "{"b":42,"c":"42"}"
JSON.stringify( a, function(k,v){
 if (k !== "c") return v;
} );
// "{"b":42,"d":[1,2,3]}"

JSON.string 还有一个可选参数 space,用来指定输出的缩进格式。space 为正整数时是指 每一级缩进的字符数,它还可以是字符串,此时最前面的十个字符被用于每一级的缩进:

var a = { 
    b: 42,
    c: "42",
    d: [1,2,3] 
};
JSON.stringify( a, null, 3 );
// "{
//
//
//
//
//
//       3
//    ]
// }"
  • ToNumber ToNumber的转换规则:
  1. 是true 转换为 1,false 转换为 0。undefined 转换为 NaN,null 转换为 0。
  2. 对象(包括数组)会首先被转换为相应的基本类型值,如果返回的是非数字的基本类型值,则再遵循以上规则将其强制转换为数字。

为了将值转换为基本类型,抽象操作 ToPrimitive会有几点操作

  1. 检查该值是否有 valueOf() 方法,如果有并且返回基本类型值,就使用该值进行强制类型转换
  2. 如果没有就使用 toString()的返回值(如果存在)来进行强制类型转换。
  3. 如果 valueOf() 和 toString() 均不返回基本类型值,会产生 TypeError 错误。
var a = {
 valueOf: function(){
     return "42";
 }
};
var b = {
 toString: function(){
     return "42";
 }
};
 var c = [4,2];
 c.toString = function(){
     return this.join( "" ); // "42"
 };
Number( a ); // 42
Number( b ); // 42
Number( c ); // 42
Number( "" ); // 0
Number( [] ); // 0
Number( [ "abc" ] );// NaN
  • ToBoolean JS 中有两个关键词 true 和 false,分别代表布尔类型中的真和假,我们常认为1和0分别代表true和false,但是实际上并不是这样的。
  1. 假值(falsy value) JavaScript 中的值可以分为以下两类:
    (1) 可以被强制类型转换为 false 的值
    (2) 其他(被强制类型转换为 true 的值)

ES5 规范 9.2 节中定义了抽象操作 ToBoolean,列举了布尔强制类型转换所有可能出现的结果。

以下这些是假值:

  1. undefined

  2. null

  3. false

  4. +0、-0 和 NaN

  5. "" 假值的布尔强制类型转换结果为 false。

  6. 真值(truthy value) 这当然是除假值之外的值,像[],{},function(){},等都是真值,因为它们不在假值列表里面。

显示强制类型转换

  1. 字符串和数字之间的显示转换 这一转换在实际中还是比较常见的,比较直接的是使用Number()和String()直接转换。
 var a = 42;
 var b = String( a );
 var c = "3.14";
 var d = Number( c );

另外,一元运算符 +,-。

let a = '3.14';
let b = +a //3.14

另外 ~ 运算符也可以,它首先将值强制类型转换为 32 位数字,然后执行字位操作“非”(对每一个字位进行反转)。说白点,~ 返回 2 的补码,~x 大致等同于 -(x+1),如:~42 ==> -43

~ 和indexof结合可以用来判断某字符是否存在,更简洁。

var a = "Hello World";
if (~a.indexOf( "lo" )) { // ~a.indexOf( "lo" ) 等于 -4 是真值

}
~a.indexOf( "ol" ); // indexOf( "ol" )找不到返回-1,这样整个值就是 0 ==>假值,取反变真
if (!~a.indexOf( "ol" )) { // true 没有找到匹配!

}

解析数字字符串

解析允许字符串中含有非数字字符,解析按从左到右的顺序,如果遇到非数字字符就停 止。而转换不允许出现非数字字符,否则会失败并返回 NaN。

let a = '42px'
Number(a) // NaN
parseInt(a) //42

parseInt的第二个参数是指转换的基数,如2,8,16等进制,ES5开始默认是10进制。来看个例子

parseInt( 1/0, 19 ); // 18

是不是懵了,为啥啊,不应该是 Infinity么,简直离谱。

我们来看看它是怎么运行的,1/0有两种选择,"Infinity" 和 "∞",显然JS选择的是Infinity,不然后续就报错了。所以上式解析成parseInt( "Infinity", 19 ),那么按19进制来看的话,第一个数值 I 在19进制里面是有效数值,(19进制:0,1,2,3,4,5,6,7,8,9,A,B,C,D,E,F,G,H,I),而后续的 n 在19进制里面是无效的,由于parseInt的解析是遇到非进制数字的字符就停止了,故只取I,故最后结果表示为10进制的 18。

  1. 显式转换为布尔值 Boolean和 !运算符都能显示的将值强制转换成布尔值。!!则会将值反转为原值。常见的三目运算符也是显示的强制类型转换,只不过,中间掺杂了的隐式强制类型转换,需要注意。

隐式强制类型转换

隐式强制类型转换指的是那些隐蔽的强制类型转换,副作用也不是很明显。但是这转换起来会让代码变得晦涩难懂。当然有好有坏,我们应该做的是取其精华。

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

    之前我们知道,+运算符能用于数字加法,也能用于字符串拼接,那如何知道是什么操作呢。

    这就得按照规范来看了,如果某操作数能够通过以下步骤转换得到字符串,将进行拼接操作。如果其中一个是对象,则首先调用ToPrimitive 抽象操作,该抽象操作再调用[[DefaultValue]],以数字为上下文。

var a = [1,2];
var b = [3,4];
a + b; // "1,23,4"

这里的数组valueOf操作无法得到基本类型字符,故而使用ToString,两个数组变成了了1,2和3,4故结果为1,23,4。

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

数字可以通过和空字符串" " 相+ 来将其转换成字符串

var a = 42;
var b = a + "";
b; // "42"

这种 a+"" 和之前 String() 有一个区别是,a + ""会对a调用valueOf()方法,然后通过ToString抽象 操作将返回值转换为字符串。而 String(a) 则是直接调用 ToString()。

-,*,/ 等运算符则是直接执行了数学运算,他们会被转换成数字再进行操作。

  • 布尔值到数字的隐式强制类型转换 布尔值遇到 + 时,true会变成1,false变成0。
true + true //2
true + false //1 
//这里都是隐式转换
  • 隐式强制类型转换为布尔值 下面的情况会发生 布尔值隐式强制类型转换。
  1. if (..)语句中的条件判断表达式。
  2. for ( .. ; .. ; .. )语句中的条件判断表达式(第二个)。
  3. while (..) 和 do..while(..) 循环中的条件判断表达式。
  4. ? :中的条件判断表达式。
  5. 逻辑运算符 ||(逻辑或)和 &&(逻辑与)左边的操作数(作为条件判断表达式)。

||和&&,一般情况下是可以作为转换布尔值来使用,但是正确饿理解是&& 和 || 运算符的返回值并不一定是布尔类型,而是两个操作数其中一个的值。

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

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

||和&&会对第一个操作数进行判断,如果其不是布尔值就先进行ToBoolean 强制类型转换,然后再执行条件判断。这是区别于其他语言的,其他语言可能直接返回两者转换后的布尔值。

ES6的Symbol是不能转换为字符串或者数字的,会产生错误,但是可以转换成布尔值,而且结果都是true。

宽松相等和严格相等

=== 和 == 想必大家都常见,但是对于其中的解释是否理解过,这里给出的解释是:” == 允许在相等比较中进行强制类型转换,而 === 不允许。”所以这种解释就意味着 == 的工作量更大,因为值的类型不同的话需要进行强制类型转换。

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

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 的结果。 也就是都会转换成数字进行比较。

而其他类型和布尔类型的对比,尤为容易出错

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

规范 11.9.3.6-7 是这样说的:

  1. 如果 Type(x) 是布尔类型,则返回 ToNumber(x) == y 的结果;
  2. 如果 Type(y) 是布尔类型,则返 x == ToNumber(y) 的结果。

因此,布尔值和数字进行对比时,布尔值都会转换成1 或者0。

那就有一个疑惑了,42 == true是错的,42 == false也是错的,那是不是就代表42即不是真值也不是假值了么。实际并不是这样的,因为这里的比较中,没有发生布尔值的强制转换,都是布尔值转换成数字类型,不涉及ToBoolean,所以 "42" 是真值还是假值与 == 本身没有关系!

什么情况下都不要使用 == true 和 == false。

null 和 undefined 之间的 == 也涉及隐式强制类型转换。ES5 规范 11.9.3.2-3 规定:

  1. 如果 x 为 null,y 为 undefined,则结果为 true。
  2. 如果 x 为 undefined,y 为 null,则结果为 true。 也就是 null == undefined,它们也与其自身相等,除此外没有其他值能相等。

关于对象和非对象之间的相等比较,ES5 规范 11.9.3.8-9 做如下规定:

  1. 如果 Type(x) 是字符串或数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果
  2. 如果 Type(x) 是对象,Type(y) 是字符串或数字,则返回 ToPromitive(x) == y 的结果 ToPrimitive 就转成基本类型的说法。布尔值会事先转换成数字再进行转换。
le a = [42]
let b = 42
a == b //true a先转换成'42' 成为 '42' == 42 后再变成 42 == 42

var a = "abc";
var b = Object( a );
a == b //true b通过ToPrimitive进行强制类型换行的值为'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 不能被封装,所以返回一个常规对象{},而NaN虽然能被封装,但是NaN 不等于NaN。

来看看其他有趣的例子

如何让 if (a == 2 && a == 3) {}执行呢,或许你觉得不可能,但是 && 不代表同时,a==2实际上是在a==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." );
}

利用a.valueOf产生的副作用,然后Number()进行比较时会发生强制类型转换,因此调用valueOf产生值的变化。

假值的相等比较

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

"0" == false; // true 
false == 0; // true
false == "";// true
false == [];// true
"" == 0;// true
"" == [];// true
0 == [];// true

这里列出了7中特殊的假值比较,它们这是属于假阳的情况。== false 的情况好理解,因为false 会被转换成 0 再进行比较,然后 Number('')或者Number([])的结果都是0。

而 '' == [],遵循上述的对象转换规则来说实际是 '' == ''([].toString),结果也就相等了。

还有更奇怪的

[] == ![] //true

来看看具体发生了什么吧,根据ToBoolean规则,会先进行布尔值的强制类型转换,变成

[] == false

然后这个结果就和前面的特殊情况一样了,再进行数字转换,0 == 0。

"" == [null];   // true [null].toString() ==> " "

0 == "\n"; // true
"""\n"、或者其他空格组合字符串被ToNumber 强制类型转换为0,结果就是 0 == 0 

所以,得出的结论是

  1. 如果两边的值中有 true 或者 false,千万不要使用 ==。
  2. 如果两边的值中有 []、"" 或者 0,尽量不要使用 ==。 因为你确实不知道什么时候,这些转换会出问题。可以的话,尽量使用 === 吧

抽象关系比较

a < b 这种比较方式也需要认识一下。ES5 规范 11.8.5 节定义了“抽象关系比较” 分为两个部分:比较双方都是字符串和其他情况。
比较双方首先调用 ToPrimitive,如果结果出现非字符串,就根据 ToNumber 规则将双方强 制类型转换为数字来进行比较。

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

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

var a = [ "42" ];
var b = [ "043" ];
a < b; // false
==> '42' < '043'

0在字母的顺序上小于4,所以结果是false。

var a = [ 4, 2 ];
var b = [ 0, 4, 3 ];
a < b; // false  和上面一样 '4,2' > '0,4,3'

var a = { b: 42 };
var b = { b: 43 };
a < b //false  
==> [object Object] < [object Object],所以小于不成立。

再来看

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本质上是指向两个不同的存储空间的,所以是不相等的。而根据规范a <= b被处理为b < a,然后将结果反转。因为 b < a 是false,所以反转就是true,a<=b为true

所以 <=在JS中是实际上表示的是 “不大于”的意思,即 !(a>=b),处理为 !(b<=a)

这个比较时有点绕,需要多理解一下。比较关系没有严格关系的比较,如果要避免两者发生隐式类型转换,就必须保证两者类型是一致的。