一个数字截取引发的精度问题(四)

做前端的都感觉JS这语言巨坑无比,兼容性让你摸不到头脑。一些初学者遇到:

0.1 + 0.2 = 0.30000000000000004

都会觉得这JS太TM坑了,一个小数计算都不会。可是我想说,这”锅”JS不背!其实和JS采用的数值存储 IEEE754 规范有关,
所有采用此规范的语言都会有此问题并不是JS的”锅”。

IEEE754

IEEE浮点数算术标准(IEEE 754)是最广泛使用的浮点数运算标准,为许多CPU与浮点运算器所采用,单精确度(32位)、双精确度(64位)、延伸单精确度(43位以上,很少使用)与延伸双精确度(79位元以上,通常以80位元实做)

计算机中是用有限的连续字节保存浮点数的。 JS采用64位(双精度)存储数据,在 IEEE 标准中,浮点数是将所有二进制位分割为特定宽度的符号域(S),指数域(E)和尾数域(F)三个域, 其中保存的值分别用于表示给定二进制浮点数中的符号,指数和尾数。

根据国际标准IEEE 754,任意一个二进制浮点数V可以表示成下面的形式:

V = (-1)^s×M×2^E
  1. (-1)^s表示符号位,当s=0,V为正数;当s=1,V为负数。
  2. M表示有效数字,大于等于1,小于2,但整数部分的1可以省略。
  3. 2^E表示指数位。

    对于十进制的5.25对应的二进制为:101.01,相当于:1.0101*2^2。所以,S为0,M为1.0101,E为2。
    -5.25=-101.01=-1.0101*2^2。所以S为1,M为1.0101,E为2。

复习一下十进制转2进制

口诀

整数部分除2取余,由下到上;小数部分乘2取整,由上到下。

0.1 在计算机中如何存储?

首先 0.1 转化为二进制:0.000110011(0011循环)套用公式可得:

(-1)^0*1.1001*2^-4

所以 s:0,M:1.1001(循环1001),E:-4。

由于小数位仅储存 52bit, 储存时会将超出精度部分进行”零舍一入”,

无限精确值:

1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001

实际储存值:

1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010

此处精度已经丢失一次。最后0.1实际存储为:

0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010

同理计算出0.2的实际存储值(同样也存在精度丢失):

0.001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010

两数相加得:

0.01001100110011001100110011001100110011001100110011001110

再转为十进制:0.30000000000000004。

在线转换工具:http://tool.oschina.net/hexconvert。

参考:

https://www.web-tinker.com/article/20035.html

http://www.mallocfree.com/interview/c-5-float.htm

https://zh.wikipedia.org/zh-hans/%E4%BA%8C%E8%BF%9B%E5%88%B6

http://2ality.com/2012/04/number-encoding.html

http://zencode.in/1.%E6%B5%AE%E7%82%B9%E6%95%B0%E5%8A%A0%E5%87%8F%E9%97%AE%E9%A2%98.html

一个数字截取引发的精度问题(三)

上次总结的第四条: 当传入的参数小于数字的整数位时,返回指数形式表示的字符串。

let numObj = 12345.6
numObj.toPrecision(2) // '1.2e+4'

在JavaScript中有一个专门返回数字的指数形式的方法:toExponential()

numObj.toExponential([fractionDigits])

解释:

A string representing the given Number object in exponential notation with one digit before the decimal point, rounded to fractionDigits digits after the decimal point.

大意:

返回一个小数点前有一位数字且已按照小数点后指定的位数(fractionDigits)四舍五入后的指数形式的字符串。

let numObj = 77.1234;

console.log(numObj.toExponential());  // logs 7.71234e+1
console.log(numObj.toExponential(4)); // logs 7.7123e+1
console.log(numObj.toExponential(2)); // logs 7.71e+1
console.log(77.1234.toExponential()); // logs 7.71234e+1
console.log(77 .toExponential());     // logs 7.7e+1

注意:

  1. fractionDigits 取 0~20之间,其实就是小数点后有几个数字。
  2. 若numObj是一个没有小数点或者非指数形式的数字字面量,在调用时需要加一个空格,以防止解释器将”点”解释为小数点。
  3. 此方法也会进行四舍五入,作为金额计算时,要多加注意。

下篇将探究一下,经典问题:0.1 + 0.2 != 0.3。

一个数字截取引发的精度问题(二)

上篇文章只是简单介绍了 toFixed 方法,抽时间把 Number 里的一些方法又看了一下,其中有个方法引起我的注意:

Number.prototype.toPrecision()

precision 为”精度”的意思,貌似这个方法更符合上一篇文章所说的功能。可是事情并没有像我想象的一样,先看官方定义:

A string representing a Number object in fixed-point or exponential notation rounded to precision significant digits

大意:

返回一个定点和指数表示的同时四舍五入到指定位数的字符串。

再看一下使用方法,同时会总结出一些要点:

let PI = 3.1415926
console.log(PI.toPrecision(6)) // 3.14159
console.log(PI.toPrecision(4)) // 3.142
console.log(PI.toPrecision(2)) // 3.1

1.按指定的数字截取数字位数,同时四舍五入。

let numObj = 0.000123

console.log(numObj.toPrecision());    // '0.000123'
console.log(numObj.toPrecision(5));   // '0.00012300'
console.log(numObj.toPrecision(2));   // '0.00012'
console.log(numObj.toPrecision(1));   // '0.0001' 

2.没有传入参数时,返回数字的字符串形式,3.截取的位置从左边第一个非0的数字开始算起,不足补0。

let numObj = 12345.6
numObj.toPrecision(2) // '1.2e+4'

4.当传入的参数小于数字的整数位时,返回指数形式标识的字符串。

此方法用下来并没有达到我直觉认为的那样,这在一些金钱格式化上还不是很好用。

一个数字截取引发的精度问题(一)

上周有一个“收银台”的业务需要重构,其中有一个需求:

收益计算的结果,取小数点后两位但不进行四舍五入,若不足则补0。

看到这个需求你应该会第一个想到:

numberObj.toFixed([digits])

因为这个方法基本可以满足这个需求。但是当看到以前同事的方法时,感觉这个方法并不能完全满足:

/**
* 截断小数点后几位
* @val 数值
* @pos 小数点后截断的位置
*/
cutOffDecimal(val, pos) {
    // 把数字转换成字符串
    val = val.toString()
    let len = val.length
    let index = val.indexOf('.')
    let subVal = val; // 这是什么鬼?
    if (index != -1) {
      subVal = val.substring(0, index + pos +  1)
    }
    // 利用 toFixed 防止小数位达不到其位数要求
    return Number(subVal).toFixed(pos)
}

代码意思很明显,检测是否含有小数点,若有则用小数点的位置 + 精确的小数位置 + 1,因为substring最后一个位置不包括在内所以加1,
最后用toFixed补全。

他没有直接用toFixed,说明此方法不能直接满足。我查了一下API说明果然有猫腻:

The number is rounded if necessary

意思是此方法在必要时进行四舍五入,一看这个肯定不能直接满足此需求,我感觉上面代码写的也有点啰嗦,改写如下:

export function NumberPrecision(number,prec = 0){

    if(typeof number != 'number' || Number.isNaN(number)){
        console.error('Must be a number but not a NaN');
        return;
    }

    return number.toFixed(prec + 1).slice(0,-1);
}

1.类型判断,非数字以及NaN的则报错;

2.toFixed我没有直接取到目标位置,而是取到目标位置的下一个位置,这样就避免了该方法的四舍五入对结果造成的影响,然后再用slice截取字符串。