为什么(2.55).toFixed(1)等于2.5?

上次遇到了一个奇怪的问题:JS的(2.55).toFixed(1)输出是2.5,而不是四舍五入的2.6,这是为什么呢?

进一步观察:

发现,并不是所有的都不正常,1.55的四舍五入还是对的,为什么2.55、3.45就不对呢?

这个需要我们在源码里面找答案。

数字在V8里面的存储有两种类型,一种是小整数用Smi,另一种是除了小整数外的所有数,用HeapNumber,Smi是直接放在栈上的,而HeapNumber是需要new申请内存的,放在堆里面。我们可以简单地画一下堆和栈在内存的位置:

如下代码:

let obj = {};

这里定义了一个obj的变量,obj是一个指针,它是一个局部变量,是放在栈里面的。而大括号{}实例化了一个Object,这个Object需要占用的空间是在堆里申请的内存,obj指向了这个内存所在的位置。

栈和堆相比,栈的读取效率要比堆的高,因为栈里变量可以通过内存偏差得到变量的位置,如用函数入口地址减掉一个变量占用的空间(向低地址增长),就能得到那个变量在内存的内置,而堆需要通过指针寻址,所以堆要比栈慢(不过栈的可用空间要比堆小很多)。因此局部变量如指针、数字等占用空间较小的,通常是保存在栈里的。

对于以下代码:

let smi = 1;

smi是一个Number类型的数字。如果这种简单的数字也要放在堆里面,然后搞个指针指向它,那么是划不来的,无论是在存储空间或者读取效率上。所以V8搞了一个叫Smi的类,这个类是不会被实例化的,它的指针地址就是它存储的数字的值,而不是指向堆空间。因为指针本身就是一个整数,所以可以把它当成一个整数用,反过来,这个整数可以类型转化为Smi的实例指针,就可以调Smi类定义的函数了,如获取实际的整数值是多少。

如下源码的注释:

// Smi represents integer Numbers that can be stored in 31 bits.
// Smis are immediate which means they are NOT allocated in the heap.
// The this pointer has the following format: [31 bit signed int] 0
// For long smis it has the following format:
// [32 bit signed int] [31 bits zero padding] 0
// Smi stands for small integer.

在32位系统上使用一个int整型是32位,使用前面的31位表示整数的值(包括正负符号),而在64位系统上int整型是64位,使用前32位表示整数的值,所以在64位系统上减去一个符号位,还剩31位,所以Smi最大整数为:

2 ^ 31 - 1 = 2147483647 = 21亿

大概为21亿,而32位系统少一半。

到这里你可能会有一个问题,为什么要搞这么麻烦,不直接用基础类型如int整型来存就好了,还要搞一个Smi的类呢?这可能是因为V8里面对JS数据的表示都是继承于根类Object的(注意这里的Object不是JS的Object,JS的Object对应的是V8的JSObject),这样可以做一些通用的处理。所以小整数也要搞一个类,但是又不能实例化,所以就用了这样的方法——使用指针存储值。

大于21亿和小数是使用HeapNumber存储的,和JSObject一样,数据是存在堆里面的,HeapNumber存储的内容是一个双精度浮点数,即8个字节 = 2 words = 64位。关于双精度浮点数的存储结构我已经在《为什么0.1 + 0.2不等于0.3?》做了很详细的介绍。这里可以再简单地提一下,如源码的定义:

  static const int kMantissaBits = 52;
static const int kExponentBits = 11;

64位里面,尾数占了52位,而指数用了11位,还有一位是符号位。当这个双精度的空间用于表示整数的时候,是用的52位尾数的空间,因为整数是能够用二进制精确表示的,所以52位尾数再加上隐藏的整数位的1(这个1是怎么来的可参考上一篇)能表示的最大值为2 ^ 53 - 1:

// ES6 section 20.1.2.6 Number.MAX_SAFE_INTEGER
const double kMaxSafeInteger = 9007199254740991.0; // 2^53-1

这是一个16位的整数,进而可以知道双精度浮点数的精确位数是15位,并且有90%的概率可以认为第16位是准确的。

这样我们就知道了,数在V8里面是怎么存储的。对于2.55使用的是双精度浮点数,把2.55的64位存储打印出来是这样的:

对于(2.55).toFixed(1),源码里面是这么进行的,首先把整数位2取出来,转成字符串,然后再把小数位取出来,根据参数指定的位数进行舍入,中间再拼个小数点,就得到了四舍五入的字符串结果。

整数部分怎么取呢?2.55的的尾数部分(加上隐藏的1)为数a:

1.01000110011...

