JS中数字值的表示方式探究
一:问题的提出
在JS中有时候会碰到浮点数计算的问题,比如下面的代码:
var two = 0.2
var one = 0.1
var eight = 0.8
var six = 0.6
console.log(two - one == one)// true
console.log(eight - six == two)// false
根据结果看,为什么0.2 - 0.1 = 0.1,而 0.8 - 0.6 != 0.2?
或者在一些大整数计算的时候,比如在计算64位系统的内存地址段的时候,比如下面的代码:
var a = 9007199254740992
var b = a + 100
var c = a + 103
console.log(b)// 9007199254741092
console.log(c)// 9007199254741096
为什么b是正确的,而c出错了呢?
二:JS中怎么表示数值 在探讨问题前,我们首先需要知道在JS中数值是怎么表示的。JS采用IEEE 754标准定义64位浮点格式表示数字。所有的数值都由浮点数表示,比如3其实是3.0。
其次,需要了解下浮点数一般的保存格式是什么。拿一个十进制的浮点数 abcd.efg 作为例子,其中的字符都是0~9的数字。如果采用浮点数保存的方式,则会将abcd.efg表示成下面的方式
abcd.efg = -1^(0)*[a(10^3) + b(10^2) + c(10^1) + d(10^0) + e(10^-1) + f(10^-2) + g(10^-3)]
abcd.efg = -1^(0)*[a.bcdefg(10^3)]
【JS中数字值的表示方式探究】我们做进一步扩展,如果是一个二进制的浮点数 abcd.efg,其中的字符都是0或者1(a是例外,a作为最高位只能为1,不然就可以将其表示为bcd.efg的格式)。那么它的表达格式,可以转换成下面的格式:
abcd.efg = -1^(0)*[a(2^3) + b(2^2) + c(2^1) + d(2^0) + e(2^-1) + f(2^-2) + g(2^-3)]
abcd.efg = -1^(0)*[a.bcdefg(2^3)]
将二进制和十进制进行比较,我们会发现,两者只有幂计算的基数不一样,各自为自己的进制数,而其他的结构类似。我们对其统一归纳,其中的a.bcdefg称为尾数/定点数,3称为幂指数,而最开始的0则称为符号位。而IEEE754协议就是按照上面的格式对浮点数进行定义的。
三:IEEE754协议64位浮点数数据结构 根据协议,IEEE754的64位浮点格式数据结构如下:
文章图片
64位浮点数.png
整个64位比特,被分为了三部分
- 符号位:1bit,符号位决定正负数,等于1表示为负数,等于0表示为正数
- 指数位:11bit,指数位决定了数值表示的幂指数,也就是上面中的3,
- 尾数位:52bit,尾数位决定了和幂指数相乘的定点数,也就是上面的a.bcdefg
2:指数位 指数位占用了11个bit,可以表示0~2047。在实际使用中,为了表示负的幂指数(在表示大于0,但非常小的小数时需要,比如0.00000012 = 1.2(10^-7))。在协议中定义了一个偏移量off。真正的幂指数=指数位-off。为了均衡,off一般取指数位的中位数,即(2047-1)/2 = 1023。通过这样的处理,指数位可以表示的幂指数范围为 -1023 ~ 1024。其中有几个特殊的幂指数有特殊的含义,我们在下面会做进一步解释。
3:尾数位 尾数位表示我们上面例子里面的a.bcdefg,且a不为0。这个数值存放在剩下的52bit里面。计算机作为一个二进制的系统,a在不为零的时候,只能为1。为了可以表示更多的数,协议中将a进行了省略,也就是说,在52位尾数位中只存储了bcdefg,而将a自动省略掉了。这也是为什么叫做尾数位(fraction)的原因所在。
总结下来,在IEEE754标准协议中,一个浮点数的表示可以写作
float = -1^(s)*1.f * 2^e // s为符号位,f为尾数位,e为(指数位-1023)
这种方式,也被称为规格化(normalized).
四:特殊情况 除了上面列举的规则外,JS中的浮点数还存在着几种特殊情况
- 指数位等于0,尾数位也等于0
按照规格化定义,一个浮点数应该等于 -1^(s)*1.f * 2^e。幂的结果永远大于零,也就是说 2^e一直是个非零整数,而1.f >= 1。这样的话,按照上面的格式,浮点数永远不能等于0。因此,IEEE754进行了特殊的规定,将指数位等于0的时候,f=0的情况,定义为0。此时有+0和-0两种情况。 - 指数位等于0,尾数位不等于0
按照规格化的定义,此时表示的浮点数为 -1^(s) * 1.f * 2^(-1023)。最小的正数是1.0 * 2^(-1023)。最大的正数是1.111……* 2^(-1023),最小累加精度为0.00……1 * 2^(-1023)。但是IEEE为了能够表示比1.0 * 2^(-1023)更小的数值,对其进行了修改,此时的定义修改为float = -1^(s) * 0.f * 2^(-1022)
按照此时的定义,最小化的正数为 0.00……01 * 2^(-1022),此时要小于 1.0 * 2^(-1023),因为 1.0 * 2^(-1023) == 0.1 * 2^(-1022)。最大化的正数为0.111…… * 2^(-1022) 。最小累加精度为 0.00……01 * 2^(-1022)。靠着减低精度的方法,成功扩展了可以表示的最小数。 - 指数位为2047,尾数位等于0
此时表示的值为 -1^(s) * 1.0 * 2^(1024),JS中将其定义为一个特殊的值infinity,表示无穷大的意思。 - 指数位为2047,尾数位不等于0
此时表示的值为NaN,表示非数值
Tip:文章里面所有带省略号的小数,在小数点后都有52位,对应尾数位的位数
五:总结 根据指数位的不同,JS中的数值分为了几段
- e =0 & f=0 表示区间
[-0, +0] - e = 0 & f != 0 表示区间
[0.00……01 * 2^(-1022), 0.11……11 * 2^(-1022)],最小累加精度为 0.00……01 * 2^(-1022) - e = [1, 2046] 表示区间
[1.0 * 2^(-1023) ~ 1.11……11 * 2^(1023)],最小累加精度为 0.00……01 * 2^(e-1023)
这里有个需要注意的地方,即整数的精度问题。按照最小累加精度的定义,当(e-1023) = 52的时候,最小精度0.00……01 * 2^52 = 1。这个时候,1.11……1 * 2^52 是该是可以准确表达整数的区间最大值。当其再加上1的时候,变为1.0 * 2^53,此时的最小精度变为了0.00……01 * 2^53 = 2。已经无法精确表示整数。因此JS的可以精确表示的最大整数就是 1.0 * 2^53。 - e = 2047 & f = 0 表示区间
此时表示为 无穷大(infinity0) - e = 2047 & f != 0 表示区间
此时表示为NaN
IEEE754默认的舍入原则为"round to nearest, tie to even"。具体来讲,就是往最近的偶数上舍(距离相等,往更高的位置舍)。比如对于十进制
Round(0.5) = 0;
Round(1.5) = 2;
Round(2.5) = 2
。至于为什么往偶数上舍,据说大师Knuth有一个例子说明偶数更好,于是一锤定音。对于二进制来讲,规定舍去的一位是0,则保持最后一位不变;如果是1,则进位,最后一位加1。比如当取四位有效数字的时候,
Round(1.0010) = 1.010;
Round(1.0101) = 1.011;
七:实际例子
- 0.2 - 0.1 = 0.1
先将0.2和0.1转换为二进制0.2 => 1.1001100110011001100110011001100110011001100110011001(1001...) * 2^(-3) 0.1 => 1.1001100110011001100110011001100110011001100110011001(1001...) * 2^(-4)
根据舍入原则,0.2 和 0.1 在IEEE754中的表示方式为0.2 => 1.1001100110011001100110011001100110011001100110011010 * 2^(-3) 0.1 => 1.1001100110011001100110011001100110011001100110011010 * 2^(-4)
然后根据浮点数的减法计算原则// 0.2 - 0.1 [0.1移阶] => 0.1100110011001100110011001100110011001100110011001101 * 2^(-3) [0.1取补] => 1.0011001100110011001100110011001100110011001100110011 * 2^(-3) [尾数相加] => 10.1100110011001100110011001100110011001100110011001101 * 2^(-3) [去溢] => 0.1100110011001100110011001100110011001100110011001101 * 2^(-3) [规则化] => 1.1001100110011001100110011001100110011001100110011010 * 2^(-4) 对比 0.1 => 1.1001100110011001100110011001100110011001100110011010 * 2^(-4)
从上面就得出了0.2 - 0.1 = 0.1。这里需要解释的是 0.2 - 0.1 = 0.1 表示的是0.2的IEEE754的表示方式,减去0.1的IEEE754的表示方式,得到的结果和0.1的IEEE754的表示方式一致。 - 0.8 - 0.6 != 0.2
先将0.8和0.6转换为二进制0.8 => 1.1001100110011001100110011001100110011001100110011001(1001...) * 2^(-1) 0.6 => 1.0011001100110011001100110011001100110011001100110011(0011...) * 2^(-1)
根据舍入原则,0.8 和 0.6 在IEEE754中的表示方式为0.8 => 1.1001100110011001100110011001100110011001100110011010 * 2^(-1) 0.6 => 1.0011001100110011001100110011001100110011001100110011 * 2^(-1)
然后根据浮点数的减法计算原则// 0.8 - 0.6 [0.6取补] => 0.1100110011001100110011001100110011001100110011001101 * 2^(-1) [尾数相加] => 10.0110011001100110011001100110011001100110011001100111 * 2^(-1) [去溢] => 0.0110011001100110011001100110011001100110011001100111 * 2^(-1) [规则化] => 1.1001100110011001100110011001100110011001100110011100 * 2^(-3) 对比 0.2 => 1.1001100110011001100110011001100110011001100110011010 * 2^(-3)
从上面就得出了0.8 - 0.6 != 0.2。而是等于0.20000000000000007 - 9007199254740992 + 100 = 9007199254741092
先转换为IEEE754的格式为9007199254740992=> 1.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 * 2^53 100 => 1.1001 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 * 2^6 103 => 1.1001 1100 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 * 2^6
然后根据浮点数的减法计算原则// 9007199254740992 + 100 [100移阶] => 0.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0011 0010 * 2^(53) [尾数相加] => 1.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0011 0010 * 2^(53) [转换] => 1 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0011 0010 0 [转换] => 9007199254741092 // 9007199254740992 + 103 [103移阶] => 0.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0011 0100 * 2^(53) [尾数相加] => 1.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0011 0100* 2^(53) [转换] => 1 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0011 0100 0 [转换] => 9007199254741096
这里需要注意的是,舍去原则不仅用于无理数的IEEE754的生成表示上,在移阶的时候,也会使用。比如103的移阶,从 1100111 先移位变为 110011,舍去1,因此加1,变为110100。 - 浮点数的乘法和除法 待后面补充
- 浮点小数计算精度问题
0.8 - 0.6 != 0.2 以及经典的 0.2 + 0.1 != 0.3 - 大整数的计算精度问题
9007199254740992 + 103 = 9007199254741096 - 结合律失效问题
3.14 + 1000000000000000 - 1000000000000000 = 3.125 3.14 + (1000000000000000 - 1000000000000000) = 3.14
Math JS
Sinful JS
BigDecimal
另外,Number类型也提供了两个方法toPrecision和toFixed。
- toPrecision:returns a string representing the
Number
object to the specified precision.
function precise(x) {
return Number.parseFloat(x).toPrecision(4);
}console.log(precise(123.456));
// expected output: "123.5"console.log(precise(0.004));
// expected output: "0.004000"
- toFixed:formats a number using fixed-point notation.
function financial(x) {
return Number.parseFloat(x).toFixed(2);
}console.log(financial(123.456));
// expected output: "123.46"console.log(financial(0.004));
// expected output: "0.00"
这两个函数的区别在于,一个是固定有效数字,一个是固定小数位。但需要注意的是他们都是用于浮点数的展示,返回的都是String类型,不可以直接进行数值运算。否则会出现非预想的结果,比如下面的代码
function foo(x, y) {
return x.toPrecision() + y.toPrecision()
}foo(0.1, 0.2)
"0.10.2"
九:自己实现简单的浮点数的加减乘除 十:为什么console.log(0.1) > 0.1 **TL; DR****: the string isn't an exact version of the number, it's just exact enough to differentiate it from its neighboring not-quite-exact numbers
字符串并不是数字的一个精准版本,它只是有足够的精度,可以让它和它相邻的那些不太精准的数字区分开来。
— the specification defines complex rules for converting numbers to strings in order to address that lack of precision. They're covered in §9.8.1 - ToString Applied to the Number Type:
规范定义了将数字转换为字符串的各种复杂规则,以解决浮点数精性的问题
Then there are following notes; follow the link for the full details. Note 3 is probably most relevant:
- If m is NaN, return the String "NaN".
- If m is +0 or ?0, return the String "0".
- If m is less than zero, return the String concatenation of the String "-" and ToString(?m).
- If m is infinity, return the String "Infinity".
- Otherwise, let n, k, and s be integers such that k ≥ 1, 10k?1 ≤ s < 10k, the Number value for s × 10n?k is m, and k is as small as possible. Note that k is the number of digits in the decimal representation of s, that s is not divisible by 10, and that the least significant digit of s is not necessarily uniquely determined by these criteria.
- If k ≤ n ≤ 21, return the String consisting of the k digits of the decimal representation of s (in order, with no leading zeroes), followed by n?k occurrences of the character ‘0’.
- If 0 < n ≤ 21, return the String consisting of the most significant n digits of the decimal representation of s, followed by a decimal point ‘.’, followed by the remaining k?n digits of the decimal representation of s.
- If ?6 < n ≤ 0, return the String consisting of the character ‘0’, followed by a decimal point ‘.’, followed by ?n occurrences of the character ‘0’, followed by the k digits of the decimal representation of s.
- Otherwise, if k = 1, return the String consisting of the single digit of s, followed by lowercase character ‘e’, followed by a plus sign ‘+’ or minus sign ‘?’ according to whether n?1 is positive or negative, followed by the decimal representation of the integer abs(n?1) (with no leading zeroes).
- Return the String consisting of the most significant digit of the decimal representation of s, followed by a decimal point ‘.’, followed by the remaining k?1 digits of the decimal representation of s, followed by the lowercase character ‘e’, followed by a plus sign ‘+’ or minus sign ‘?’ according to whether n?1 is positive or negative, followed by the decimal representation of the integer abs(n?1) (with no leading zeroes).
NOTE 3For me, the 4-10.ps.gz file seemed to be corrupted (couldn't read pages 6-8), but I found a PDF here: http://ampl.com/REFS/rounding.pdf (not as random a link as it may seem, apparently AMPLwas a prime motivation for the work in the paper).
Implementers of ECMAScript may find useful the paper and code written by David M. Gay for binary-to-decimal conversion of floating-point numbers:
Gay, David M. Correctly Rounded Binary-Decimal and Decimal-Binary Conversions. Numerical Analysis, Manuscript 90-10. AT&T Bell Laboratories (Murray Hill, New Jersey). November 30, 1990. Available as http://cm.bell-labs.com/cm/cs/doc/90/4-10.ps.gz. Associated code available as http://cm.bell-labs.com/netlib/fp/dtoa.c.gz and as http://cm.bell-labs.com/netlib/fp/g_fmt.c.gzand may also be found at the various netlib mirror sites.
参考链接
https://www.cnblogs.com/fsjohnhuang/p/5115672.html
https://www.css88.com/archives/7340
https://stackoverflow.com/questions/28494758/how-does-javascript-print-0-1-with-such-accuracy
推荐阅读
- 热闹中的孤独
- Shell-Bash变量与运算符
- JS中的各种宽高度定义及其应用
- 2021-02-17|2021-02-17 小儿按摩膻中穴-舒缓咳嗽
- 深入理解Go之generate
- 异地恋中,逐渐适应一个人到底意味着什么()
- 我眼中的佛系经纪人
- 《魔法科高中的劣等生》第26卷(Invasion篇)发售
- “成长”读书社群招募
- 2020-04-07vue中Axios的封装和API接口的管理