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位浮点格式数据结构如下:

JS中数字值的表示方式探究
文章图片
64位浮点数.png
整个64位比特,被分为了三部分
  • 符号位:1bit,符号位决定正负数,等于1表示为负数,等于0表示为正数
  • 指数位:11bit,指数位决定了数值表示的幂指数,也就是上面中的3,
  • 尾数位:52bit,尾数位决定了和幂指数相乘的定点数,也就是上面的a.bcdefg
1:符号位 符号位是最容易理解的,它表示了当前数值的符号,通过(-1)作为基数,幂上符号位的值得出。当符号位为1的时候,(-1)^1 = -1;当符号位为0的时候,(-1)^0 = 1
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,表示非数值
上面这几种特殊的情况,被称为非规格化(denormalized)
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
六:舍入原则 在具体分析之前,还需要确认的一个规则为舍入原则。所谓的舍入原则是指当浮点数的尾数无法精准表示数值时,如何将多余的部分舍去。比如,0.1如果转换成二进制的话,它的表示方式是 0. 0 0011 0011 0011 ……是个无穷尽的循环。但是现在我们只有52位+默认的置1位,也就是53位,可以使用,那么多余的部分就要舍去。
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

八:解决方案 JS中浮点数的精度问题,是由表达方式/舍入方法本质上决定的。最好的办法就是尽量避免直接进行小数之间的运算比较。或者使用类库。常用的类库有
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:
规范定义了将数字转换为字符串的各种复杂规则,以解决浮点数精性的问题
  1. If m is NaN, return the String "NaN".
  2. If m is +0 or ?0, return the String "0".
  3. If m is less than zero, return the String concatenation of the String "-" and ToString(?m).
  4. If m is infinity, return the String "Infinity".
  5. 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.
  6. If kn ≤ 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’.
  7. 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.
  8. 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.
  9. 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).
  10. 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).
Then there are following notes; follow the link for the full details. Note 3 is probably most relevant:
NOTE 3
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.
For 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).
参考链接
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

    推荐阅读