全面总结 JS 中浮点数运算问题

常常会碰到一个问题,"为何 0.1 + 0.2 !== 0.3? ",我找了不少资料,尽量全面地分析缘由和解决办法。javascript

文章可能有点枯燥,囧。html

这里先给出判断方法java

Math.abs(0.1+0.2-0.3) <= Number.EPSILON
复制代码

IEEE 754 64 位浮点类型

IEEE 754git

IEEE 754 规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,不多使用)与延伸双精确度(79比特以上,一般以80位实现)。github

该标准的全称为IEEE二进制浮点数算术标准(ANSI/IEEE Std 754-1985),又称IEC 60559:1989,微处理器系统的二进制浮点数算术(原本的编号是IEC 559:1989)。算法

单精度浮点数

单精度浮点数格式是一种数据类型,在计算机存储器中占用 4 个位元(32 bits),利用“浮点”(浮动小数点)的方法,能够表示一个范围很大的数值。安全

在 IEEE 754-2008 的定义中,32-bit base 2 格式被正式称为 binary32 格式。这种格式在 IEEE 754-1985 被定义为 single,即单精度。须要注意的是,在更早的一些计算机系统中,也存在着其余 4 字节的浮点数格式。bash

定义ide

第 1 位表示正负,中间 8 位表示指数,后 23 位储存有效数位(有效数位是 24 位)。post

中间八位共可表示 28=256 个数,指数能够是二补码;或 0 到 255,0 到 126 表明-127 到-1,127 表明零,128-255 表明 1-128。

有效数位最左手边的 1 并不会储存,由于它必定存在(二进制的第一个有效数字一定是 1)。换言之,有效数位是 24 位,实际储存 23 位。

图片

双精度浮点数

双精度浮点数(double)是计算机使用的一种数据类型。比起单精度浮点数,双精度浮点数(double)使用 64 位(8 字节) 来存储一个浮点数。 它能够表示十进位制的 15 或 16 位有效数字,其能够表示的数字的绝对值范围大约是 [2.23e-308,1.79e308]

定义

和单精度相似,第 1 位表示正负,后 11 位为指数位,最后 52 位表示精确度(有效位数是 53 位)。

图片

Number in JavaScript

Number.EPSILON

Number.EPSILON === 2.220446049250313e-16,表示 1 与 Number 可表示的大于 1 的最小的浮点数之间的差值。其接近于 2**-52

Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON 能够判断 0.1 + 0.20.3 的大小。

Number.MAX_SAFE_INTEGER

Number.MAX_SAFE_INTEGER 常量表示在 JavaScript 中最大的安全整数(maxinum safe integer)(2**53 - 19007199254740991)。

由于 Javascript 的数字存储使用了 IEEE 754 中规定的双精度浮点数数据类型,而这一数据类型可以安全存储 -(2**53 - 1)2**53 - 1 之间的数值(包含边界值)。

这里安全存储的意思是指可以准确区分两个不相同的值,例如 Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2 将获得 true 的结果

Number.MAX_VALUE

Number.MAX_VALUE 属性表示在 JavaScript 里所能表示的最大数值。

MAX_VALUE 属性值接近于 1.79e308,也就是双精度浮点型能表示的最大数字。大于 MAX_VALUE 的值表明 Infinity

看个例子

图片.png

Number.MIN_SAFE_INTEGER

表明在 JavaScript 中最小的安全的 integer 型数字 -(2**53 - 1)9007199254740991.

Number.MIN_VALUE

Number.MIN_VALUE 属性表示在 JavaScript 中所能表示的最小的正值。

MIN_VALUE 属性是 JavaScript 里最接近 0 的正值,而不是最小的负值。

MIN_VALUE 的值约为 5e-324。小于 MIN_VALUE ("underflow values") 的值将会转换为 0。

注意下,用 Math.abs(0.1 + 0.2 - 0.3) < Number.MIN_VALUE 将会返回 false

Number.isSafeInteger()

Number.isSafeInteger() 方法用来判断传入的参数值是不是一个“安全整数”(safe integer)。

好比,2**53 - 1 是一个安全整数,它能被精确表示,在任何 IEEE-754 舍入模式(rounding mode)下,没有其余整数舍入结果为该整数。做为对比,2**53 就不是一个安全整数,它可以使用 IEEE-754 表示,可是 2**53 + 1 不能使用 IEEE-754 直接表示,在就近舍入(round-to-nearest)和向零舍入中,会被舍入为 2**53

图片.png

0.一、0.二、0.3 分别是怎么表示的

这个地方比较复杂,涉及到二进制小数没法表示时自动截断,在 JS 中测试时发现,截断的精度有时是 52 位,有时是 53 位。在 0.1 + 0.2 中截断精度是 52 位,在 0.1 + 0.5 中截断精度是 53 位。

