toFixed四舍五入保留有误

https://www.cnblogs.com/zhangycun/p/7880580.html 

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

进一步观察:


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

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

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


如下代码:

letobj = {};

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

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

对于以下代码:

letsmi = 1;

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

如下源码的注释:

// Smi representsintegerNumbers that can be storedin31 bits.// Smis are immediatewhichmeans they are NOT allocatedinthe 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 standsforsmallinteger.

在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 buffer, int* length) {  int number_length = 0;  // We fill the digitsinreverse 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)的话就为1for(int i = 0; i < fractional_count; ++i) {if(fractionals == 0)break;  fractionals *= 5; // fractionals = fractionals * 10 / 2;  point--;  char digit = static_cast(fractionals >> point);  buffer[*length] ='0'+ digit;  (*length)++;  fractionals -= static_cast(digit) << point;}// If the first bit after the point issetwe 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 + 1e-14)._toFixed(n);};

就是把toFixed加一个很小的小数,这个小数经实验,1e-14就行了。这个可能会造成什么影响呢,会不会导致原本不该进位的进位了?加上一个14位的小数可能会导致13位进1。但是如果两个数相差1e-14的话,其实几乎可以认为这两个数是相等的,所以加上这个造成的影响几乎是可以忽略不计的,除非你要求的精度特别高。这个数和Number.EPSILON就差了一点点:


这样处理之后,toFixed就正常了:



本文通过V8源码,解释了数在内存里面是怎么存储的,并且对内存栈、堆存储做了一个普及,讨论了源码里面toFixed是怎么进行的,导致没有进位的原因是什么,怎么做一个修正。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容