你真理解JavaScript数据类型了?
前言
看文章前,首先问自己两个问题。
- 为什么有时候数字会失真?比如
0.1 + 0.2 !== 0.3
- 能够说清楚
JavaScript
的强制类型转换吗?
如果无法回答这两个问题,那大家可以仔细往下看,本文将仔细讲解数据类型中比较疑难的这两个问题
精度丢失
Number类型,大家最关心的一个问题之一就是精度丢失,因为在开发中经常会遇到计算不精确的问题,比如 0.1 + 0.2 !== 0.3
。这是什么原因?我们来研究一下。
原因
查阅资料知道,在JavaScript
中 Number
类型使用 IEEE754
标准来表示整数和浮点数值。所谓 IEEE754
标准,全称 IEEE
二进制浮点数算术标准,这个标准定义了表示浮点数的格式等内容。
在 IEEE754
中,规定了四种表示浮点数值的方式:单精确度(32位
)、双精确度(64位
)、延伸单精确度、与延伸双精确度。像 ECMAScript 采用的就是双精确度,即会用 64
位来储存一个浮点数。
所以说,0.1
、0.2
会以64位二进制来储存,需要将0.1
、0.2
先转成二进制:
0.1
转二进制为 0.000110011001100....0.2
转二进制为 0.0011001100110011....
可以看出0.1
和0.2
转成二进制都是无限循环的。
那在JavaScript
如何存储无限循环的值?这又要提到IEEE754
了,这个标准认为一个浮点数可以表示为:
Value = sign * exponent * fraction。
简单来讲就是科学计数法,也就是说0.1
的二进制0.000110011001100....
会以科学计数法的方式存储。表示为
1 * 2^-4 * 1.1001100110011……
该标准只做二进制科学计数法的表示时,这个 Value
的会被更具体的变成公式:
V = (-1)^S * (1 + Fraction) * 2^E
来理解一下这个公式:
-
(-1)^S
表示符号位,当 S = 0,V 为正数;当 S = 1,V 为负数。 -
(1 + Fraction)
因为所有的浮点数都可以表示为 1.xxxx * 2^xxx 的形式,而且 1.XXX 前面部分一定是1,所以只存后面的 xxxxx 。 -
2^E
以0.1
的二进制的科学计数法为例,E
的值就是-4
,因为E
既可能是负数,又可能是正数,所以储存这个E
需要 +bias
,储存E + bias
。
为什么E存储需要+bias ?
因为要存正负数。假如用 8 位来存储 E 这个数,只有正数的话,储存的值的范围是 0 ~ 254,
而如果要储存正负数的话,值的范围就是 -127~127,
在存储的时候,把要存储的数字加上 127,当存 -127 的时候表示存 0,当存 127 的时候表示存 254。
这样就解决了存负数的问题。
对应的,当取值的时候,我们再减去 127。
所以呢,真到实际存储的时候,我们并不会直接存储 E,而是会存储 E + bias,
当用 8 位的时候,这个 bias 就是 127
综上所述,如果要存储一个浮点数,只要存 S
和 Fraction
和 E + bias
,那具体要分配多少位来存储这些数呢?IEEE754 给出了浮点数值双精度的标准:
-
sign(正负): 会用 1 位存储 S,0 表示正数,1 表示负数。
-
exponent(E + bias): 用 11 位存储 E + bias,对于 11 位来说,bias 的值是 2^(11-1) - 1,也就是 1023。
-
Fraction: 用 52 位存储 Fraction。
按这样标准,以0.1的二进制为例可以表示成:
1 * 1.1001100110011…… * 2^-4
Sign
是 0
,E + bias
是 -4 + 1023 = 1019
,1019
用二进制表示是 1111111011
,Fraction
是 1001100110011……
按照这个标准,0.1的64位的完整表示就是
0 01111111011 1001100110011001100110011001100110011001100110011010
同样的,0.2表示为:
0 01111111100 1001100110011001100110011001100110011001100110011010
所以可以看到在这一步精度就开始丢失了,0.1
和0.2
的尾部都被截取掉了,只存到了64
为止。
接下来将这两个数相加。运算步骤分为对阶、尾数运算、规格化、舍入处理、溢出判断。
- 对阶:阶码调整为相同
0.1: 1.1001100110011... * 2^-4
,阶码是 -4
0.2: 1.10011001100110...* 2^-3
,阶码是 -3
两个阶码不同,需要调成一样的,调整原则是小阶对大阶,也就是0.1
的 -4
调成 -3
,对应变成 0.11001100110011... * 2^-3
- 尾数计算
0.1100110011001100110011001100110011001100110011001101
1.1001100110011001100110011001100110011001100110011010
————————————————————————————————————————————————————————
10.0110011001100110011001100110011001100110011001100111
得到的结果为
10.0110011001100110011001100110011001100110011001100111 * 2^-3
- 规格化
需要把得到的结果规划化,也就是将上面的结果转成
1.0011001100110011001100110011001100110011001100110011(1) * 2^-2
括号里的 1 意思是超出位数了,所以需要做舍入处理了
- 舍入处理
在二进制中,舍入的规则是0舍1入。因为括号中是1,所以要进一位,也就是变成
1.0011001100110011001100110011001100110011001100110100 * 2^-2
- 溢出判断
因为这里没溢出就不做处理
所以0.1 + 0,2
最终的结果会被存成
0 01111111101 0011001100110011001100110011001100110011001100110100
将它转换为十进制数会变成 0.30000000000000004440892098500626
所以两次(0.1
和 0.2
)存储时的精度丢失加上一次运算时的精度丢失,最终导致了 0.1 + 0.2 !== 0.3
。
我们来总结一下:因为JavaScript
的Number
类型使用的是IEEE754
标准,所以在储存时可能会导致精度丢失,并且在运算时可能会进行舍入处理导致第二次精度丢失,最终可能得不到我们预料的结果。所以在开发过程中进行数字运算包括(+、-、*、/)时要十分注意精度丢失的问题导致BUG
。
解决方案
建议采用一些成熟的库来处理js的精度丢失问题。
为什么这样建议?
因为在实践中证明团队不统一用一种正确地方式处理精度丢失问题,而是各自处理会经常出问题。
比如:decimal.js、big-number、big.js,关于这三者的区别,可以参考这个链接
类型转换
强制类型转换在JavaScript
是最令人头疼的问题之一,它经常被人认为是JavaScript
设计上有问题,应该避而远之。但是当你真正理解它之后,就会改变看法。接下来我们来深入理解一下JavaScript
类型转换。
将值从一种类型显示的转成另一种类型称为类型转换,这是显示的情况;一些内置的或者隐蔽的情况被称为强制类型转换。
通常我们会称类型转换为显示类型转换,而强制类型转换我们称之为隐式类型转换
显示类型转换
下面来看一下类型之间的显示转换实例,这些情况是根据JavaScript
内置的抽象操作来进行的。
ToString
基本类型的值转成字符串:
String(null) // null
String(undefined) // undefined
String(false) // false
String(true) // true
String(0) // 0
String(-0) // 0
String(NaN) // NaN
String(Infinity) // Infinity
String(-Infinity) // -Infinity
String(1) // 1
对普通对象来说,除非自行定义,否则toString()
(Object.prototype.toString()
)返回内部属性[[Class]]
的值,如"[object Object]
"。
如果对象有自己的toString()方法,字符串化时就会调用该方法并使用其返回值。
ToNumber
基本类型的值转成数字类型:
Number(null) // +0
Number(undefined) // NaN
Number(false) // +0
Number(true) // 1
Number("1") // 1
Number("-1") // -1
Number("0001") // 1
Number("") // 0
Number(" ") // 0
Number("1 1") // NaN
Number("南墨") // NaN
ToBoolean
基本类型的值转成布尔值类型:
如下例:
Boolean(false) // false
Boolean(undefined) // false
Boolean(null) // false
Boolean(+0) // false
Boolean(-0) // false
Boolean(NaN) // false
Boolean("") // false
除了这些,其他都会被转为true
虽然Boolean(..)
是显式的,但并不常用,显式强制类型转换为布尔值最常用的方法是!!
const a = [];
const b = {};
const c = false;
const d = undefined;
const e = null;
const f = 0;
const g = NaN;
consg h = "";
!!a // true
!!b // true
!!c // false
!!d // false
!!e // false
!!f // false
!!g // false
!!g // false
建议使用Boolean(..)
和!!
来进行显式转换以便让代码更清晰易读
ToPrimitive
对象转数字
对象转成Number
类型,需要进行抽象操作ToPrimitive,步骤如下:
-
ValueOf: 看看有没
valueOf
方法,如果有并且返回的是非数字的基本类型值,那就把它转换为Number类型再返回,否则就直接使用该值进行类型转换。 -
toString:如果第一步无法转换,就会看
toString()
是否存在值,如果存在值把它转换为Number类型然后用该值进行类型转换。 -
TypeError: 如果不能返回基本类型的值,那么就会报
TypeError
来看一些例子
console.log(Number({})) // NaN
console.log(Number([])) // 0
console.log(Number({name : '南墨'})) // NaN
console.log(Number([0])) // 0
console.log(Number([1,1])) // NaN
console.log(Number(new Date(2023, 0, 1))) // 1672502400000
Number({})
,为什么会输出0
。
根据ToPrimitive
方法,先查看valueOf返回{}
,不是基本类型,转而查toStrong()返回'[object Object]'
,是基本类型,将'[object Object]'
ToNumber转成 NaN
,所以得到了NaN
- Number([]),为什么会输出
0
。
根据ToPrimitive
方法,先查看valueOf返回空数组,不是基本类型,转而查toStrong()返回''
,再将''
ToNumber,返回了 0
,所以得到了0
。
其他同理
对象转字符串
对象转成String
类型,需要进行抽象操作ToPrimitive,步骤如下:
-
toString:
-
看看有没
toString
方法,如果有并且返回的是非字符串的基本类型值,那就把它转换为String类型再返回,否则就直接使用该值进行类型转换。 -
ValueOf: 如果第一步无法转换,就会看
ValueOf()
是否存在值,如果存在值把它转换为String
类型,然后用该值进行类型转换。 -
TypeError: 如果不能返回基本类型的值,那么就会报
TypeError
基本与转成Number
类似,不再赘述。
隐式类型转换
前面提到过比较隐蔽的类型转换是隐式类型转换。大家可能会觉得隐式类型转换晦涩难懂,就选择退而求其次,只使用显示类型转换。我们理解完隐式类型转换后,就会明白他们不仅相辅相成,而且有助于提升代码可读性。
接下来,讲解一下所有隐式转换具体的例子。
一元操作符 +
根据ES规范1.4.6,当 +
单独放在一个类型的前面的时候,是一元操作符,相当于调用了ToNumber
。
来看几个例子
consle.log(+[]) // 0
console.log(+{}) // NaN
因为+一元操作符相等于调用了ToNumber
。所以例子相当于变成了
consle.log(Number([])) // 0
console.log(Number({})) // NaN
在ToPrimitive
那一节清楚的解释该例子的转换过程。
二元操作符 +
关于二元操作符,我们直接来看两个例子。
- 例子1:
var a = "123"
var b = "0"
var c = 123
var d = 0
a + b // "1230"
c + d // 123
字符串相加得到"1230"
,数字相加得到预想的结果。
正常来说我们会认为:操作值如果是字符就拼接,如果是数字那就进行运算。
其实没这么简单,比如说
var a = [1,1]
var b = [2,2]
a + b // 1,12,2
这就解释不通 +
的规律。那这到底是为什么?
根据ES5规范11.6.1节,以下两种情况操作值会进行拼接
- 操作值是字符串就拼接
- 操作值能够通过
ToPrimitive
抽象操作转成字符串(ToPrimitive
那一节讲过)就拼接。
上述例子中的[1,1]、[2,2]
是数组并且进行+
运算,说明操作值要转成数字,所以说会先看数组[1,1]、[2,2]
有没有ValueOf
,因为数组的ValueOf(
)得不到简单的基本类型。于是转而调用toString()
,发现可以返回值,因此上述的两个值变成了'1,1'
与'2,2'
,所以将它们拼接起来返回了1,12,2
。
- 例子2:
来看个奇怪的例子
[] + {} // [object Object]
{} + [] // 0
是不是有点摸不着头脑。
-
我们来捋捋,先来看看
[] + {}
:- []: 操作符是
+
说明是想进行运算,根据要ToToPrimitive
转成数字,先看valueOf(), 返回[]不是基本类型转而看toString()
返回''
。 - {}: 同理,先看
valueOf()
返回{}
,不是基本类型转而看toString()
返回[object object]
。 - 将
''
+[object object]
得到[object object]
。
- []: 操作符是
这一个符合我们的逻辑。
{} + []
,同样的分析一下,最后会是[object object]
+''
得出的结果也是[object object]
。实际输出0
,这到底是为什么? 原因是 在{} + []
中,{}
被当做一个独立的代码块(不执行任何操作)。而在console.log中真正输出的是+[]
的值,+[]
前面讲过会得到0
,所以才变成了0
。
看完了这两个例子,来总结一下,如果+
运算其中一个操作值是字符串(或能转成字符串),前后就会被拼接起来;否则就进行数字运算。
隐式转为布尔值
在开发过程中,我们经常会使用到这种隐式转换,我们来看一些例子
if(..) 语句的隐式转换
var a = 10
if (!!a) { ... }
三目运算
var b = '1'
var c = '南墨';
var d = 'wzx';
var e;
e = b ? c : d;
逻辑运算符 &&
与 ||
var a = 'a'
var b = 'b'
var c = 'c'
if ((a && b) || c) {
console.log('ok,输出成功')
}
上面的情况都会被隐式的转为boolean值方便判断。我认为这是十分方便的。
宽松相等 ==
与 严格相等===
相信很多人在刚刚开始区分 ==
与 ===
时,会认为 ==
只比较值是否相等,而 ===
比较值与类型是否同时相等。
其实不然,应该是==
允许在相等比较中进行隐式类型转换,而 ===
不允许。
null和undefined的 ==
比较
ES5规范11.9.3.2-3规定,null
和undefined
的 ==
比较:
- 如果
x
为null
,y
为undefined
,则结果为true
。 - 如果
x
为undefined,
y为
null `,则结果为true。
也就是说在==中null和undefined相等。
null == undefined // true
字符串和数字的 ==
比较
ES5
规范11.9.3.4-5
这样定义,字符串和数字的 ==
比较:
- 如果
Type(x)
是数字,Type(y)
是字符串,则返回x
==ToNumber(y)
的结果。 - 如果
Type(x)
是字符串,Type(y)
是数字,则返回ToNumber(x)
==y
的结果。
简单来说就是,字符串和数字的 ==
比较,都是把字符串转成数字再进行比较。
来看个简单的例子:
var a = '123'
var b = 123
a == b // true
a === b // false
a
== b
返回true 是因为a被隐式转成了数字123
所以就等于123
,而a
=== b
没有进行隐式转换,所以返回false
, a
和 b
不相等。
其他类型和布尔类型的 ==
比较
规范11.9.3.6-7
是这样说的:
-
如果
Type(x)
是布尔类型,则返回ToNumber(x) == y
的结果; -
如果
Type(y)
是布尔类型,则返回x == ToNumber(y)
的结果。
总之就是,将布尔值转成数字再进行比较。来看两个例子。
例子1:
var a = true;
var b = '123'
a === b // false
来看下怎么返回false的:
- 根据上述规则,先将
a
转成数字 - 再根据
ToNumber
的规则,true
会被转成1
1
和'123'
比较,变成数字与字符串的比较,所以根据数字与字符串的比较规则将'123'
转成123
1
与123
不相等,返回false
。
例子2:
var a = false
var b = '123'
a === b // false
从这个例子可以看出,字符串"123"
既不等于true
,也不等于false
。一个值怎么可以既非真值也非假值,这是为什么?
因为"123"
== true
中并没有发生布尔值的比较和隐式类型转换,即"123"
没转换为true
,而是true
转换为1
,所以"123"
是真值还是假值与==
本身没有关系。
这里很容易误解,所以说建议无论什么情况下都不要使用== true
和== false
。
如果你需要判断a
是一个真假值,我建议这样写
// 不错
if (Boolean(a)) {
...
}
// 最佳
if (!!a) {
...
}
这样可以避免上述的 == true和== false 这些坑了。
对象和非对象之间的相等比较
ES5规范11.9.3.8-9做如下规定:
- 如果
Type(x)
是字符串或数字,Type(y)
是对象,则返回x == ToPrimitive(y)
的结果; - 如果
Type(x)
是对象,Type(y)
是字符串或数字,则返回ToPrimitive(x) == y
的结果。
也就是说会把对象通过ToPrimitive
进行转换再进行对比。
例如:
var num = 18
var arr = [18]
num == arr // true
根据上述规则,[18]
会先通过ToPrimitive
,转成"18"
,"18"
在与18
相比,"18"
又转成了18
,所以最终返回true
特殊情况
下面两个例子都是更改内置原生原型了
例子1:
Number.prototype.valueOf = function() {
return 3;
}
new Number(2) == 3 // true
2 == 3
不会有这种问题,因为2
和3
都是数字基本类型值,
不会调用Number.prototype.valueOf()
方法。
而Number(2)
涉及ToPrimitive
强制类型转换,因此会调用valueOf()
这看起来让人觉得JavaScript
设计有问题,其实有问题的是写出该代码的人。
我们应该避免这样的写法。
例子2:
if (a == 2 && a == 3) {
// ..
}
第一反应:a
怎么可能既等于2
又等于3
?
如果让a.valueOf()
每次调用都 +1
,比如设置一个变量一开始是2
,调用a
后该变量+1
,返回3
。
var i = 2;
Number.prototype.valueOf = function() {
return i++;
};
var a = new Number( 42 );
if (a == 2 && a == 3) {
console.log( "居然可以!" );
}
看完这两个例子,我们应该明白千万不要去更改内置原生原型,避免这些奇奇怪怪的情况。
假值的==
比较
下面分别列出了常规和非常规的情况:
"" == null // false
"" == undefined // false
"" == NaN // false
"" == 0 // true (1)
"" == [] // true (2)
"" =={} // false
"0" == null // false
"0" == undefined // false
"0" == false // true (3)
"0" == NaN // false
"0" == 0 // true
"0" == "" // false
0 == null // false
0 == undefined // false
0 == NaN // false
0 == [] // true(4)
0 == {} // false
false == null // false
false == undefined // false
false == NaN false
false == 0 // true (5)
false == "" // true (6)
false == [] // true (7)
false == {} // false
以上被我标记的七种情况不好理解,我们来看一下
"" == 0 // true (1)
"" == [] // true (2)
"0" == false // true (3)
0 == [] // true(4)
false == 0 // true (5)
false == "" // true (6)
false == [] // true (7)
(3)(5)(6)(7)都涉及到其他值与布尔值的转换,其实不难理解,根据其他值与布尔值的转换规则就能得出结果。我们应该避免使用布尔值与其他值的 ==
比较,所以我们重点看其他三种情况
"" == 0 // true (1)
"" == [] // true (2)
0 == [] // true(4)
这些情况比较特殊,我们一般不会这样写代码,所以要用心记一下这几个情况,以免遇到了感到诧异。
参考文献
-
ES5规范
-
《你不知道的JavaScript(中)》
转载自:https://juejin.cn/post/7193543204849860645