JavaScript – Decimal

前言

之前就写过一篇 decimal, double, float,但写的有点杂乱,这篇把 JS 的部分独立写成一篇整理版。

 

参考

JavaScript 浮点数运算的精度问题

关于JavaScript中计算精度丢失的问题

Rounding

C#.Net筑基-深入解密小数内部存储的秘密

big.js设计思路和源码分享

JavaScript数字运算必备库——big.js源码解析

 

The Question: 0.1 + 0.2 = ?

JS 有一道经典的问题

console.log(0.1 + 0.2); // 0.30000000000000004

第一次接触 JS 的人可能会感到不可思议,但其实上面这道题,并不是 JS 独有的。

C# 也是一样的计算结果

public class Program
{
    public static void Main()
    {
        double x = 0.1;
        double y = 0.2;
        double z = x + y; // 0.30000000000000004
    }
}

Why 0.30...4 ?

代码上看,写的是十进制,但电脑在存储和计算时,其实用的是二进制 (想要深入理解,可以看这篇,我只懂个大概而已)。

0.1 + 0.2,电脑会先把 0.1 转换成二进制,而这个二进制是个无穷数 0.0001100110011001...(无限),所以只能保留一部分的精度 (IEEE 754 标准的 64 位双精度浮点数的小数部分最多支持 53 位二进制位)。

最终相加以后再转换成十进制,精度就丢失了,结果就会有偏差。

图片来源:关于JavaScript中计算精度丢失的问题(一)

toFixed 也有问题

不仅仅是加减乘除,就连 rounding 也是会出错的。

console.log(162.125.toFixed(2)); // 162.13 (四舍五入)
console.log(162.295.toFixed(2)); // 162.29 (四舍五入 失败)

先转换成整数,再做计算

可能我们会误以为,只要先把小数转换成整数,然后才做计算,就可以避开精度丢失问题...没有这么简单。

console.log(162.315.toFixed(2));              // 错误 162.31
console.log(Math.round(162.315 * 100) / 100); // 正确 162.32
console.log(Math.round(162.295 * 100) / 100); // 依然错误 162.29

因为 * 100 也是走二进制,所以依然有丢失的可能。

 

The Answer

上面说了这道题在 C# 也是同样的计算结果,但为什么往往叫的人都是 jser 呢?

因为 C# 有一个 best practice -- 但凡可能会让人计算的数,请使用 decimal。

public class Program
{
    public static void Main()
    {
        decimal x = 0.1m;
        decimal y = 0.2m;
        decimal z = x + y; // 0.3
    }
}

换成 decimal 计算就正确了。 

decimal vs double

decimal 的特色就是精度准、计算速度慢(因为 CPU 不支持 decimal 计算)、存储空间大、数目小。

所以如果不 care 精准度的话,大部分情况都会使用 double。(比如做游戏啦、科学啦、这些场景一般上需要计算的快、数目又大,但通常不需要太准)

算钱则一定是用 decimal 的,因为要准嘛。

How it work?

为什么 decimal 就 ok 了呢?

因为 decimal 不使用二进制 (这也是它慢的主要原因)。

上面我们提到,精度丢失就是因为十进制转二进制后,变成无穷数,只能存储一部分,而丢失的那部分就不可能还原了,最后就有微差。

decimal 不转二进制,就没有丢失的问题了。

但...不转二进制要怎样计算呢?

C# Decimal 我不清楚。

JS Decimal -- big.js 库的实现方式,就类似于,我们小时候用纸笔做算数那样

 

import Big from 'big.js';

const n1 = new Big(8881.12);
const n2 = new Big(165.26);
console.log(n1.plus(n2).toNumber()); // 正确 9046.38
console.log(8881.12 + 165.26);       // 错误 9046.380000000001

首先它把 number 转成 string,接着 split,然后按位置保存

最后按位置做加法计算,进位。

2 + 6 = 8,

1 + 2 = 3,

1 + 5 = 6,

8 + 6 = 14,保留 4,进位 1

8 + 1 + 1 = 10,保留 0,进位 1 

8 + 1 = 9

答案倒着看:9046.38

注:这个例子刚巧两个数目的小数点位置是相同的,假如不相同,那在计算之前先对齐就可以了。

由于是挨个挨个位置做加法计算,计算时虽然也会转成二进制,但它每个位置都是整数 (整数转二进制是不会无穷的),不是小数,所以就彻底避开了精度丢失的问题。

当然,这个加法计算,肯定是慢的,要 for loop,要自己处理进位等等一系列繁琐的操作,但视乎也没其它的办法了。

 

JS Decimal の big.js 库

JS 没有原生的 decimal 类型 (tc39 proposal),但是有 library 可以实现 decimal 的效果。

big.jsbignumber.jsdecimal.js 这 3 个库都是同一个作者。

big.js 最轻,也是我目前用着的,三者之间的区别可以看这篇: What is the difference between big.js, bignumber.js and decimal.js? 或它的翻译篇

安装
yarn add big.js
yarn add @types/big.js --dev

使用

import Big from 'big.js';
console.log(Big(0.1).plus(0.2).toNumber()); // 0.3

第一步是把 number 变成 Big 对象.

Big(0.1) 或者 new Big(0.1) 都可以, new 是 optional 的.

接着就是调用各做 operator 方法. 比如 plus, minus, mul / times, div (加减乘除, 注: mul 和 times 都是乘, alias 而已)

最后通过 toNumber 把 Big 对象转换成 JS 的 number 类型.

除了加减乘除, big js 也提供了许多对比方法, ===, >, >=, <, <= 等等. 这样写起来就比较方便了.

big.js 没有提供 min、max、sum 这些功能,需要的话得用 reduce 自己累加实现。

toPrecision() 类似 JS 的 toFixed 返回 string

round() 类似 Math.round 但它支持 round to n decimal point,而且有不同的 rounding mode,默认是四舍五入。

 

JS Workaround (Number.EPSILON)

如果不想大费周章搞 decimal,也可以用一些小技巧解决。

const value = 0.1 + 0.2;
if (value === 0.3) {
  console.log('yes');
} else {
  console.log('no'); // will be no, because it is 0.30000000000000004
}

把 if expression 换成

const value = 0.1 + 0.2;
if (0.3 - value < Number.EPSILON) { // EPSILON is a very very small number 2.220446049250313e-16
  console.log('yes'); // will be yes
} else {
  console.log('no');
}

Number.EPSILON 是 es6 的新特性。

 

posted @ 2022-05-07 14:11  兴杰  阅读(2131)  评论(2编辑  收藏  举报