它的指数位是1,所以把这个数左移一位就得到数b:

10.1000110011...

a原本是52位,左移1位就变成了53位的数,再把b右移52 - 1 = 51位就得到整数部分为二进制的10即十进制的2。再用b减掉10左移51位的值,就得到了小数部分。这个实际的计算过程是这样的:

// 尾数右移51位得到整数部分
uint64_t integrals = significand >> -exponent; // exponent = 1 - 52
// 尾数减掉整数部分得到小数部分
uint64_t fractionals = significand - (integrals << -exponent);

接下来的问题——整数怎么转成字符串呢?源代码如下所示:

static void FillDigits32(uint32_t number, Vector<char> buffer, int* length) {
int number_length = 0;
// We fill the digits in reverse order and exchange them afterwards.
while (number != 0) {
char digit = number % 10;
number /= 10;
buffer[(*length) + number_length] = '0' + digit;
number_length++;
}
// Exchange the digits.
int i = *length;
int j = *length + number_length - 1;
while (i < j) {
char tmp = buffer[i];
buffer[i] = buffer[j];
buffer[j] = tmp;
i++;
j--;
}
*length += number_length;
}

就是把这个数不断地模以10,就得到个位数digit,digit加上数字0的ascii编码就得到个位数的ascii码,它是一个char型的。在C/C++/Java/Mysql里面char是使用单引号表示的一种变量,用一个字节表示ascii符号,存储的实际值是它的ascii编码,所以可以和整数相互转换,如'0' + 1就得到'1'。每得到一个个位数,就除以10,相当十进制里面右移一位,然后继续处理下一个个位数,不断地把它放到char数组里面(注意C++里面的整型相除是会把小数舍去的,不会像JS那样)。

最后再把这个数组反转一下,因为上面处理后,个位数跑到前面去了。

小数部分是怎么转的呢?如下代码所示:

int point = -exponent; // exponent = -51
// fractional_count表示需要保留的小数位,toFixed(1)的话就为1
for (int i = 0; i < fractional_count; ++i) {
if (fractionals == 0)
break;
fractionals *= 5; // fractionals = fractionals * 10 / 2;
point--;
char digit = static_cast<char>(fractionals >> point);
buffer[*length] = '0' + digit;
(*length)++;
fractionals -= static_cast<uint64_t>(digit) << point;
}
// If the first bit after the point is set we have to round up.
if (((fractionals >> (point - 1)) & 1) == 1) {
RoundUp(buffer, length, decimal_point);
}

如果是toFixed(n)的话,那么会先把前n位小数转成字符串,然后再看n + 1位的值是需要进一位。

在把前n位小数转成字符串的时候,是先把小数位乘以10,然后再右移50 + 1 = 51位,就得到第1位小数(代码里面是乘以5,主要是为了避免溢出)。小数位乘以10之后,第1位小数就跑到整数位了,然后再右移原本的尾数的51位就把小数位给丢掉了,因为剩下的51位肯定是小数部分了,所以就得到了第一位小数。然后再减掉整数部分就得到去掉1位小数后剩下的小数部分,由于这里只循环了一次所以就跳出循环了。

接着判断是否需要四舍五入,它判断的条件是剩下的尾数的第1位是否为1,如果是的话就进1,否则就不处理。上面减掉第1位小数后还剩下0.05:

实际上存储的值并不是0.05,而是比0.05要小一点:

由于2.55不是精确表示的,而2.5是可以精确表示的,所以2.55 - 2.5就可以得到0.05存储的值。可以看到确实是比0.05小。

按照源码的判断,如果剩下的尾数第1位不是1就不进位,由于剩下的尾数第1位是0,所以不进位,因此就导致了(2.55).toFixed(1)输入结果是2.5.

根本原因在于2.55的存储要比实际存储小一点,导致0.05的第1位尾数不是1,所以就被舍掉了。

那怎么办呢?难道不能用toFixed了么?

知道原因后,我们可以做一个修正:

if (!Number.prototype._toFixed) {
Number.prototype._toFixed = Number.prototype.toFixed;
}
Number.prototype.toFixed = function(n) {
return (this + 3e-16)._toFixed(n);
};

就是把toFixed加一个很小的小数,这个小数经实验,只要3e-16就行了。这个可能会造成什么影响呢,会不会导致原本不该进位的进位了?我们刚刚提到双精度的精度是15位,第16位起是不可靠的,加上一个16位的小数可能会导致15位进1。但是如果两个数相差3e-16的话,其实几乎可以认为这两个数是相等的,所以加上这个造成的影响是可以忽略不计的。这个数和Number.EPSILON就差了一点点:

