JavaScript著名面试题: 0.1 + 0.2 !== 0.3,即将成为过去

From 阿里巴巴前端标准化组

更新一下,可能很多同学并没有打开<0.30000000000000004.com>这个网站看看,里面列举各种语言0.1+0.2的结果(并不是大多数语言都等于0.30000000000000004 IEEE 754)。这个结果基于语言是采用的浮点算法。

另外0.1+0.2无法精确表达是因为二进制的原因,在这个网站中也说了。二进制只能精准表达2除尽的数字1/2, 1/4, 1/8,例如0.1(1/10)和0.2(1/5),在二进制中都无法精准表示时,需要根据精度舍入。我们人类熟悉的十进制运算系统,可以精准表达2和5除尽的数字,例如1/2, 1/4, 1/5(0.2), 1/8, 1/10(0.1)。当然十进制也有无法除尽的地方,例如1/3, 1/7,也需要根据精度舍入。但是金融系统和货币结算系统都采用的十进制,为了更加安全的应用在金融和货币系统中,因此各种语言中才考虑引入Decimal 10进制。


前言

我们先从简单,但是诡谲的断言开始:

// 违反常识,但JS中的确如此
console.assert(0.1 + 0.2 !== 0.3);
// 断言失败,报错
console.assert(0.1 + 0.2 === 0.30000000000000008);// 0.30000000000000001
// 断言成功
console.assert(0.1 + 0.2 === 0.30000000000000007);// 0.30000000000000004
console.assert(0.1 + 0.2 === 0.30000000000000006);// 0.30000000000000004
console.assert(0.1 + 0.2 === 0.30000000000000005);// 0.30000000000000004
console.assert(0.1 + 0.2 === 0.30000000000000004);// 0.30000000000000004
console.assert(0.1 + 0.2 === 0.30000000000000003);// 0.30000000000000004
console.assert(0.1 + 0.2 === 0.30000000000000002);// 0.30000000000000004
// 断言失败,报错
console.assert(0.1 + 0.2 === 0.30000000000000001);// 0.3

// JS中数字字面量会四舍五入到最近的双精度浮点。(Ties To Even,这是默认的舍入方式,二进制舍零进一)
因此字面量`0.30000000000000007`会变成`0.30000000000000004`
`0.1+0.2===0.30000000000000004`

如果学习过其他语言,对于JS的这种结果肯定感觉到诧异。这句话居然让这么多人讨论。。。有些歪楼,我用Rust解释一下。Rust可以在申明变量时指定类型,因此下面的等式结果符合预期。既然申明是f32,你会知道这里转换成了32位二进制浮点运算,当然也要了解精度舍入的问题。

  let d:f32 = 0.1 + 0.2;//0.3
  let c:f32 = 0.3;
  let e:f64 = 0.1 + 0.2;//0.30000000000000004
  let f:f64 = 0.3;
  println!("{} {}", d==c, e!=f);
// Rust中的BigDecimal
  let n = BigDecimal::from_str("0.1").unwrap();
  let m = BigDecimal::from_str("0.2").unwrap();
  let p = BigDecimal::from_str("0.3").unwrap();
  println!("{}", n+m==p);

JS是弱类型动态语言,申明变量时,我想用是Big Decimal如何表达呢?

let a = 0.1+0.2;//BigDecimal("0.1")+BigDecimal("0.2");
console.assert(a===0.3);//BigDecimal("0.3")
let b = 0.1+0.2;//Double(0.1)+Double(0.2);
console.assert(b!==0.3)//Double(0.2);

可以参考QuickJS的实现。BigDecimal提案支持无舍入误差的十进制浮点数运算,并且能自定义取整方式。这对应于新的0.1m形式字面量,就会得到实数0.1,不会有舍入误差。

let a = 0.1m+0.2m;
console.assert(a===0.3m)
let b = 0.1+0.2;//0.30000000000000004
console.assert(b!==0.3)

BigDecimal就是为了0.1+0.2 ≠ 0.3的问题而设计的,BigDecimal基于10进制,其他语言实现中通常申明时要采用字符串BigDecimal("0.1")。如果说引入BigDecimal会导致运算量变大是对的,但是对于金融产品来说,运算安全显然更加重要。

BigDecimalallows storing any real number to arbitrary precision; which avoids common floating point errors (such as 0.1 + 0.2 ≠ 0.3) at the cost of complexity.