(0.1).toString(2) === "0.000 110011001100110011001100110011001100110011001100110 1“

(0.2).toString(2) === "0.00 1100110011001100110011001100110011001100110011001101"

(0.30000000000000004).toString(2) === "0.0100110011001100110011001100110011001100110011001101"

(0.3).toString(2) === "0.010011001100110011001100110011001100110011001100110011"

咱们看看 0.1 是如何被表示成这么一大串数字的。

0.1 * 2 = 0.2  -> 0
0.2 * 2 = 0.4  -> 0
0.4 * 2 = 0.8  -> 0
0.8 * 2 = 1.6  -> 1
0.6 * 2 = 1.2  -> 1
0.2 * 2 = 0.4  -> 0
0.4 * 2 = 0.8  -> 0 ... 一直循环,没法达到 1
复制代码

因此最终 0.1 用二进制表示是 0.0001 1001 1001 1001 ...,可是咱们看上面 (0.1).toString() 最后的六位 001101,正常循环应该是 001100,因此截断以后,0.1 二进制表示的值变大了!!!。0.2 转换为二进制表示截断以后也变大了。

图片

经过对比 0.一、0.2 及它们的和的二进制表示,能够发现字符串的长度变化了,可是精确度却没有变化,也就是从 1 开始到最后的字符串长度都是 52。

0.1 + 0.2 原本应该是长度在为 57,可是因为没法表示这样一个数,从新从 1 开始的数字开始计数,会截断最后的三个数字 (最后精确度为 52 或者 53 )

咱们再来看一个例子, 0.1 + 0.5 === 0.6 为 true,实际不能这么比较,极其容易出错。

(0.1).toString(2) === "0.0001100110011001100110011001100110011001100110011001101“,字符串长度为 57,精度为 52。

(0.5).toString(2) === "0.1"

(0.6).toString(2) === "0.10011001100110011001100110011001100110011001100110011",这个字符串长度为 55,精度为 53.

0.1 + 0.5 的本来结果为 0.1001100110011001100110011001100110011001100110011001101,这个数字没法用二进制表示,由于从第一个 1 开始日后的总长度为 55,大于 53,因此截断以后变成了 0.10011001100110011001100110011001100110011001100110011,这个结果和 0.6 的二进制表示正好相等!!!因此有 0.1 + 0.5 === 0.6

小数何时精度为 52 位,何时为 53 位

0.一、0.二、0.3 分别是怎么表示的 这一节中,咱们看到 0.1+0.2 结果的精确度是 52 位,而 0.1+0.5 的精确度是 53 位的,结合以前讲的双精度浮点数的表示方法,难免有个疑惑,精确度不该该都是用 53 位的吗?

咱们进一步看看,0.1~0.9 这几个小数的二进制表示

0.1 -> "0.0001100110011001100110011001100110011001100110011001101" 精度 52 位
0.2 -> "0.001100110011001100110011001100110011001100110011001101"  精度 52 位
0.3 -> "0.010011001100110011001100110011001100110011001100110011"  精度 53 位
0.4 -> "0.01100110011001100110011001100110011001100110011001101"   精度 52 位
0.5 -> "0.1"
0.6 -> "0.10011001100110011001100110011001100110011001100110011"   精度 53 位
0.7 -> "0.1011001100110011001100110011001100110011001100110011"    精度 52 位
0.8 -> "0.1100110011001100110011001100110011001100110011001101"    精度 52 位
0.9 -> "0.11100110011001100110011001100110011001100110011001101"   精度 53 位
复制代码

说实话,没有从这几个数字中得到什么规律!!!0.七、0.9 的精确度位数和预想的不同。。

欢迎各位留言讨论这一部分~

如何解决小数运算不许确的问题

小数运算不许是由于要计算的数字小数部分没法用二进制精确表示所致使,咱们能够把小数转化成整数运算以后再变回小数来解决!

如下解决办法来自 number-precision

/** * 精确加法 */
function plus(num1: number, num2: number, ...others: number[]): number {
  if (others.length > 0) {
    // 递归
    return plus(plus(num1, num2), others[0], ...others.slice(1));
  }
  // digitLength 是获取小数的点后面的字符个数
  // 下面是计算让 num一、num2 都为整数时的最小倍数
  const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
  // 让 num一、num2 都变成整数,而后运算,而后再变回小数
  return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
}
复制代码

计算过程相似于

0.11 + 0.345

0.11 -> digitLength(0.11) -> 2
0.345 -> digitLength(0.345) -> 3

故 baseNum = 3

0.11 * 10**3 = 110
0.345 * 10**3 = 345

110 + 345 = 455

455 / baseNum= 0.455
复制代码

image

手动写安全的加减乘数

计算的关键就在于把小数转换成可准确表示的整数,下面的代码只是大概的功能实现,若是要直接使用,能够用 github.com/nefe/number…