js toFixed的更多相关文章

  1. JS toFixed 四舍六入五成双

    以前一直以为toFixed就是四舍五入的方法,后来又有一段时间以为toFixed是五舍六入.今天终于写的时候,终于才知道toFixed是一个叫做四舍六入无成双的诡异的方法... 完全不明白为什么要这么 ...

  2. js toFixed() 四舍五入后并不是你期望的结果

    小学的时候学数学就知道有一种叫四舍五入的计算方式,就是对于小数位数的取舍,逢五进一,比如1.225 取两位小数后就是1.23.在前端开发中自己也少不了这样的计算,js也提供了相关的方法--toFixe ...

  3. 关于 js tofixed()保留小数位数问题

    保留位数必须是数字 const num = parseFloat ('123456.33').tofixed(2); !!!! 注意 现在的的 num 是 字符串类型, 如果给它加数字的话,就会报错 ...

  4. js toFixed()方法的坑

    javascript中toFixed使用的是银行家舍入规则. 银行家舍入:所谓银行家舍入法,其实质是一种四舍六入五取偶(又称四舍六入五留双)法. 简单来说就是:四舍六入五考虑,五后非零就进一,五后为零 ...

  5. js toFixed 方法重写,兼容负数

    Number.prototype.toFixed = function (s) { var that = this, changenum, index; if (this < 0) { that ...

  6. JS精度损失toFixed

    1234*0.01=12.3400000001 很明显后缀00001跟预期想要的不一致,起初面临这个问题我的处理方式是这样的: (1234*0.01).toString().substring(0,2 ...

  7. js小数计算小数点后显示多位小数(转)

    首先写一个demo 重现问题,我使用的是一个js在线测试环境[打开] 改写displaynum()函数 function displaynum(){var num = 22.77;alert(num ...

  8. JS浮点计算精度问题分析与解决

    问题描述 在JS计算四则运算时会遇到精度丢失的问题,会引起诸多问题,看看以下例子: 例如:在chrome控制台输入 0.1 + 0.7 输出结果是 0.7999999999999999 例如:0.1+ ...

  9. JS007. 深入探讨带浮点数运算丢失精度问题(二进制的浮点数存储方式)

    复现与概述 当JS在进行浮点数运算时可能产生丢失精度的情况: 从肉眼可见的程度上观察,发生精度丢失的浮点数是没有规律的,但该浮点数丢失精度的问题会100%复现.经查阅,这个问题要追溯至浮点数的二进制存 ...

随机推荐

  1. python yield && scrapy yield

    title: python yield && scrapy yield date: 2020-03-17 16:00:00 categories: python tags: 语法 yi ...

  2. TCP协议与UDP协议的区别以及与TCP/IP协议的联系

    先介绍下什么是TCP,什么是UDP. 1. 什么是TCP? TCP(Transmission Control Protocol,传输控制协议)是面向连接的.可靠的字节流服务,也就是说,在收发数据前,必 ...

  3. Polya定理应用实例

    关于Polya原理的应用经典实例: 问题:用两种颜色去染排成一个圈的6个棋子,如果通过旋转得到只算作一种.问有多少种染色状态. 解:先将棋子表上号: 1 6   2 5   3 4 那么把所有通过旋转 ...

  4. hdu5303贪心

    http://acm.hdu.edu.cn/showproblem.php?pid=5303 说一下题目大意.. 有一个长为L的环..你家在原点位置0,那么剩下L-1个点上种有一些树, 给你树的位置和 ...

  5. Android 神奇的SpannableStringBuilder

    一 无图言屌 先看看神奇的效果 仅用一个TextView实现 二 SpannableStringBuilder Google官方介绍 This is the class for text whose ...

  6. how to remove duplicates of an array by using js reduce function

    how to remove duplicates of an array by using js reduce function ??? arr = ["a", ["b& ...

  7. MDN & JavaScript 文档翻译状态

    MDN & JavaScript 文档翻译状态 https://developer.mozilla.org/zh-CN/docs/MDN/Doc_status/JavaScript refs ...

  8. skills share & free videos

    skills share & free videos 技术分享 & 免费视频 https://www.infoq.cn/video/list WebAssembly https://w ...

  9. Flutter & UI system & GUI & API & SDK

    Flutter & UI system & GUI & API & SDK https://book.flutterchina.club/chapter14/flutt ...

  10. Flutter 使用高德地图定位

    amap_location 包 获取debug SHA1 // 使用debug.keystore获取debug SHA1 C:\Users\ajanuw\.android>keytool -li ...