https://www.cnblogs.com/zhangycun/p/7880580.html?
上次遇到了一個奇怪的問題:JS的(2.55).toFixed(1)輸出是2.5,而不是四舍五入的2.6,這是為什么呢?
進一步觀察:
發(fā)現(xiàn),并不是所有的都不正常,1.55的四舍五入還是對的,為什么2.55、3.45就不對呢?
這個需要我們在源碼里面找答案。
數(shù)字在V8里面的存儲有兩種類型,一種是小整數(shù)用Smi,另一種是除了小整數(shù)外的所有數(shù),用HeapNumber,Smi是直接放在棧上的,而HeapNumber是需要new申請內(nèi)存的,放在堆里面。我們可以簡單地畫一下堆和棧在內(nèi)存的位置:
如下代碼:
letobj = {};
這里定義了一個obj的變量,obj是一個指針,它是一個局部變量,是放在棧里面的。而大括號{}實例化了一個Object,這個Object需要占用的空間是在堆里申請的內(nèi)存,obj指向了這個內(nèi)存所在的位置。
棧和堆相比,棧的讀取效率要比堆的高,因為棧里變量可以通過內(nèi)存偏差得到變量的位置,如用函數(shù)入口地址減掉一個變量占用的空間(向低地址增長),就能得到那個變量在內(nèi)存的內(nèi)置,而堆需要通過指針尋址,所以堆要比棧慢(不過棧的可用空間要比堆小很多)。因此局部變量如指針、數(shù)字等占用空間較小的,通常是保存在棧里的。
對于以下代碼:
letsmi = 1;
smi是一個Number類型的數(shù)字。如果這種簡單的數(shù)字也要放在堆里面,然后搞個指針指向它,那么是劃不來的,無論是在存儲空間或者讀取效率上。所以V8搞了一個叫Smi的類,這個類是不會被實例化的,它的指針地址就是它存儲的數(shù)字的值,而不是指向堆空間。因為指針本身就是一個整數(shù),所以可以把它當成一個整數(shù)用,反過來,這個整數(shù)可以類型轉(zhuǎn)化為Smi的實例指針,就可以調(diào)Smi類定義的函數(shù)了,如獲取實際的整數(shù)值是多少。
如下源碼的注釋:
// 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位系統(tǒng)上使用一個int整型是32位,使用前面的31位表示整數(shù)的值(包括正負符號),而在64位系統(tǒng)上int整型是64位,使用前32位表示整數(shù)的值,所以在64位系統(tǒng)上減去一個符號位,還剩31位,所以Smi最大整數(shù)為:
2 ^ 31 - 1 = 2147483647 = 21億
大概為21億,而32位系統(tǒng)少一半。
到這里你可能會有一個問題,為什么要搞這么麻煩,不直接用基礎類型如int整型來存就好了,還要搞一個Smi的類呢?這可能是因為V8里面對JS數(shù)據(jù)的表示都是繼承于根類Object的(注意這里的Object不是JS的Object,JS的Object對應的是V8的JSObject),這樣可以做一些通用的處理。所以小整數(shù)也要搞一個類,但是又不能實例化,所以就用了這樣的方法——使用指針存儲值。
大于21億和小數(shù)是使用HeapNumber存儲的,和JSObject一樣,數(shù)據(jù)是存在堆里面的,HeapNumber存儲的內(nèi)容是一個雙精度浮點數(shù),即8個字節(jié) = 2 words = 64位。關于雙精度浮點數(shù)的存儲結(jié)構(gòu)我已經(jīng)在《為什么0.1 + 0.2不等于0.3?》做了很詳細的介紹。這里可以再簡單地提一下,如源碼的定義:
? static const int kMantissaBits = 52;
? static const int kExponentBits = 11;
64位里面,尾數(shù)占了52位,而指數(shù)用了11位,還有一位是符號位。當這個雙精度的空間用于表示整數(shù)的時候,是用的52位尾數(shù)的空間,因為整數(shù)是能夠用二進制精確表示的,所以52位尾數(shù)再加上隱藏的整數(shù)位的1(這個1是怎么來的可參考上一篇)能表示的最大值為2 ^ 53 - 1:
// ES6 section 20.1.2.6 Number.MAX_SAFE_INTEGER
const double kMaxSafeInteger = 9007199254740991.0;? // 2^53-1
這是一個16位的整數(shù),進而可以知道雙精度浮點數(shù)的精確位數(shù)是15位,并且有90%的概率可以認為第16位是準確的。
這樣我們就知道了,數(shù)在V8里面是怎么存儲的。對于2.55使用的是雙精度浮點數(shù),把2.55的64位存儲打印出來是這樣的:
對于(2.55).toFixed(1),源碼里面是這么進行的,首先把整數(shù)位2取出來,轉(zhuǎn)成字符串,然后再把小數(shù)位取出來,根據(jù)參數(shù)指定的位數(shù)進行舍入,中間再拼個小數(shù)點,就得到了四舍五入的字符串結(jié)果。
整數(shù)部分怎么取呢?2.55的的尾數(shù)部分(加上隱藏的1)為數(shù)a:
1.01000110011...
它的指數(shù)位是1,所以把這個數(shù)左移一位就得到數(shù)b:
10.1000110011...
a原本是52位,左移1位就變成了53位的數(shù),再把b右移52 - 1 = 51位就得到整數(shù)部分為二進制的10即十進制的2。再用b減掉10左移51位的值,就得到了小數(shù)部分。這個實際的計算過程是這樣的:
// 尾數(shù)右移51位得到整數(shù)部分
uint64_t integrals = significand >> -exponent; // exponent = 1 - 52
// 尾數(shù)減掉整數(shù)部分得到小數(shù)部分
uint64_t fractionals = significand - (integrals << -exponent);
接下來的問題——整數(shù)怎么轉(zhuǎn)成字符串呢?源代碼如下所示:
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;}
就是把這個數(shù)不斷地模以10,就得到個位數(shù)digit,digit加上數(shù)字0的ascii編碼就得到個位數(shù)的ascii碼,它是一個char型的。在C/C++/Java/Mysql里面char是使用單引號表示的一種變量,用一個字節(jié)表示ascii符號,存儲的實際值是它的ascii編碼,所以可以和整數(shù)相互轉(zhuǎn)換,如'0' + 1就得到'1'。每得到一個個位數(shù),就除以10,相當十進制里面右移一位,然后繼續(xù)處理下一個個位數(shù),不斷地把它放到char數(shù)組里面(注意C++里面的整型相除是會把小數(shù)舍去的,不會像JS那樣)。
最后再把這個數(shù)組反轉(zhuǎn)一下,因為上面處理后,個位數(shù)跑到前面去了。
小數(shù)部分是怎么轉(zhuǎn)的呢?如下代碼所示:
int point = -exponent; // exponent = -51// fractional_count表示需要保留的小數(shù)位,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位小數(shù)轉(zhuǎn)成字符串,然后再看n + 1位的值是需要進一位。
在把前n位小數(shù)轉(zhuǎn)成字符串的時候,是先把小數(shù)位乘以10,然后再右移50 + 1 = 51位,就得到第1位小數(shù)(代碼里面是乘以5,主要是為了避免溢出)。小數(shù)位乘以10之后,第1位小數(shù)就跑到整數(shù)位了,然后再右移原本的尾數(shù)的51位就把小數(shù)位給丟掉了,因為剩下的51位肯定是小數(shù)部分了,所以就得到了第一位小數(shù)。然后再減掉整數(shù)部分就得到去掉1位小數(shù)后剩下的小數(shù)部分,由于這里只循環(huán)了一次所以就跳出循環(huán)了。
接著判斷是否需要四舍五入,它判斷的條件是剩下的尾數(shù)的第1位是否為1,如果是的話就進1,否則就不處理。上面減掉第1位小數(shù)后還剩下0.05:
實際上存儲的值并不是0.05,而是比0.05要小一點:
由于2.55不是精確表示的,而2.5是可以精確表示的,所以2.55 - 2.5就可以得到0.05存儲的值。可以看到確實是比0.05小。
按照源碼的判斷,如果剩下的尾數(shù)第1位不是1就不進位,由于剩下的尾數(shù)第1位是0,所以不進位,因此就導致了(2.55).toFixed(1)輸入結(jié)果是2.5.
根本原因在于2.55的存儲要比實際存儲小一點,導致0.05的第1位尾數(shù)不是1,所以就被舍掉了。
那怎么辦呢?難道不能用toFixed了么?
知道原因后,我們可以做一個修正:
if(!Number.prototype._toFixed) {? ? Number.prototype._toFixed = Number.prototype.toFixed;}Number.prototype.toFixed =function(n) {return(this + 1e-14)._toFixed(n);};
就是把toFixed加一個很小的小數(shù),這個小數(shù)經(jīng)實驗,1e-14就行了。這個可能會造成什么影響呢,會不會導致原本不該進位的進位了?加上一個14位的小數(shù)可能會導致13位進1。但是如果兩個數(shù)相差1e-14的話,其實幾乎可以認為這兩個數(shù)是相等的,所以加上這個造成的影響幾乎是可以忽略不計的,除非你要求的精度特別高。這個數(shù)和Number.EPSILON就差了一點點:
這樣處理之后,toFixed就正常了:
本文通過V8源碼,解釋了數(shù)在內(nèi)存里面是怎么存儲的,并且對內(nèi)存棧、堆存儲做了一個普及,討論了源碼里面toFixed是怎么進行的,導致沒有進位的原因是什么,怎么做一個修正。