為什麼(2.55).toFixed(1)等於2.5?

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

進一步觀察:

發現,並不是所有的都不正常,1.55的四捨五入還是對的,為什麼2.55、3.45就不對呢?

這個需要我們在源碼裡面找答案。

數字在V8裡面的存儲有兩種類型,一種是小整數用Smi,另一種是除了小整數外的所有數,用HeapNumber,Smi是直接放在棧上的,而HeapNumber是需要new申請內存的,放在堆裡面。我們可以簡單地畫一下堆和棧在內存的位置:

如下代碼:

let obj = {};n

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

棧和堆相比,棧的讀取效率要比堆的高,因為棧里變數可以通過內存偏差得到變數的位置,如用函數入口地址減掉一個變數佔用的空間(向低地址增長),就能得到那個變數在內存的內置,而堆需要通過指針定址,所以堆要比棧慢(不過棧的可用空間要比堆小很多)。因此局部變數如指針、數字等佔用空間較小的,通常是保存在棧里的。

對於以下代碼:

let smi = 1;n

smi是一個Number類型的數字。如果這種簡單的數字也要放在堆裡面,然後搞個指針指向它,那麼是划不來的,無論是在存儲空間或者讀取效率上。所以V8搞了一個叫Smi的類,這個類是不會被實例化的,它的指針地址就是它存儲的數字的值,而不是指向堆空間。因為指針本身就是一個整數,所以可以把它當成一個整數用,反過來,這個整數可以類型轉化為Smi的實例指針,就可以調Smi類定義的函數了,如獲取實際的整數值是多少。

如下源碼的注釋:

// Smi represents integer Numbers that can be stored in 31 bits.n// Smis are immediate which means they are NOT allocated in the heap.n// The this pointer has the following format: [31 bit signed int] 0n// For long smis it has the following format:n// [32 bit signed int] [31 bits zero padding] 0n// Smi stands for small integer.n

在一般系統上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;n static const int kExponentBits = 11;n

64位裡面,尾數佔了52位,而指數用了11位,還有一位是符號位。當這個雙精度的空間用於表示整數的時候,是用的52位尾數的空間,因為整數是能夠用二進位精確表示的,所以52位尾數再加上隱藏的整數位的1(這個1是怎麼來的可參考上一篇)能表示的最大值為2 ^ 53 - 1:

// ES6 section 20.1.2.6 Number.MAX_SAFE_INTEGERnconst double kMaxSafeInteger = 9007199254740991.0; // 2^53-1n

這是一個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位得到整數部分nuint64_t integrals = significand >> -exponent; // exponent = 1 - 52n// 尾數減掉整數部分得到小數部分nuint64_t fractionals = significand - (integrals << -exponent);n

接下來的問題——整數怎麼轉成字元串呢?源代碼如下所示:

static void FillDigits32(uint32_t number, Vector<char> buffer, int* length) {n int number_length = 0;n // We fill the digits in reverse order and exchange them afterwards.n while (number != 0) {n char digit = number % 10;n number /= 10;n buffer[(*length) + number_length] = 0 + digit;n number_length++;n }n // Exchange the digits.n int i = *length;n int j = *length + number_length - 1;n while (i < j) {n char tmp = buffer[i];n buffer[i] = buffer[j];n buffer[j] = tmp;n i++;n j--;n }n *length += number_length;n}n

就是把這個數不斷地模以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 = -51n// fractional_count表示需要保留的小數位,toFixed(1)的話就為1nfor (int i = 0; i < fractional_count; ++i) {n if (fractionals == 0)n break;n fractionals *= 5; // fractionals = fractionals * 10 / 2;n point--;n char digit = static_cast<char>(fractionals >> point);n buffer[*length] = 0 + digit;n (*length)++;n fractionals -= static_cast<uint64_t>(digit) << point;n}n// If the first bit after the point is set we have to round up.nif (((fractionals >> (point - 1)) & 1) == 1) {n RoundUp(buffer, length, decimal_point);n}n

如果是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) {n Number.prototype._toFixed = Number.prototype.toFixed;n}nNumber.prototype.toFixed = function(n) {n return (this + 3e-16)._toFixed(n);n};n

就是把toFixed加一個很小的小數,這個小數經實驗,1e-14就行了。這個可能會造成什麼影響呢,會不會導致原本不該進位的進位了?加上一個14位的小數可能會導致13位進1。但是如果兩個數相差1e-14的話,其實幾乎可以認為這兩個數是相等的,所以加上這個造成的影響幾乎是可以忽略不計的,除非你要求的精度特別高。這個數和Number.EPSILON就差了一點點:

這樣處理之後,toFixed就正常了:

本文通過V8源碼,解釋了數在內存裡面是怎麼存儲的,並且對內存棧、堆存儲做了一個普及,討論了源碼裡面toFixed是怎麼進行的,導致沒有進位的原因是什麼,怎麼做一個修正。


推薦閱讀:

2016前端開發技術巡禮
聊聊前端開發中的長列表
Vuejs 中使用 markdown
推薦閱讀-第9期

TAG:前端开发 | GoogleChrome | 源代码 |