上次遇到了一个奇怪的问题:JS的(2.55).toFixed(1)输出是2.5,而不是四舍五入的2.6,这是为何呢?git
进一步观察:sql
发现,并非全部的都不正常,1.55的四舍五入仍是对的,为何2.5五、3.45就不对呢?数组
这个须要咱们在源码里面找答案。bash
数字在V8里面的存储有两种类型,一种是小整数用Smi,另外一种是除了小整数外的全部数,用HeapNumber,Smi是直接放在栈上的,而HeapNumber是须要new申请内存的,放在堆里面。咱们能够简单地画一下堆和栈在内存的位置:函数
以下代码:ui
let obj = {};复制代码
这里定义了一个obj的变量,obj是一个指针,它是一个局部变量,是放在栈里面的。而大括号{}实例化了一个Object,这个Object须要占用的空间是在堆里申请的内存,obj指向了这个内存所在的位置。this
栈和堆相比,栈的读取效率要比堆的高,由于栈里变量能够经过内存误差获得变量的位置,如用函数入口地址减掉一个变量占用的空间(向低地址增加),就能获得那个变量在内存的内置,而堆须要经过指针寻址,因此堆要比栈慢(不过栈的可用空间要比堆小不少)。所以局部变量如指针、数字等占用空间较小的,一般是保存在栈里的。编码
对于如下代码:spa
let smi = 1;复制代码
smi是一个Number类型的数字。若是这种简单的数字也要放在堆里面,而后搞个指针指向它,那么是划不来的,不管是在存储空间或者读取效率上。因此V8搞了一个叫Smi的类,这个类是不会被实例化的,它的指针地址就是它存储的数字的值,而不是指向堆空间。由于指针自己就是一个整数,因此能够把它当成一个整数用,反过来,这个整数能够类型转化为Smi的实例指针,就能够调Smi类定义的函数了,如获取实际的整数值是多少。prototype
以下源码的注释:
// 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.复制代码
在通常系统上int为32位,使用前面的31位表示整数的值(包括正负符号),而若是是64位的话,使用前32位表示整数的值。因此32位的时候有31位来表示数据,再减去一个符号位,还剩30位,因此Smi最大整数为:
2 ^ 30 - 1 = 1073741823 = 10亿
大概为10亿。
到这里你可能会有一个问题,为何要搞这么麻烦,不直接用基础类型如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是怎么进行的,致使没有进位的缘由是什么,怎么作一个修正。