上次遇到了一个奇怪的问题: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 + 1e-14)._toFixed(n);
};

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

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

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

为什么(2.55).toFixed(1)等于2.5?的更多相关文章

  1. (xxx.55).toFixed(1) 无法正确进位处理

    参考:https://juejin.im/post/5a11a9fef265da43284073b4?utm_medium=fe&utm_source=weixinqun 根本原因在于2.55 ...

  2. js toFixed

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

  3. JS中toFixed()方法的问题及解决方案

    最近发现JS当中toFixed()方法存在一些问题,采用原生的Number对象的原型对象上的toFixed()方法时,规则并不是所谓的“四舍五入”或者是“四舍六入五成双”,所谓“四舍六入五成双”,在百 ...

  4. js精度误差

    之前虽然有看到过 js 精度相关的文章.但也都没有“印象深刻” ,但是今天"有幸"遇到了. 做一个项目,进行页面调试的时候, 当数量增加到3时总价格变得好长好长 立马在控制台验证了 ...

  5. JS面试题-<变量和类型>-JavaScript的数据类型

    前言 整理以前的面试题,发现问js数据类型的频率挺高的,回忆当初自己的答案,就是简简单单的把几个类型名称罗列了出来,便没有了任何下文.其实这一个知识点下可以牵涉发散出很多的知识点,如果一个面试者只是罗 ...

  6. es和数据类型

    js=es+dom+bom,dom和bom前面已经讲完了 es是js的本体,是指数据类型,和对于数据的操作手段,他的版本更新得很快 这些功能不是html文件提供的,也不是浏览器提供的,即使脱离了dom ...

  7. 关于 JavaScript 的 精度丢失 与 近似舍入

    一.背景 最近做 dashborad 图表时,涉及计算小数且四舍五入精确到 N 位.后发现 js 算出来的结果跟我预想的不一样,看来这里面并不简单-- 二.JS 与 精度 1.精度处理 首先明确两点: ...

  8. JavaScript笔记基础篇(二)

    基础篇主要是总结一些工作中遇到的技术问题是如何解决的,应为本人属于刚入行阶段技术并非大神如果笔记中有哪些错误,或者自己的一些想法希望大家多多交流互相学习. 1.ToFixed()函数 今天在做Birt ...

  9. 5个缺失的 JavaScript 数字格式化函数

    /** 下面两个函数都能对浮点数进行四舍五入,保留小数点后两位 **/ function CurrencyFormatted(amount) { var i = parseFloat(amount); ...

随机推荐

  1. 微服务SpringCloud无法进行服务消费

    最近用SpringCloud做微服务,一直无法成功进行服务消费. 我使用的服务消费者是Feign,声明式调用服务提供者. 排查过程 1.检查服务提供者: (1)对提供的方法进行测试,确保提供的服务没有 ...

  2. java十进制转换成二进制数

    牢记这些呀,特别常用! 1.十进制转成二进制 String s = Integer.toBinaryString(n)  //将十进制数转成字符串,例如n=5 ,s = "101" ...

  3. etcd-v2第一集

    网站:https://github.com/coreos/etcd 一些观点:https://yq.aliyun.com/articles/11035 1.etcd是键值存储仓库,配置共享和服务发现2 ...

  4. CentOS 使用yum命令安装出现错误提示”could not retrieve mirrorlist http://mirrorlist.centos.org ***”

    执行yum命令时出现以上错误; 解决方法: vi /etc/sysconfig/network-scripts/ifcfg-eth0 这一段为你的网卡修改图中框框部分 然后重启 :reboot

  5. Monotonic Array LT896

    An array is monotonic if it is either monotone increasing or monotone decreasing. An array A is mono ...

  6. Array of Doubled Pairs LT954

    Given an array of integers A with even length, return true if and only if it is possible to reorder ...

  7. The current state of generics in Delphi( 转载)

    The current state of generics in Delphi   To avoid duplication of generated code, the compiler build ...

  8. shell脚本监控系统负载、CPU和内存使用情况

    hostname >>/home/vmuser/xunjian/xj.logdf -lh >>/home/vmuser/xunjian/xj.logtop -b -n 1 | ...

  9. CentOS7 启动中文输入法

    CentOS 系统中是带有中文输入法的(智能拼音),启动方式如下: Applications --> System Tools -->  Setting --> Region &am ...

  10. spring与junit整合测试

    1.导包4+2+aop+test 2.配置注解 3.测试