更新: 因为小数 * 整数也可能致使计算不许确,因此以前的代码存在偏差。修复事后不容许直接对小数参数作运算,必须转换成整数运算而后再转换回来


digitLength 获取到数字的小数部分的位数,这是变为整数的关键。

// 兼容多种类型的表示
// 1.11 或者 1.11e-30 或者 1e-30
export function digitLength(num: number): number {
  // 1.11 -> eSplit: ['1.11']
  // 1.11e-30 -> eSplit: ["1.11", "-30"]
  const eSplit = num.toString().split(/[eE]/)
  // 右边的 `|| ''` 为了防止 1e-30 -> eSplit: ["1", "-30"] 这种
  // 左边 1.11 有两个小数,右边 e 后面有 -30,因此是 2 - (-30) 为 32
  const len = (eSplit[0].split('.')[1] || '').length - Number(eSplit[1] || 0)
  return len > 0 ? len : 0
}
复制代码

baseNum 计算出让 num1num2 都为整数的最小 10 的倍数

export function baseNum(num1: number, num2: number): number {
  return Math.pow(10, Math.max(digitLength(num1), digitLength(num2)))
}
复制代码

strip 对错误的数进行修正

/**
 * 把错误的数据转正
 * strip(0.09999999999999998)=0.1
 */
+export function strip(num: number, precision = 12): number {
+ // (0.09999999999999998).toPrecision(12) => "0.100000000000"
+ // parseFloat("0.100000000000") => 0.1
+ return +parseFloat(num.toPrecision(precision))
+}
复制代码

float2Fixed 把传入的数变为整数

+export function float2Fixed(num: number) {
+ // 1.23456 => 123456
+ if (num.toString().indexOf('e') === -1) {
+ return Number(num.toString().replace('.', ''))
+ }
+ // 1.1e-30
+ const dLen = digitLength(num)
+ // 这个地方须要辅助矫正,num * Math.pow(10, dLen) 小数和整数相乘仍然可能会出现不许的状况
+ return dLen > 0 ? strip(num * Math.pow(10, dLen)) : num
+}
复制代码

乘法计算

export function times(num1: number, num2: number): number {
   const bn = digitLength(num1) + digitLength(num2)
- const intNum1 = num1 * Math.pow(10, digitLength(num1))
- const intNum2 = num2 * Math.pow(10, digitLength(num2))
+ const intNum1 = float2Fixed(num1)
+ const intNum2 = float2Fixed(num2)
   return (intNum1 * intNum2) / Math.pow(10, bn)
}
复制代码

加法计算

export function plus(num1: number, num2: number): number {
   const bn = baseNum(num1, num2)
- return (num1 * bn + num2 * bn) / bn
+ // fix:不能使用 num1 * bn,小数与整数相乘可能不许确,须要精确乘 times
+ return (times(num1, bn) + times(num2, bn)) / bn
}
复制代码

减法计算

export function minus(num1: number, num2: number): number {
   const bn = baseNum(num1, num2)
- return (num1 * bn - num2 * bn) / bn
+ return (times(num1, bn) - times(num2, bn)) / bn
}
复制代码

除法计算

export function divide(num1: number, num2: number): number {
   const bn = baseNum(num1, num2)
- const intNum1 = num1 * bn
- const intNum2 = num2 * bn
+ const intNum1 = times(num1, bn)
+ const intNum2 = times(num2, bn)
   // 要检查扩大后的数字是否超过了安全边界
   return intNum1 / intNum2


+ // 避免把数字扩的太大的写法
+ // const num1Changed = float2Fixed(num1)
+ // const num2Changed = float2Fixed(num2)
+ // return times(num1Changed / num2Changed, strip(Math.pow(10, digitLength(num2) - digitLength(num1))))
}
复制代码

这四种运算的原理都是先放大数字,使之可以精确表示,计算以后再缩小数字,获得实际值。

测试结果

import { plus, minus, divide, times } from './index'

test('javascript/number-precision-operation', () => {
  expect(plus(0.1, 0.2)).toBe(0.3) // 0.30000000000000004
  expect(plus(0.1, 0.7)).toBe(0.8) // 0.7999999999999999
  expect(minus(1, 0.9)).toBe(0.1) // 0.09999999999999998
  expect(divide(0.1, 0.3)).toBe(0.3333333333333333) // 0.33333333333333337
  expect(times(0.1, 0.1)).toBe(0.01) // 0.010000000000000002
})
复制代码

参考

欢迎在本文下面评论或者在 GitHub issue 中参与讨论 github.com/lxfriday/gi…


欢迎你们关注个人掘金和公众号,算法、TypeScript、React 及其生态源码按期讲解

欢迎进群讨论~~

|
相关文章
相关标签/搜索