国内很多程序员(Java已经支持BigDecimal)喜欢将JS的这个问题用来挖苦JavaScript,或者作为面试题。还有人专门为此注册了一个域名:0.30000000000000004.com

历史原因

这的确是JS的历史原因,正如“前言”中的示例,因为JavaScript是"弱类型"语言,但在小数点运算时,JavaScript将隐式的采取IEEE754二进制浮点运算。而不是我们想象中的十进制运算。而十进制和二进制转换时,就可能出现精度丢失的问题。

// 十进制转二进制无法准确表达0.1和0.2,只能用循环逼近;
0.1 -> 0.0001100110011001100(1100循环) -> 1.100110011001100 * 2^(-4)
0.2 -> 0.0011001100110011001(1001无限循环) -> 1.100110011001100 * 2^(-3)
// 数学中计算时,我们需要将指数位置对齐,但需要指明的是JS中没有采用Exponent Bias,而是将尾数Mantissa视为为整数计算的,这样误差会增大,但是实现算法简单。
(1).1001100110011001100110011001100110011001100110011010 (Exponent:-4)+
(1).1001100110011001100110011001100110011001100110011010 (Exponent:-3)=
(1).0011001100110011001100110011001100110011001100110100 (Exponent:-2)

// 转换为IEEE754双精度为 1.0011001100110011001100110011001100110011001100110100 * 2^(-2),如果用二进制转成十进制为(2^(-2)+2^(-5)+2^(-6)...)。 结果大约是0.30000000000000004419,去小数点后面17位精度为0.30000000000000004,

因为JS的精度丢失问题历史,所以通常JS中最佳实践中,不推荐大家用JS进行浮点运算,例如货币,交易相关。如果无法避免的计算场景,通常推荐先将数字转换成整数,例如¥10.00元,变成¥1000分,进行计算。 另外也可以通过比较两个浮点数差值的绝对值,是否超过误差精度。

function compareTwo(n1,n2) {
  return Math.abs( n1 - n2 ) < Number.EPSILON;
}
compareTwo(0.1+0.2, 0.3);

终解Decimal

经过上一节的JS历史介绍,我们可以看出其实JS默认数字都是隐式采用IEEE 754二进制运算的。EcmaScript的安全整数范围为 [-2^53, 2^53]。这一点对计算机比较友好,但却有些违反人类常识。JS语言设计之初,设计者并不认为JS适合去处理复杂的数字计算,而是作为浏览器中的动态语言处理对象(prototype原型)。 但随着这些年在广大开发者和EcmaScript组织TC39的不停努力下,JS边界范围的不断延展,JS已经应用到嵌入式设备,还有作为后端语言运行在服务器上,甚至发上了外太空(SpaceX的操作系统)。 因此原先的设计已经不太能够满足我们日常开发的需求,如果需要进一步让JS也可以应用到金融交易领域,我们也需要给JS引入十进制运算,另外引入十进制后,带来一个额外好处,能让JS开发者意识到:在JS中默认的数字运算其实是隐式转二进制运算。

TC39 Decimal proposal

这个议题是由Bloomberg + Igalia联合提出的,目前已经进入Stage 1。后续我们阿里巴巴也将参与这个议题的推进。有如已经进入Stage4的BigInt Proposal。BigInt扩展的是JS的正数边界,超过2^53安全整数问题。Decimal则是解决JS的小数问题-2^53。 这个议案在JS中引入新的原生类型:decimal(后缀m),声明这个数字是十进制运算。

let zero_point_three = 0.1m + 0.2m;
assert(zero_point_three === 0.3m);
// 提案中的例子
function calculateBill(items, tax) {
  let total = 0m;
  for (let {price, count} of items) {
    total += price * BigDecimal(count);
  }
  return BigDecimal.round(total * (1m + tax), {maximumFractionDigits: 2, round: "up"});
}

let items = [{price: 1.25m, count: 5}, {price: 5m, count: 1}];
let tax = .0735m;
console.log(calculateBill(items, tax));

后记

今年9月份的TC39会议,在0921--0924,10:00 to 15:00可喜的这次是东京时间。如果对于JS语言有兴趣的,或对TC39感兴趣的同学,欢迎加入 @贺师俊 Hax创建的:<github.com/orgs/JSCIG/t> 中国组一起讨论参与建设。

参考资料

[1] 0.30000000000000004.com

[2] juejin.im/post/68449038

[3] weitz.de/ieee/

[4] docs.google.com/present

[5] github.com/tc39/proposa

编辑于 09-14

文章被以下专栏收录