// 将小数末位的5改成6再调用toFixed()
function toFixed(number, precision) {
var str = number + ''
var len = str.length
var last = str.substring(len - 1, len)
if (last == '5') {
last = '6'
str = str.substring(0, len - 1) + last
return (str - 0).toFixed(precision)
} else {
return number.toFixed(precision)
}
}
console.log(toFixed(1.333335, 5))
方案2
// 先扩大再缩小
function toFixed(num, s) {
var times = Math.pow(10, s)
// 因为乘法同样存在精度问题,加上0.5保证不会扩大后尾数过多而parseInt后丢失精度
var des = num * times + 0.5
// 去除小数
des = parseInt(des, 10) / times
return des + ''
}
console.log(toFixed(1.333335, 5))
2.4 修复数据运算(+-*/)
修复常用算数运算符的方法原理都是扩大缩小法,但也有些细节要注意。
/**
* floatObj 包含加减乘除四个方法,能确保浮点数运算不丢失精度
*
* 精度丢失问题(或称舍入误差,其根本原因是二进制和实现位数限制有些数无法有限表示
* 以下是十进制小数对应的二进制表示
* 0.1 >> 0.0001 1001 1001 1001…(1001无限循环)
* 0.2 >> 0.0011 0011 0011 0011…(0011无限循环)
* 计算机里每种数据类型的存储是一个有限宽度,比如 JavaScript
使用 64 位存储数字类型,因此超出的会舍去。舍去的部分就是精度丢失的部分。
*
* ** method **
* add / subtract / multiply /divide
*
* ** explame **
* 0.1 + 0.2 == 0.30000000000000004 (多了 0.00000000000004)
* 0.2 + 0.4 == 0.6000000000000001 (多了 0.0000000000001)
* 19.9 * 100 == 1989.9999999999998 (少了 0.0000000000002)
*
* floatObj.add(0.1, 0.2) === 0.3
* floatObj.multiply(19.9, 100) === 1990
*
*/
var floatObj = (function () {
/*
* 判断obj是否为一个整数 整数取整后还是等于自己。利用这个特性来判断是否是整数
*/
function isInteger(obj) {
// 或者使用 Number.isInteger()
return Math.floor(obj) === obj
}
/*
* 将一个浮点数转成整数,返回整数和倍数。如 3.14 >> 314,倍数是 100
* @param floatNum {number} 小数
* @return {object}
* {times:100, num: 314}
*/
function toInteger(floatNum) {
// 初始化数字与精度 times精度倍数 num转化后的整数
var ret = { times: 1, num: 0 }
var isNegative = floatNum < 0 //是否是小数
if (isInteger(floatNum)) {
// 是否是整数
ret.num = floatNum
return ret //是整数直接返回
}
var strfi = floatNum + '' // 转换为字符串
var dotPos = strfi.indexOf('.')
var len = strfi.substr(dotPos + 1).length // 拿到小数点之后的位数
var times = Math.pow(10, len) // 精度倍数
/* 为什么加0.5?
前面讲过乘法也会出现精度问题
假设传入0.16344556此时倍数为100000000
Math.abs(0.16344556) * 100000000=0.16344556*10000000=1634455.5999999999
少了0.0000000001
加上0.5 0.16344556*10000000+0.5=1634456.0999999999 parseInt之后乘法的精度问题得以矫正
*/
var intNum = parseInt(Math.abs(floatNum) * times + 0.5, 10)
ret.times = times
if (isNegative) {
intNum = -intNum
}
ret.num = intNum
return ret
}
/*
* 核心方法,实现加减乘除运算,确保不丢失精度
* 思路:把小数放大为整数(乘),进行算术运算,再缩小为小数(除)
* @param a {number} 运算数1
* @param b {number} 运算数2
*/
function operation(a, b, op) {
var o1 = toInteger(a)
var o2 = toInteger(b)
var n1 = o1.num // 3.25+3.153
var n2 = o2.num
var t1 = o1.times
var t2 = o2.times
var max = t1 > t2 ? t1 : t2
var result = null
switch (op) {
// 加减需要根据倍数关系来处理
case 'add':
if (t1 === t2) {
// 两个小数倍数相同
result = n1 + n2
} else if (t1 > t2) {
// o1 小数位 大于 o2
result = n1 + n2 * (t1 / t2)
} else {
// o1小数位小于 o2
result = n1 * (t2 / t1) + n2
}
return result / max
case 'subtract':
if (t1 === t2) {
result = n1 - n2
} else if (t1 > t2) {
result = n1 - n2 * (t1 / t2)
} else {
result = n1 * (t2 / t1) - n2
}
return result / max
case 'multiply':
// 325*3153/(100*1000) 扩大100倍 ==>缩小100倍
result = (n1 * n2) / (t1 * t2)
return result
case 'divide':
// (325/3153)*(1000/100) 缩小100倍 ==>扩大100倍
result = (n1 / n2) * (t2 / t1)
return result
}
}
// 加减乘除的四个接口
function add(a, b) {
return operation(a, b, 'add')
}
function subtract(a, b) {
return operation(a, b, 'subtract')
}
function multiply(a, b) {
return operation(a, b, 'multiply')
}
function divide(a, b) {
return operation(a, b, 'divide')
}
return {
add: add,
subtract: subtract,
multiply: multiply,
divide: divide,
}
})()
console.log(0.1 + 0.2) // 0.30000000000000004
console.log(floatObj.add(0.1, 0.2)) // 0.3
console.log(0.3 - 0.1) // 0.19999999999999998
console.log(floatObj.subtract(0.3, 0.1)) // 0.2
console.log(35.41 * 100) // 3540.9999999999995
console.log(floatObj.multiply(35.41, 100)) // 3541
console.log(0.3 / 0.1) // 2.9999999999999996
console.log(floatObj.divide()) // 3
JavaScript的数值存储探析
学
1. 浮点数的存储规则
JavaScript中的所有数字包括证书和小数只有一种类型:
Numbr
。它的实现遵循IEEE 754
标准,使用64位固定长度来表示,即标准的双精度浮点数double(单精度浮点数float则是32位)。IEEE754的标准,如图所示:
64位比特可分为三个部分:
符号位S:第一位是正负数符号位(sign),0表正数,1表负数
指数位E:中间的11位存储指数(exponent)
为什么要偏移1023?
尾数位M:最后的52位是尾数(mantissa),用来表示小数部分,位数不够用0补齐,超出部分进1舍0。
因此,计算机存储二进制构成即为:符号位+指数位+尾数位
举个栗子:
29.5转换为二进制是11101.1
11101.1转换为科学计数法:
符号位 为0(正数)
指数位 为4,加上指数偏移量1023,即1027,转为二进制即 10000000011
尾数位 为11011,补满52位即: 1101 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000
所以29.5存储为计算机的二进制标准格式为
符号位+指数位+尾数位:
0+10000000011+1101 1000 0000 0000 0000 0000 0000 0000 0000 0000
0000 0000 0000 ,即
0100 0000 0011 1101 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000
正好64位
好,现在整理一下步骤:
计算机想存储一个数字
①首先将数字转换为二进制
②再把二进制转换为科学计数法表示
③分析科学计数表示法,得出 符号位【1】+(指数位+偏移量)【11】+尾数位【52】
④拼接成64位的二进制数
2. Number对象上的特殊值
MAX_SAFE_INTEGER
表示最大的安全整数。
由上一节可知,双精度浮点数的可准确表示的最大整数是
MAX_VALUE
表示JS里内能表示的最大的值。
你或许以为就是64位全部拉满的情况:
0 11111111111 1111111111111111111111111111111111111111111111111111
但实际上,前文引用中提到过:“那为什么指数偏移是127,不是128呢?因为人们 为了特殊用处,不允许使用0和255这两个数字表示指数 ,少了2个数字,自然就只好采用127了。” 相对应的,64位存储时,11位的指数位,,即1024也会用于特殊用途。
因此,最大值的64位应该是指数位对应十进制为拉满的情况下-1,64位即:
0 11111111110 1111111111111111111111111111111111111111111111111111
计算过程是:
0 11111111110 1111111111111111111111111111111111111111111111111111
转换成二进制的科学计数法表示如下:
1.1111111111111111111111111111111111111111111111111111 * 2^{2046 - 1023}
= 1.1111111111111111111111111111111111111111111111111111 * 2^{1023}
= 11111111111111111111111111111111111111111111111111111 * 2^{971}
= (2^{53} - 1) * 2^{971}
= 1.7976931348623157e+308
验证一下:
到此,我们就可以很容易地理解下面精度相关的问题了。
3. 特殊值的存储
前文提到,某些指数有特殊用途,即:
以上规则,总结如下:
学以致用
我们先用前面学到的知识点来分析以下常见场景的误差产生的根本原因,最后来总结解决方案。
案例分析
1.1 精度丢失
为什么0.1+0.2 === 0.30000000000000004?
0.1转换为64位下的存储格式:
0.1
>>> 0.0001100110011001100110011001100110011001100110011001101 >>> 1.100110011001100110011001100110011001100110011001101 * 2^{-4}
>>> 0011111110111001100110011001100110011001100110011001100110011010
同理,转换0.2
0.2
>>> 0.001100110011001100110011001100110011001100110011001101
>>> 1.100110011001100110011001100110011001100110011001101 * 2^{-3}
>>> 0011111111001001100110011001100110011001100110011001100110011010
可以看出来在转换为二进制时
“就像一些无理数不能有限表示,如 圆周率 3.1415926...,1.3333... 等,在转换为二进制的科学记数法的形式时只保留64位有效的数字,此时只能模仿十进制进行四舍五入了,但是二进制只有 0 和 1 两个,于是变为 0 舍 1 入。在这一步出现了错误,那么一步错步步错,那么在计算机存储小数时也就理所应当的出现了误差。这即是计算机中部分浮点数运算时出现误差,这就是丢失精度的根本原因”
将0.1和0.2的二进制形式按实际展开,末尾补零相加,结果如下
0.00011001100110011001100110011001100110011001100110011010
+0.00110011001100110011001100110011001100110011001100110100
=0.01001100110011001100110011001100110011001100110011001110
则0.1 + 0.2的结果的二进制数科学记数法表示为为1.001100110011001100110011001100110011001100110011010 2^(-2), 省略尾数最后的0,即 1.00110011001100110011001100110011001100110011001101 2^(-2), 因此(0.1+0.2)实际存储时的形式是 0011111111010011001100110011001100110011001100110011001100110100 因计算机存储位数的限制而截断的二进制数字,再转换为十进制,就成了0.30000000000000004,刚好符合控制台里打印0.1+0.2的结果
所以,我们可以得出结论:十进制的浮点数在转换为二进制时,若出现了无限循环,会造成二进制的舍入操作,再转换为十进制时就会造成了计算误差。
1.2 大数危机
9999999999999999 == 10000000000000001===true ?
大整数的精度丢失和浮点数本质上是一样的,存储二进制时小数点的偏移量最大为52位,超出就会有舍入操作,因此JavaScript中能精准表示的最大整数是Math.pow(2, 53),十进制即9007199254740992,大于9007199254740992就可能会丢失精度。
使用parseInt()时也会有这种问题。
1.3 toFixed()对于小数最后一位为5时进位不正确问题
根本原因还是浮点数精度丢失问题:
如 1.005.toFixed(2) 返回的是 1.00 而不是 1.01
2. 解决方案
“修复” 0.1+0.2 == 0.3
ES6在Number对象上新增了一个极小的常量——Number.EPSILON
引入这么一个小的数的值,目的在于为浮点数的计算设置一个误差范围,如果误差能够小于Number.EPSILON,我们就可以认为结果是可靠的。
测试是否相等
2.2 修复数据展示
当你拿到可能有精度丢失的数据(如0.1+0.2),要展示时可以这样:
但此方法仅用于最终结果的展示,在运算前这样处理是无意义的(计算中仍会丢失精度)。
修复 toFixed()
方案1
方案2
2.4 修复数据运算(+-*/)
修复常用算数运算符的方法原理都是扩大缩小法,但也有些细节要注意。
当然,也可以用成熟的库来解决此类问题,如
math.js
、number-precision
等。参考文章:
https://github.com/camsong/blog/issues/9
https://zhuanlan.zhihu.com/p/100353781
https://segmentfault.com/q/1010000016401244/a-1020000016446375###
https://zh.wikipedia.org/wiki/IEEE_754#%E7%89%B9%E6%AE%8A%E5%80%BC