JavaScript中的大数字误舍入

85

看这段代码:

var jsonString = '{"id":714341252076979033,"type":"FUZZY"}';
var jsonParsed = JSON.parse(jsonString);
console.log(jsonString, jsonParsed);

当我在Firefox 3.5中查看控制台时,jsonParsed的值被四舍五入为数字:
Object id=714341252076979100 type=FUZZY

尝试不同的值,结果相同(数字四舍五入)。

我也不理解它的四舍五入规则。714341252076979136 被四舍五入为 714341252076979200,而 714341252076979135 被四舍五入为 714341252076979100。

为什么会这样呢?


感谢大家的快速帮助回答,我希望我能将这三个答案都标记为官方答案。 - Jaanus
6个回答

104
你正在超出JavaScript的number类型的容量,详见规范第8.5节IEEE-754双精度二进制浮点数的维基百科页面。这些ID需要是字符串。
IEEE-754双精度浮点数(JavaScript使用的数字类型)无法精确表示所有数字(当然)。众所周知,0.1 + 0.2 === 0.3是错误的。这会影响整数,就像它影响分数一样;它从9,007,199,254,740,991(Number.MAX_SAFE_INTEGER)开始。
超过Number.MAX_SAFE_INTEGER + 19007199254740992)之后,IEEE-754浮点格式无法再表示每个连续的整数。 9007199254740991 + 19007199254740992,但是 9007199254740992 + 1 也是 9007199254740992,因为 9007199254740993 无法在该格式中表示。下一个可以表示的是 9007199254740994。然后 9007199254740995 不能,但是 9007199254740996 可以。
原因是我们已经用完了位数,所以我们不再有一个1的位;最低位现在表示2的倍数。最终,如果我们继续下去,我们会失去那个位,只能以4的倍数工作。依此类推。
您的值远远超过了这个阈值,因此它们会被四舍五入为最接近的可表示值。
从ES2020开始,您可以使用BigInt来表示任意大的整数,但是它们没有JSON表示形式。您可以使用字符串和一个恢复函数:

const jsonString = '{"id":"714341252076979033","type":"FUZZY"}';
// Note it's a string −−−−^−−−−−−−−−−−−−−−−−−^

const obj = JSON.parse(jsonString, (key, value) => {
    if (key === "id" && typeof value === "string" && value.match(/^\d+$/)) {
        return BigInt(value);
    }
    return value;
});

console.log(obj);
(Look in the real console, the snippets console doesn't understand BigInt.)


如果你对这些位感到好奇,这里是发生的事情:IEEE-754二进制双精度浮点数有一个符号位,11位指数(它定义了数字的整体比例,作为2的幂次方[因为这是一个二进制格式]),以及52位有效数字(但是这个格式非常巧妙,它从这52位中获得了53位的精度)。指数的使用方式很复杂(在这里描述),但是以非常模糊的方式来说,如果我们将指数加一,有效数字的值就会加倍,因为指数用于2的幂次方(再次强调,这并不是直接的,其中有一些巧妙的地方)。
那么让我们来看看值为9007199254740991(也称为Number.MAX_SAFE_INTEGER)的情况:
+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− 符号位   / +−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
那个指数值,10000110011,意味着每次我们给尾数加一,所代表的数值就会增加1(整数1,我们早就失去了表示小数的能力)。
但是现在尾数已经满了。要超过这个数,我们必须增加指数,这意味着如果我们给尾数加一,所代表的数值就会增加2,而不是1(因为指数应用于2,这个二进制浮点数的基数)。
+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− 符号位   / +−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
好吧,没关系,因为9007199254740991 + 1无论如何都是9007199254740992。但是!我们无法表示9007199254740993。我们已经用完了比特位。如果我们只是将有效数字加1,那么值就会增加2:
+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− 符号位   / +−−−−−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
最终,我们再次用尽了有效数字位数,不得不增加指数,因此我们最终只能表示4的倍数。然后是8的倍数。然后是16的倍数。以此类推。

6
我喜欢这个答案,因为它实际上告诉你如何解决问题。 - jsh

72

你看到的实际上是两个舍入效果。在ECMAScript中,数字被内部表示为双精度浮点数。当id设置为714341252076979033(十六进制为0x9e9d9958274c359)时,它实际上被分配给最近的可表示双精度值,即714341252076979072(十六进制为0x9e9d9958274c380)。当您打印该值时,它会四舍五入为15个有效十进制数字,这会得到14341252076979100


1
15个有效的十进制数字是“143412520769791”,而不是“714341252076979”,这是我没有理解的地方。 - Monish Chhadwa
1
这个答案似乎有两个错误:1)最后一个数字缺少小数点前的 7,2)输出结果没有舍入到15位数字——它也是53位尾数浮点数的最近表示,大约需要15.95个十进制数字。那个 ...100 部分不如四舍五入稳定,例如 ...79135 会误差到 ...79100,而 ...79136 则会误差到 ...79200,甚至这个 ...35/...36 的限制将会任意漂移。(吹毛求疵模式:从某种意义上说,它四舍五入,因为它“四舍五入”到15.95个小数位) - user3125367
相关:为什么在JS中5726718050568503296被截断 - Sebastian Simon

10
此JSON解析器并非原因所在。只需尝试在fbug的控制台中输入714341252076979033,您将看到相同的714341252076979100。有关详细信息,请参见此博客文章:浮点数

8
感谢您链接到我的文章,但它只解释了问题的一半——将内部舍入值打印出来。即使JavaScript让你打印整个值,它仍然是错误的——它将是最接近可表示的双精度值,正如其他人在下面解释的那样。 - Rick Regan

5

JavaScript使用双精度浮点值,即53位的总精度,但你需要

ceil(lb 714341252076979033) = 60

需要用几个比特位来精确表示该值。

最接近的可精确表示的数字是714341252076979072(将原始数字转换为二进制,将最后7位替换为0并向上舍入,因为最高替换的数字是1)。

由于ToString()按照ECMA-262,§9.8.1中的说明使用十的幂和53位精度,所以你会得到714341252076979100而不是这个数字。


4
问题在于你的数字需要比JavaScript所支持的精度更高。
你能把这个数字作为一个字符串发送吗?分成两部分发送?

2

JavaScript只能处理精确的整数,最高可达9000亿亿(也就是15个零的9)。超过这个范围会得到垃圾值。可以通过使用字符串来存储这些数字来解决此问题。如果您需要对这些数字进行数学运算,请编写自己的函数或查找库:我建议使用前者,因为我不喜欢看到的库。要开始使用,请参见另一个答案中的两个函数。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接