5k字讲透强制类型转换

61 阅读18分钟

什么是类型转换

将值从一种类型转换到另一种类型称为类型转换,这是显式的情况;隐式的情况就称为强制类型转换。JavaScript中的强制类型转换总是返回标量基本类型值,比如字符串、数字和布尔值,不会返回对象和函数。

在介绍显式和隐式强制类型转换之前,我们需要掌握字符串、数字、布尔值之间的类型转换。这里需要运用到‘抽象操作’,这里的抽象操作可以理解成一个大类,抽象操作包括ToString、ToNumber、ToBoolean和ToPrimitive,这些操作里定义具体的实现方法。

ToString:非字符串到字符串的强制类型转换的规则

  1. 基本类型值的转化规则:基本类型值是通过toString()方法来转化成字符串的,null会转化为'null',undefined会转化为'underfined',1会转化为'1',但是极大数和极小数可能和预想的不一样
var a = 1.07*1000*1000*1000*1000*1000*1000*1000
a.toString() // "1.07e21"
  1. 数组的默认规则,数组也是调用toString()方法转化为字符串,但是数组的toString()方法经过了重新定义,将所有单元字符串化(如果已经是字符串了就直接用)以后再用","连接
var a = [1,2,3]
a.toString();  //"1,2,3"
  1. JSON字符串化

工具函数JSON.stringify()在将JSON对象序列化为字符串时也用到了ToString(注意这里是用了ToString抽象操作,并不是toString()方法)。JSON字符串和toString()方法基本相同,只不过序列化的结果总是字符串

JSON.stringify("42") //""42""
JSON.stringify(null) //"null"

所有安全的JSON值都可以用JSON.Stringify()。而不安全的JSON值包括undefined、function、symbol和包含循环引用(对象之间相互引用,形成一个无限循环)的对象 JSON.stringify()在对象中遇到underfined、function和symbol时会自动将其忽略,在数组中则会返回null

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

而对包含循环引用的对象执行JSON.stringify()会出错

如果要对非法JSON值进行序列化,那么可以定义toJSON方法。如果定义了ToJSON方法,JSON字符串化化之前会首先调用ToJSON()方法,然后用它的返回值进行序列化。

var o = {}

var a = {
  b:42,
  c:o,
  d:function(){}
}

o.e = a //循环引用,o中引用了aa中也引用了o
JSON.stringify(a) //循环引用在这里会产生错误

a.toJSON = function(){
  // 序列化仅包含b
  return {b:this.b} //"{"b":42}"
}

注意,toJSON()返回的应该是一个适当的值,可以是任何类型,然后再由JSON.stringify()对其进行字符串化。也就是说,toJSON()应该“返回一个能够被字符串化的安全的JSON值”,而不是“返回一个JSON字符串”

顺便讲讲JSON.stringify()的后两个参数

1.第一个可选参数是replacer。
replacer是一个数组,那必须是字符串数组,其中包含序列化要处理的对象的属性名称,除此之外其他的属性都会被忽略。
replacer是一个函数,它会对对象本身调用一次,然后对对象中的每一个中的每个属性都调用一次,每次传递两个参数,键和值。如果要忽略某个键就返回underfined,否则返回指定的值。当replacer是函数,那么k在第一次调用的时候就是underfined

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]}"
})
  1. JSON.stringify还有一个可选参数space,用来指定输出的缩进格式
JSON.stringify(a,null,3)

ToNumber:将非数字转化为数字来使用

  1. 通过Number()方法,将true转化为1,false转化为0,underfined转化为NaN,null转化为0,''转化为0,处理失败时返回NaN,ToNumber对以0开头的十六进制不按十六进制处理,而是按十进制处理

  2. 对象(包括数组)转化为Number时,会运用到抽象操作ToPrimitive。即首先检查该值是否有valueOf()方法,如果有且有返回基本类型值,就使用该值进行强制类型转化。如果没有就使用toString()的返回值来进行强制类型转换。如果valueOf和Stringify都不返回基本类型值,会产生TypeError错误

PS:由于ToPrimitive抽象操作会先调用valueOf再调用toString,并且ToPrimitive抽象操作是一个范围没有那么‘大’的规则,所以在这一部分插入一段用到ToPrimitive的操作
1.上面几行提到的,对象转换成Number,会用到ToPrimitive
2.new String2) == 1new Number2) === 3的时候会用到ToPrimitive操作,因为StringNumber的原型对象上都有valueOf这个方法

使用Object.create(null)来创建的对象的原型对象属性为null,并且这个原型对象没有valueOf()和toString()方法,因此无法进行强制类型转化

var a = {
    valueOf: function(){
        return '42'
    }
}

var b = {
    toString: function(){
        return '42'
    }
}

var c = [4,2]
c.toString = function(){
    return this.join('')
}

Number(a) //42,这里a是对象,自然是ToPrimitive
Number(b) //42
Number(c) //42
Number("") //0
Number([]) //0
Number({}) //NaN,注意和空数组不一样
Number(['abc']) //NaN

ToBoolean:

  1. 假值

可以被强制类型转化为false的值。包括underfined、null、false、+0、-0、NaN,"",......。除了这些以外的都是真值,可以被强制类型转化为true

2.真值

假值列表以外的都是真值,包括各种字符串(除了""),空数组、空对象、空函数

显示强制类型转化

1.字符串与数字之间的显式转换

  • 字符串和数字之间的转换可以通过String()和Number()两个原生构建函数来实现的,请注意他们前面没有new关键词,并不创建封装对象
  • 还可以通过toString()方法来转化为字符串,不过其中涉及隐式转换,因为toString()对基本类型数值是不适用的,因此会为基本数据类型创建一个封装对象(new Number()),然后该对象才能调用toString()方法
  • 可以通过一元运算符+来将字符串转化为数字
var a = 42
var b = String(a) //'42'
var e = a.toString() //'42'

var c  = '3.14'
var d = Number(c) //3.14
var f = +c //3.14

2.显式解析数字字符串

  • 解析parseInt()允许字符串中出现非数字字符,解析按从左到右的顺序,遇到非数字字符就停止,并返回指定基数的十进制整数。
    • 解析时针对字符串形式,向parseInt()传递数字和其它类型的参数是没有用的,比如NaN、true、function
    • 如果传递了非字符串形式的参数,会通过ToString抽象操作来进行隐式转换,应该避免传递的参数不是字符串
    • parseInt(string,x)的第二个参数表示string的进制
  • 转换Number()不允许出现非数字字符,否则会失败并返回NaN
var a = "42"
var b = "42px"

Number(a) //42
parseInt(a) //42

Number(b) //NaN
parseInt(b) //42

OK,但是parseInt()有一个很坑的地方

parseInt(1/0,19) //18

这里很显然并不是parseInt的问题。第一个参数会被解析成Infinity(无穷大)。第二个参数是19进制,但很明显这是一个玩笑,因为表示19进制并不会用19,它的有效范围应该是0-9,a-i(区分大小写)。

因此这里实际上是parseInt('Infinity',19),第一个字符时I,第二个字符时n并不是一个有效数字字符,解析到此为止。第二个参数是19,则在19进制中I表示18,自然会输出18.

parseInt(0.000008) //0,"0"来自于"0.000008"
parseInt(0.0000008) //8,"8"来自于"8e-7"
parseInt(false,16) //250,"fa"来自于"false"
parseInt(parseInt,16) //8,"f"来自于"function..."
parseInt("0x10") //16
parseInt("103",2) //3不存在与二进制中,所以3不解析

3.显示转化为布尔值

  • 和前面的String()、Number()一样,Boolean()是显式的ToBoolean强制类型转化,转化的规则就是前面提到的真假值列表
  • 和前面的+类似,一元运算符!可以显式地将值转换为布尔值,但同时他还会将真值反转为假值,因此可以使用!!a来显示转化为布尔值
  • 显示转化布尔值的用法之一
    • JSON序列化的过程中可以将不安全的值转化为boolean
var a = 42
var b = a ? true : false

相信这段代码大家都很熟悉,通过三元运算符来判断a进而给b赋值。这里涉及隐式强制类型转化,因为a要先被转化成布尔值,这种情况叫做“显示的隐式”,有百害而无一利,应该使用Boolean(a)或者!!a来进行强制类型转化

隐式强制类型转换

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

+运算符即能用作数字加法,也能用于字符串拼接,那么什么时候该用哪种呢?

var a = '42'
var b = '0'

var c = 42
var d = 0

a+b //'420'
c+d //42

对这段代码通常的理解是,因为某一个或者两个操作数都是字符串,所以+执行的是字符串拼接操作。这样的解释只对了一半,实际操作会复杂地多。例如:

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

根据ES5规范11.6.1节,如果某个操作数是字符串或者能够转化为字符串的话,+将进行拼接操作。如果其中一个操作数是对象(包括数组),则首先对其调用ToPrimitive抽象操作,看其是否能转化成字符串。

在上一段代码中,a和b都是数组,调用toPrimitive抽象操作,由于我们没有自动以valueOf()方法,因此默认的valueOf()方法返回数组本身而不是基本数据类型,此时调用toString()方法将两个字符串合并。

对隐式强制类型转化来说,我们可以将数字和空字符串""相+来将其转换为字符串

var a = 42
var b = a +''

b //"42"

a + ""(隐式)和前面的String(a)(显式)之间有一个细微的差别,根据ToPrimitive抽象规则,a+""会先对a调用valueOf()方法,再调用toString()方法。而String(a)会直接调用toString()方法。

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

a + "" //"42"
String(a) //4

呃呃呃,一般都不会遇到这个问题,除非代码中真的会有这些匪夷所思的数据结构和操作。我只是想提醒,定制valueOf()和toSting()方法时需要特别小心,因为这会影响强制类型转化结果

-是数字运算减法符,因此a - 0会将a强制转化为数字,a*1和a/1同理

var a = [3]
var b = [1]
a - b //2

为了执行减法运算,a和b都会被转化为数字,他们会先被转化为字符串(通过toString()), 然后在转化成数字

2. ||和&&

逻辑运算符||(或)和&&(与)应该不陌生。但是将其称为逻辑运算符是不太正确的,称他们为“选择器运算符”或者“操作数选择器运算符”更恰当些。因为在JavaScript中,他们返回的并不是布尔值,他们的返回值是两个操作数中的一个(且仅一个)

||和&&首先会对第一个操作数进行条件判断,如果其不是布尔值,就先进行ToBoolean抽象操作转化成布尔值,然后再进行条件判断。

对于||来说,如果判断条件是true就返回第一个操作数的值,如果是false就返回第二个操作数的值 对于&&来说,如果判断条件是true就返回第二个操作数的值,如果是false就返回第一个操作数的值

var a = 42
function foo(){}
a && foo()

这段代码常用于JavaScript代码压缩工具,如果第一个操作数为真值,则&&选择第二个操作数为返回值,这叫做‘守护运算符’.而如果第一个操作数为假值,那么foo()就不会执行,这叫做‘短路’

b || foo() //如果b是真值,那么foo()也不会执行,这也是‘短路’

而我们使用更多的if(a&&b){}一直没有问题,并不是a&&b返回值有问题,而是if将a&&b的结果强制类型转化为布尔值

4.符号(Symbol)的强制类型转换

ES6允许从符号到字符串的显式强制类型转换,然而隐式强制类型转换会产生错误

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

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

符号不能被强制类型转换为数字(显式或者隐式都会产生错误),但可以被强制类型转换为布尔值(显式和隐式的结果都是true)。鉴于符号的特殊用途,我们不会经常用到它来进行强制类型转化。

宽松相等和严格相等

1. 宽松相等==和严格相等===的误区

  • 这两个符号都是用来判断两个值是否相等,但是他们经常会被误会为“==检查值是否相等,===检查值和类型是否相等”。很多JavaScript的书籍和博客也是这样解释的,但是很遗憾他们都错了,正确的解释是:“==允许在相等比较中进行强制类型转换,而===不允许”

2.相等比较操作的性能

  • 按照第一个解释(不准确版本),===似乎比==做的事情要多,因为它还有检查值的类型。而第二种解释中==的工作量大一些,因为值的类型不同时还要进行强制转换 -有人觉得==会比===慢,实际上强制类型转换确实需要多花点时间,但仅仅是微妙级的差别而已,除了JavaScript引擎实现上的细微差别外,他们之间没有什么不同
  • 如果两个值的类型不同,我们就需要考虑有没有强制类型转换的必要,有就用==,没有就用===,不用在乎性能问题

3.抽像相等

  • ES5规范中的“抽像相等比较算法”定义了==运算符的行为,该算法简单而又全面地涵盖了所有可能出现的类型组合,以及他们进行强制类型转换的方式
  • 其中第一段就规定如果两个值的类型相同,就仅仅比较他们的值相不相等,例如42等于42,‘abc’等于‘abc’。但是有几个常规的值需要注意
NaN不等于NaN
+0等于-0
  • 规范最后也定义了对象(包括函数和数组)的宽松相等==,当两个对象指向同一个值时即视为相等,不发生强制类型转换
1.字符串和数字之间的相等比较
var a = 42
var b = '42'

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

因为没有强制类型转化,因此a === b 为false。而a == b设计强制类型转换,所以为true。具体怎么转换?是a从42变成字符串,还是b从‘42’转化为数字?

ES5规范这样定义:

x == y时
(1)如果type(x)是数字,Type(y)是字符串,则返回x == ToNumber(y)的结果
(2)如果Type(x)是字符串,Type(y)是数字,则返回ToNumber(x) == y的结果
总结:通过ToNumber抽象操作转换成数字进行比较
2.其他类型和布尔类型之间的相等比较
var a = "42"
var b = true

a == b //false

我们都知道“42”是真值,那么为什么结果不是true呢?规范是这样说的

