上次遇到了一个奇怪的问题: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. gitlab 备份与恢复

    1. gitlab 备份命令:# gitlab-rake gitlab:backup:create 1.1 查看备份文件(默认备份路径:/var/opt/gitlab/backups)# ls /va ...

  2. Appium 学习一:环境搭建问题

    1.安装Android-sdk http://tools.android-studio.org/index.php/sdk 问题1:下载 android-sdk_r24.4.1-windows.zip ...

  3. AngularJS学习笔记(二)

    一.AngularJS Select(选择框) 1.使用 ng-options 创建选择框 <div ng-app="myApp" ng-controller="m ...

  4. PTA 1067 Sort with Swap(0, i) (25 分)(思维)

    传送门:点我 Given any permutation of the numbers {0, 1, 2,..., N−1}, it is easy to sort them in increasin ...

  5. AltiumDesigner PCB中栅格与格点的切换

    PCB中通过快捷键Ctrl+G,进入设置界面. 在弹出的对话框中,在Display,Coarse选择Lines为栅格,Dots为格点,Do Not Draw为无任何显示.

  6. 114. Flatten Binary Tree to Linked List 把二叉树变成链表

    [抄题]: Given a binary tree, flatten it to a linked list in-place. For example, given the following tr ...

  7. Django实现微信消息推送

    一 所需准备条件 微信公众号的分类 微信消息推送 公众号 已认证公众号 服务号 已认证服务号 企业号 基于:微信认证服务号 主动推送微信消息.前提:关注服务号环境:沙箱环境 沙箱环境地址: https ...

  8. sql中with as测试实例

    一.使用场景 1.多处使用才有必要2.一方面减少代码数量便于理解维护3.一方面跟代码一样一次计算到处用 二.实例(本处示例仅为测试,实际用join比较好) 1.不使用with as 2.使用with ...

  9. 使用手势对UIImageView进行缩放、旋转和移动

    // 添加所有的手势 - (void) addGestureRecognizerToView:(UIView *)view { // 旋转手势 UIRotationGestureRecognizer  ...

  10. Linux 只列出目录的方法

    1. ls -d 2. find -type d -maxdepth 1 3. ls -F | grep "/$" 4. ls -l | grep "^d"