上次遇到了一個奇怪的問題:JS的(2.55).toFixed(1)輸出是2.5,而不是四捨五入的2.6,這是爲何呢?git

進一步觀察:sql

發現,並非全部的都不正常,1.55的四捨五入仍是對的,爲何2.5五、3.45就不對呢?數組

這個須要咱們在源碼裏面找答案。bash

數字在V8裏面的存儲有兩種類型,一種是小整數用Smi,另外一種是除了小整數外的全部數,用HeapNumber,Smi是直接放在棧上的,而HeapNumber是須要new申請內存的,放在堆裏面。咱們能夠簡單地畫一下堆和棧在內存的位置:函數

以下代碼:post

let obj = {};

這裏定義了一個obj的變量,obj是一個指針,它是一個局部變量,是放在棧裏面的。而大括號{}實例化了一個Object,這個Object須要佔用的空間是在堆裏申請的內存,obj指向了這個內存所在的位置。ui

棧和堆相比,棧的讀取效率要比堆的高,由於棧裏變量能夠經過內存誤差獲得變量的位置,如用函數入口地址減掉一個變量佔用的空間(向低地址增加),就能獲得那個變量在內存的內置,而堆須要經過指針尋址,因此堆要比棧慢(不過棧的可用空間要比堆小不少)。所以局部變量如指針、數字等佔用空間較小的,一般是保存在棧裏的。this

對於如下代碼:編碼

let smi = 1;

smi是一個Number類型的數字。若是這種簡單的數字也要放在堆裏面,而後搞個指針指向它,那麼是划不來的,不管是在存儲空間或者讀取效率上。因此V8搞了一個叫Smi的類,這個類是不會被實例化的,它的指針地址就是它存儲的數字的值,而不是指向堆空間。由於指針自己就是一個整數,因此能夠把它當成一個整數用,反過來,這個整數能夠類型轉化爲Smi的實例指針,就能夠調Smi類定義的函數了,如獲取實際的整數值是多少。spa

以下源碼的註釋:

// 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 + 3e-16)._toFixed(n); };

就是把toFixed加一個很小的小數,這個小數經實驗,只要3e-16就好了。這個可能會形成什麼影響呢,會不會致使本來不應進位的進位了?咱們剛剛提到雙精度的精度是15位,第16位起是不可靠的,加上一個16位的小數可能會致使15位進1。可是若是兩個數相差3e-16的話,其實幾乎能夠認爲這兩個數是相等的,因此加上這個形成的影響是能夠忽略不計的。這個數和Number.EPSILON就差了一點點: