本章旨在全面介绍强制类型转换的优缺点。
1.值类型转换
将值从一种类型转换为另一种类型通常称为类型转换,这是显式的情况;隐式的情况称为强制类型转换。
JavaScript中的强制类型转换总是返回标量基本类型值,不会返回对象和函数。
可以这样分别称呼:显式强制类型转换、隐式强制类型转换。(这里的显/隐区分不是官方定的,而是根据一般的开发人员的感受而做的区分)
2.抽象值操作
本节介绍各种基本类型之间转换的规则,主要介绍toString、toNumber、toBoolean,并捎带讲下toPrimitive。
2.1 toString
null -> 'null'
undefined -> 'undefined'
true -> 'true'
数字的转换遵循通用规则,其中极大和极小的数字使用指数形式。
对普通对象,则调用对象的toString(Object.prototype.toString()),返回内部属性[[Class]]的值,这个方法也可以自己定义。
数组的toString就是Array类里重新定义过的,跟Object.prototype.toString()不一样。
下面讲下JSON.stringify
对大多数基本类型值来说,JSON.stringify和toString的效果基本相同。
安全的JSON值指的是能够呈现为有效JSON格式的值。
那么什么是不安全的JSON值? undefined、function、symbol和包含循环引用的对象 都不是安全的JSON值。
JSON.stringify()在对象中遇到undefined、function和symbol时会自动将其忽略,在数组中则会返回null(以保证单元位置不变)。而对包含循环引用的对象执行JSON.stringify()会出错。
如果要对含有非法JSON值的对象做字符串化,就需要定义toJSON()方法来返回一个安全的JSON值。这个toJSON方法应该返回一个“能够被字符串化的安全的JSON值”。
JSON.stringify的使用小妙招:
JSON.stringify(..)接收一个可选参数replacer,它可以是数组或者函数,用来指定对象序列化过程中哪些属性应该被处理,哪些应该被排除。如果replacer是一个数组,那么它必须是一个字符串数组,其中包含序列化要处理的对象的属性名称,除此之外其他的属性则被忽略;如果replacer是一个函数,它会对对象本身调用一次,然后对对象中的每个属性各调用一次,每次传递两个参数,键和值。如果要忽略某个键就返回undefined,否则返回指定的值。
JSON.stringify(..)的字符串化过程是递归的,递归的过程中会多次调用replacer函数(如果有的话),可以自己试着打印一下看看递归的顺序。
JSON.stringify还有一个可选参数space,用来指定输出的缩进格式。space为正整数时是指定每一级缩进的字符数,它还可以是字符串,此时最前面的十个字符被用于每一级的缩进。(后半句话没懂是什么意思。)
mdn对space是这么解释的:
A String or Number object that's used to insert white space into the output JSON string for readability purposes.
If this is a Number, it indicates the number of space characters to use as white space; this number is capped at 10 (if it is greater, the value is just 10). Values less than 1 indicate that no space should be used.
If this is a String, the string (or the first 10 characters of the string, if it's longer than that) is used as white space. If this parameter is not provided (or is null), no white space is used.
我觉得比书上的中文说得清楚。
2.2 toNumber
true -> 1
false -> 0
undefined -> NaN
null -> 0
ToNumber对字符串的处理基本遵循数字常量的相关规则。处理失败时返回NaN。不同之处是ToNumber对以0开头的十六进制数并不按十六进制处理(而是按十进制,参见第2章)。
(↑这句没看懂,Number('0x011')也成功转成了17,为什么作者要说“并不按十六进制处理呢?)
对象(包括数组)会首先被转换为相应的基本类型值,如果返回的是非数字的基本类型值,则再遵循以上规则将其强制转换为数字。
对象转换为基本类型值的过程:首先检查该值是否有valueOf()方法,如果有并且返回基本类型值,就使用该值进行强制类型转换;如果没有就使用toString()的返回值(如果存在)来进行强制类型转换。
2.3 toBoolean
JavaScript规范具体定义了一小撮可以被强制类型转换为false的值:undefined、null、false、+0、-0、NaN、空字符串。
我们可以理解为假值列表以外的值都是真值。
(ie浏览器有个神妙的假值对象document.all,它为什么会是个假值的原因,反正是历史原因。)
3.显式强制类型转换
显式强制类型转换是那些显而易见的类型转换。我们在编码时应尽可能地将类型转换表达清楚,以免给别人留坑。类型转换越清晰,代码可读性越高,更容易理解。
3.1 字符串和数字之间的显式转换
tips: JavaScript有一处奇特的语法,即构造函数没有参数时可以不用带()
涉及字位运算符的强制转换
前面提过,字位运算符只适用于32位整数,所以运算符会强制操作数使用32位格式。这是通过抽象操作ToInt32来实现的,比如"123"会先被ToNumber转换为123,然后再执行ToInt32。
虽然严格说来并非强制类型转换(因为返回值类型并没有发生变化),但字位运算符(如|和~)和某些特殊数字一起使用时会产生类似强制类型转换的效果,返回另外一个数字。
例:
0 | -0 // 0
0 | NaN // 0
0 | Infinity // 0
0 | -Infinity // 0
以上这些特殊数字无法以32位格式呈现(因为它们来自64位IEEE 754标准,参见第2章),因此ToInt32返回0。
然后作者说到~的用法
很多语言中会有“哨位值”,用来表示特殊的含义,比如JavaScript中的indexOf()用-1表示没有搜索到指定子串。对于这样的方法如果我们直接用>=0或者===-1这样的判断,就是把方法的实现细节暴露出来了,而用~计算indexOf()的结果,刚好~-1
为0,是假值,其它结果是真值。
作者认为if (~a.indexOf(..)
这样的判断比>=0或者===-1更简洁。
然后作者开始讨论~~
~~
中的第一个~
执行ToInt32并反转字位,然后第二个~
再进行一次字位反转,即将所有字位反转回原值,最后得到的仍然是ToInt32的结果(只适用于32位数字)。
~~x能将值截除为一个32位整数,但它对负数的处理与Math. floor(..)不同。
Math.floor(-49.6) // -50
~~-49.6 // 49
3.2 显式解析数字字符串
强制转换方法Number() 和 解析方法parseInt()、parseFloat()的区别:解析允许字符串中含有非数字字符,解析按从左到右的顺序,如果遇到非数字字符就停止。而转换不允许出现非数字字符,否则会失败并返回NaN。
parseInt(..)针对的是字符串值,非字符串参数会首先被强制类型转换为字符串。依赖这样的隐式强制类型转换并非上策,应该避免向parseInt(..)传递非字符串参数。
parseInt(..)第二个参数可以指定转换的基数。如果没有传第二个参数,ES5前parseInt(..)会根据字符串的第一个字符来自行决定基数,从ES5开始parseInt(..)则默认转换为十进制数(除非0x开头)。
如果使用不当,parseInt会出现一些难以理解的结果,但其实并没毛病。
比如:parseInt(1/0, 19)
,实际上是parseInt("Infinity", 19)
。第一个字符是"I",以19为基数时值为18。所以最后的结果是18,而非Infinity或者报错。
还有一些例子:
parseInt(0.000008) // 0 (0来自于"0.000008")
parseInt(0.0000008) // 8 (8来自于"8e-7")
parseInt(false, 16) // 250 ("fa"来自于"false")
parseInt(parseInt, 16) // 15 ("f"来自于"function..")
parseInt("0x10") // 16
parseInt("103", 2) // 2
3.3 显式转换为布尔值
Boolean(..)是显式的ToBoolean强制类型转换,还有种写法是!!
4.隐式强制类型转换
4.1 隐式的简化
隐式强制类型转换 相当于 省略了一些转换的代码,让转换的环节看起来变少了,一些中间环节被隐藏掉了,代码看起来更简洁。
4.2 字符串和数字之间的隐式强制类型转换
前面讲过+运算符可以把字符串转成数字,然后可以进行数字加法,+运算符也可以用于字符串拼接。那么JavaScript怎么决定最后执行什么操作?
举几个例子:
'42' + '0' // 420
42 + 0 // 42
[1, 2] + [3, 4] // 1,23,4
ES5规定,如果某个操作数是字符串的话,+将进行拼接操作,否则执行数字加法。
如果操作数是对象,它会先被调用valueOf,获取值,如果valueOf得不到基本类型值,就会调用它的toString。
再说个神奇的例子:
[] + {} // [object Object]
{} + [] // 0
后面再分析
然后讨论从字符串强制转换为数字的情况
比如说用-运算符:
[3] - [2] // 1
[3, 4] - [2] // NaN
除了-运算符,*和/也会将操作数强制转换为数字。
我自己在浏览器控制台试了下
{} - 1 // -1
[] - 1 // -1
唉,也不知道作何解释。
4.3 布尔值到数字的隐式强制类型转换
可以对布尔值用数学运算符,它会被隐式转换成数字0或1。有的时候这样是有用的。
4.4 隐式强制类型转换为布尔值
会发生布尔值隐式强制类型转换的情况:
(1) if (..)语句中的条件判断表达式
(2) for ( .. ; .. ; .. )语句中的条件判断表达式
(3) while (..)和do..while(..)循环中的条件判断表达式。
(4) ? :三目运算中的条件判断表达式
(5) 逻辑运算符||(逻辑或)和&&(逻辑与)左边的操作数(作为条件判断表达式)
4.5 ||和&&
在JavaScript中||和&&返回的并不是布尔值,而是两个操作数中的一个(且仅一个)。
||和&&首先会对第一个操作数(a和c)执行条件判断,第一个操作数如果不是布尔值,会被强制类型转换成布尔值。
对于||来说,如果条件判断结果为true就返回第一个操作数的值,如果为false就返回第二个操作数的值。&&则相反。
a || b 大致相当于 a ? a : b
a && b 大致相当于 a ? b : a
之所以说大致相当,是因为如果a是一个复杂一些的表达式,用?:的写法它可能被执行两次,而在a || b和a && b中a只执行一次。
||常用来设默认值。
&&常被代码压缩工具用来压缩代码,如if (a) { foo(); }会被压缩成a && foo()。
4.6 符号的强制类型转换
symbol不能被强制类型转换为数字(显式隐式都不行)
symbol可以被强制类型转换为布尔值(显式和隐式结果都是true)
symbol可以被显式强制类型转换为字符串,但不能隐式转换成字符串。
例:
var s1 = Symbol('cool')
String(s1) // 'Symbol(cool)'
s1 + '' // 会报错 Uncaught TypeError: Cannot convert a Symbol value to a string
5.宽松相等==和严格相等===
==允许在相等比较中进行强制类型转换,而===不允许。
5.1 两者的性能
如果比较的两个值类型不同,==会先进行强制类型转换。如果两个值类型相同,则==和===使用相同的算法。
性能上两者几乎没有差别,使用时,只需要考虑有没有强制类型转换的必要,有就用==,没有就用===,不用在乎性能。
5.2 抽象相等
es5规范规定了“抽象相等比较算法”定义了==运算符的行为。
它规定:
(1)如果两个值的类型相同,就仅比较它们是否相等。(注意特例:NaN不等于NaN;+0等于-0)
(2)两个对象之间比较,如果两个对象指向同一个值时即视为相等。
(3)两个值类型不相同,会发生隐式强制类型转换,强制类型转换可能会发生在其中一个值,也可能两个都被转换,之后再被进行比较。
下面进行更具体的分情况讨论:
①字符串和数字之间的相等比较
前面说过,==的两个比较值的类型不一致时,会先进行强制类型转换。那么转换哪个呢?
es5规范规定:如果Type(x)是数字,Type(y)是字符串,则返回x == ToNumber(y)的结果;如果Type(x)是字符串,Type(y)是数字,则返回ToNumber(x) == y的结果。
②其他类型和布尔类型之间的相等比较
es5规范规定:如果Type(x)是布尔类型,则返回ToNumber(x) == y的结果;如果Type(y)是布尔类型,则返回x == ToNumber(y)的结果。
所以true、false这样的比较值会被转成1和0再进行比较。
③null和undefined之间的相等比较
es5规范规定:如果x为null, y为undefined,则结果为true;如果x为undefined, y为null,则结果为true。
在==中null和undefined相等(它们也与其自身相等),除此之外其他值都不存在这种情况。
比方说null == ''、null == false、null == 0的判断结果都是false
根据上述规则,在开发中如果要判断一个值是否为null或undefined,就没必要写成a === null || a === undefined,直接写成a == null就行,还更简洁。
④对象和非对象之间的相等比较
es5规范规定:如果Type(x)是字符串或数字,Type(y)是对象,则返回x == ToPrimitive(y)的结果;如果Type(x)是对象,Type(y)是字符串或数字,则返回ToPrimitive(x) == y的结果。
举例:42 == [42],对象[42]会先被调用toPrimitive,得到"42",然后"42" == 42又被强制类型转换了一次变成42 == 42,判断结果为true。
Number对象、String对象(平时我们不怎么显式地去用)存在“拆封”,这个拆封的过程也会调用对象的toPrimitive。
举例:"abc" === new String("abc") // false
"abc" == new String("abc") // true
5.3 奇葩情况集锦
①给对象定义了奇葩的valueOf方法,然后就能看到各种壮观的奇葩事情。
②各种假值的相等比较
由于强制类型转换的原因,所以假值之间比较可能会有一些看起来难以理解的结果,比如[] == ''为真、[] == false为真,这都是因为强制类型转换,想想Number('')为0、[].toString()为''就知道了。
③[] == ![]为真
原因是这样的,![]首先把[]转成布尔类型值,得true,然后前面那个!把它转成false,然后[] == false为真,因为它们都被转成数字了。
对上述奇葩情况做总结,首先不要拿布尔值做==判断,布尔值会被转成数字的,然后就会看起来很灵异,咱犯不上那样,布尔值就不要拿来==了。其次,两边的值有[]、''或者0的,尽量不要用==,这样就能避开几乎所有奇奇怪怪的强制类型转换行为了。
作者小tips:对于typeof,使用==是安全的,因为typeof总是返回七个字符串之一,其中没有空字符串。所以在类型检查过程中不会发生隐式强制类型转换,typeof x == "function"是安全的。所以代码中按需选择==和===即可,没必要处处用===。
6.抽象关系比较
这一小节讨论a < b中涉及的隐式强制类型转换。
ES5规范定义了“抽象关系比较”(abstract relational comparison),分为两个部分:比较双方都是字符串和其他情况。
①比较双方首先调用ToPrimitive,如果结果出现非字符串,就根据ToNumber规则将双方强制类型转换为数字来进行比较。
②如果比较双方都是字符串,则按字母顺序来进行比较。
奇怪的例子:
{a: 1} <= {a: 2} // true
{a: 1} >= {a: 2} // true
这是为什么呢?因为根据规范,a <= b被处理为b < a,然后将结果反转。而{a: 1}和{a: 2}的toPrimitive都是"[object Object]",a < b为false,a > b也为false。
我们可能以为<=应该是“小于或者等于”,但其实在JavaScript中<=是“不大于”的意思(即!(a > b),处理为!(b < a))。同理,a >= b处理为b <= a。
如果要避免a < b中发生隐式强制类型转换,我们只能确保a和b为相同的类型,除此之外别无他法。