(1)如果Type(x)是布尔类型,那么返回ToNumber(x) == y的结果
(2)如果Type(y)是布尔类型,那么返回x == ToNumber(y)的结果
总结:遇到布尔类型的抽象相等,会通过ToNumber抽象操作将布尔值转换为数字

因此,“42”是真值没错,但是这里并没有将“42”转换为true,而是true转换为1,变成“42” == 1,再通过强制类型转换,变成42 = 1,自然返回false

amazing,个人建议无论任何时候都不要使用==true和==false,但===true和===false不允许强制类型转换,所以不涉及ToNumber抽象操作

//不要这样用,条件判断不成立
if(a == true){}

//也不要这样用,条件判断不成立
if(a === true){}

//这样的显式用法没有问题
if(a){}

//这样的显式用法更好
if(!!a){}

//这样的显式用法也很好
if(Boolean(a)){}
3.null和underfined之间的相等比较

ES5规范规定

(1)如果x是null,y为underfined,则结果为true
(2)如果x是underfined,y是null,则结果为true
总结:在==中null和underfined相等(他们也与其自身相等),除此之外与其他值都不相等,包括假值

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
4. 对象和非对象之间的相等比较

关于对象(对象、函数、数组)和标量基本类型(字符串、数字、布尔值)之间的相等规范,ES5做了如下规范

1)如果Type(x)是字符串或者数字,Type(y)是对象,则返回x = ToPrimitive(y)的结果
(2)如果Type(x)是对象,Type(y)是字符串或数字,则返回ToPrimitive(x) = y的结果
总结:将对象通过ToPrimitive抽象操作(如valueOf()、toString())拆封出来再进行比较

注意:这里只提到了字符串和数字,并没有布尔值,原因是因为前文提到的如果遇到布尔值,是布尔值被转换成数字

当然,有一些值也是例外的

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

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

-----------

var a = null
var b = Object(a)
a == b //false

var c = underfined
var d = Object(c)
c == d //false

var e = NaN
var f = Object(e)
e == f //false

这里的原因是因为,underfined和null并没有能够被封装的对象,Object(null)和Object(underfined)都会返回一个空对象{}。
NaN可以被封装为数字封装对象,但封装后NaN == NaN返回false,因为NaN不等于NaN

5. 比较少见的情况
Number.prototype.valueOf = function(){
  return 3
}

new Number(2) == 3

这是因为这里用了ToPrimitive抽象操作。而2==3则不会有这种问题,因为2和3都是数字基本类型,没有原型链,自然也不会使用到Number.prototype.valueOf()方法

还有一个更怪的情况

if(a == 2 && a ==3){}

你也许觉得这并不可能,因为2不可能同时等于2并且等于3,但是你要注意到,a是先等于2再等于3,而a==2和a==3的时候都会发送隐式类型转换,这实现起来似乎就简单很多了

let a = 1
String.prototype.valueOf = function(){
  return ++a
}
let b = new String(2)

if(b == 2 && b == 3){
  console.log(11);
}

来点例题

"0" == false //true,通过以上规则,布尔值转换为数字,转化为"0"==0,字符串再转换成数字,0 == 0
false == 0 //true
false == [] //true,转换成0==[],[]通过ToPrimitive抽象操作先调用valueOf()返回自身,再通过toString()放回"",此时是0=="",通过ToNumber抽象操作可知,Number("") == 0
false == {} //false,{}通过ToPrimitive抽象操作先调用valueOf()返回自身,再通过toString()方法返回的值为[object Object],**这是字符串类型**,这是因为对象没有重写toString(),用的是Object.prototype.toString()这个方法
"" == 0 //true,Number("") 返回值为0
"" == [] //true,同理,调用了ToPrimitive抽象操作
"" == {} //false,同理,调用了ToPrimitive抽象操作
0 == [] //true,同理,调用了ToPrimitive抽象操作
0 == {} //false,同理,调用了ToPrimitive抽象操作
[] == ![] //true,![]被显示转换成false,则同上

抽象关系比较

ES5规范定义了‘抽象关系比较’,分为两个部分,比较双方都是字符串和其它情况

比较双方首先调用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'这两个字符串,他们分别以'0'和'4'开头,在字母顺序表上'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

结果还是false,通过ToPrimitive抽象操作可知,a和b都会返回[object Object]字符串值,因此a < b不成立,那么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呢?其实这和数学不同,==和>在JavaScript中是两套不同体系,对象的相等并没有发生隐式转换,而是直接比较地址值,a和b的地址值不同,那么不会返回true

为什么a<b和a>b都返回false,为什么a>=b和a<=b会返回true呢。这可能和我们设想的大相径庭。

a<=b其实并不是‘小于或者等于’,而是‘不大于’,即a<=b会被处理成!(a>b),a>b为false,取反不就是true了嘛

完结撒花❀❀❀