從標準原理出發理解JavaScript數值精度
學過前端的開發人員在專案開發的時候,都會遇到0.1+0.2!=0.3的詭異問題。按照常規的邏輯來思考,這肯定是不符合我們的數學規範。那麼JavaScript中為啥會出現這種基本運算錯誤呢,其中的原理又是什麼。這篇文章將從原理給大家梳理此問題的緣由
JavaScript數值問題
在進入原理解析之前,筆者先丟擲三個基本問題,大家可以先思考一下。
問題一:
JavaScript規範中的數量值如何計算,出現NaN的原因,以及NaN的數量值
The Number type has exactly 18437736874454810627 values…(為什麼是這個數) 複製程式碼
問題二:
Number.MAX_SAFE_INTEGER === 9007199254740991 //為什麼是這個數 Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2//true 複製程式碼
問題三:
0.1 + 0.2 != 0.3 //原因是什麼? 複製程式碼
計算機中的二進位制
接下來進入正文,學過計算機基礎的人都知道,計算機底層是通過二進位制來進行資料之間的互動的。其中我們應該要明白為什麼計算機通過二進位制來進行資料互動,以及二進位制是什麼
1.計算機為什麼要通過二進位制來進行資料互動?
在我們日常使用的電子計算機中,數位電路組成了我們計算機物理基礎構成,這些數位電路可以看成是一個個閘電路集合組成,閘電路的理論基礎是邏輯運算。那麼當我們的計算機的電路通電工作,每個輸出端就有了電壓。電壓的高低通過模數轉換即轉換成了二進位制:高電平是由1表示,低電平由0表示。
說得簡單點,就是計算機的基本執行是由電路支援的,電路容易識別高低電壓,即電路只要能識別低、高就可以表示“0”和“1”。
2.二進位制是什麼
二進位制就跟我們的十進位制一樣,十進位制是逢十進一,二進位制就是逢二進一。
比如001如果增加1的話,在十進位制中就是002,在二進位制中則變成了010,因為002的2需要進一位。
那麼我們平常在計算機中的計算都是十進位制的,所以計算機在處理我們的運算的時候,會把十進位制的數字轉化為二進位制的數字之後,再進行二進位制加法,得到的結果轉化為十進位制,從而呈現在我們的螢幕中。這些轉化都是通過計算機內部操作的,平常我們是看不到他們轉化的過程。那麼機智的你肯定就明白了0.1 + 0.2 != 0.3這個問題,肯定跟十進位制轉二進位制,然後二進位制轉回十進位制的處理( 精度丟失
)有關係。
計算機的十進位制運算
從上面可知,我們已經定位到了問題所在,不著急,我們先確定二進位制轉十進位制、十進位制轉二進位制怎麼實現,才能分析精度丟失的原因。
十進位制轉二進位制
十進位制整數轉二進位制
示例:將十進位制的21轉換為二進位制數。
方法:將整數除於2, 反向取餘數
21 / 2=10-- 1 ⬆ 10 / 2=5-- 0 ⬆ 5 / 2=2-- 1 ⬆ 2 / 2=1-- 0 ⬆ 1 / 2=0-- 1 ⬆ 複製程式碼
二進位制(反取餘數):10101
十進位制小數轉換為二進位制
示例:將0.125換算為二進位制
方法:將小數部分乘以2,然後取整數部分,至到小數部分為0截止。若小數部分一直都無法等於0,那麼就採用取捨。如果後面一位是0,那麼就捨去。如果後面為1,那麼就進一。 讀數要從前面的整數讀到後面的整數
0.125 * 2 = 0.25-- 0 ⬇ 0.25* 2 = 0.5-- 0 ⬇ 0.5* 2 = 1.0-- 1 ⬇ 複製程式碼
二進位制:0.001
二進位制轉化為十進位制
二進位制轉化為十進位制,整數部分和小數部分的方法都是相同的。
示例:將二進位制數101.101轉換為十進位制數
方法:將二進位制每位上的數乘以權,然後相加之和即是十進位制數
計算機中將十進位制轉化為二進位制之後,進行了二進位制的相加。
注意:在計算機的運算中, 只有加法運算 。如5 - 5會變成5 + (-5)
在二進位制的運算中,為了防止運算不正確,以及最高位溢位問題。引入了原碼、反碼、補碼等概念。由於篇幅有限,在這裡就不展開對原碼、反碼、補碼的概念,有興趣的讀者可以自行查閱資料。
JavaScript中的數值--浮點數IEEE 754
那麼講完基礎內容,迴歸到我們的JavaScript中來。眾所周知JavaScript僅有Number這個數值型別,而Number採用的是IEEE 754 64位雙精度浮點數編碼。所以在JavaScript中, 所有的數值都是通過浮點數來表示 ,那麼IEEE 754標準是怎麼樣的呢,在JavaScript中又是怎麼約定Number值的。
IEEE 754的標準,個人理解就是通過科學計數法的方式控制小數點的位置,來表示不同的數值。
在wiki中,IEEE 754規定了四種表示浮點數值的方式:單精確度(32位)、雙精確度(64位)、延伸單精確度(43位元以上,很少使用)與延伸雙精確度(79位元以上,通常以80位實現),通常我們只會使用到單精確度(32位)、雙精確度(64位)
單精確度(32位)表示

雙精確度(64位)表示

從上面兩張圖,可以看出數值用IEEE 754標準表示時,被劃分為三個區段,有sign、exponent以及fraction。而理解這三個區段是學習IEEE 754標準的重點所在。那麼這三個區段分別表示什麼呢?不急,我們先了解一下經過IEEE 754標準之後,我們的二進位制的數值應該怎麼表示,然後再來學習這三個定義。
在國際規定的IEEE 754的標準中,不管是32位單精確度,還是64位雙精確度,任何一個 二進位制浮點數V 都可以有如下圖的表示,圖源自於阮一峰老師部落格

其中:
- (-1)^s 表示符號位,當s=0,V為正數;當s=1,V為負數。
- M表示有效數字,大於等於1,小於2。
- 2^E中的E表示指數位。
舉個例子,十進位制的7轉二進位制就是111,就相當於1.11*2^2,那麼此時s = 0,M = 1.11,E = 2;
如果十進位制的-7轉二進位制就是-111,就相當於-1.11*2^2,那麼此時s = 1,M = 1.11, E = 2;
其實,在公式中的s就相當於sign(符號位)判斷數值正負,M就相當於fration(有效數字),E就相當於exponent(指數)。
在32位單精確度下,符號位sign是最高位,佔一位大小,接著的8位是指數E,剩下的23位為有效數字M。
在64位單精確度下,符號位sign是最高位,佔一位大小,接著的11位是指數E,剩下的52位為有效數字M。
那麼我們接下來討論,指數E以及有效數字M是怎麼定義的。前面提及了有效數字M是大於等於1,小於2的。其實這很好理解,在我們的科學計數法中,有效數字開頭通常都是1,即1.XXXX的形式,其中XXXX就是小數部分,那麼在32位精確度中,有效數字M佔了23位,那麼是否XXXX只能佔22位呢,其中1位留給整數部分1。聰明的標準制定者們為了使32位精確度能夠表示更多的有效數字,決定整數部分的1不佔有效數字M的一位。於是XXXX能夠佔23位,這樣等到讀取的時候,再把第一位的1加上去,那麼就等於可以儲存24位有效數字了。 IEEE 754規定,在計算機內部儲存M時,預設這個數的第一位總是1,因此可以被捨去,只儲存後面的xxxxxx部分。 同樣, 64位精確度的M也相當於可以儲存53位有效數字
那麼指數E就比較複雜了,由於E是一個無符號的整數,那麼在32位精確度中(E佔8位),可以表示的取值範圍為0 ~ 255,在64位精確度中可以表示的取值範圍為0 ~ 2047。但是其實我們的科學計數法指數部分是可以出現負數的。那麼如何使用E來表示負數呢,可以將E取一箇中間值,左邊的就為負指數,右邊就為正指數了。於是 IEEE 754就規定,E的真實值(即在exponent中表示的值)必須再減去一箇中間數,32位精確度中的中間數是127,64位精確度中的中間數是1023; ,看到加粗的字就可以明白,指數範圍其實表示的是-127~128; 這樣我們就可以在32位精確度中表示從

例子:十進位制的7轉二進位制就是111,就相當於1.11*2^2,此時E = 2,那麼這時候的E其實已經減了中間值了,所以E的真實值為2 + 127 = 129,二進位制為10000001;
同時指數E還可以 根據規定分為三種情況討論 (以32位精確度作為討論)
1. E不全為0或不全為1 這個階段就是正常的浮點數表示,通過計算E然後減去127即為指數
2. E全為0 浮點數的指數E等於0-127 = -127,當指數為-127時, 有效數字M不再加上第一位的1 ,而是還原為0.xxxxxx的小數。這樣做是為了表示±0,以及接近於0的很小的數字
3. E全為1 此時如果有效數字M全為0,那麼就表示+∞或者-∞,取決於第一位符號位。但是如果有效數字M不全為0,則表示這不是一個數( NaN )
回到JavaScript
在上面的討論中,我們很少提及JavaScript,似乎跟我們的文章主題不搭邊,但是在瞭解了上述的原理之後,你將會對JavaScript中的數字的理解有質的飛躍。
接下來的內容將會帶領大家一步一步解決上面提出的這些疑問:
1.JavaScript規範中的數值量,為什麼是這個數?
首先需明白在JavaScript中的數字是64-bits的雙精度,所以有2^64種可能性,在上述中提到,當E全為1的時候,表示的要麼為無窮數,要麼為NaN。所以不是數值的可能為2^53種,同時JavaScript中把+∞和-∞、NaN定義為數值。所以JavaScript數值的總量為

同時我們也可以直接推算出JavaScript中NaN的具體數量有多少,因為上述中NaN的定義為在E全為1的情況下,如果有效數字M不全為0,則表示這不是一個數。即排除掉有效數字M全為0的情況就行(+∞、-∞)

2.JavaScript中的最大安全整數值為什麼為9007199254740991
上述提及,有效數字有53個(包括最前面一位的1.xxxx中的1),如果超出了小數點後面52位以外的話,就遵從二進位制舍0進1的原則,那麼這樣的數字就不是一一對應的,會有誤差,精度就丟失了。也就不是安全數了。所以JavaScript中的最大安全整數值為

3. 0.1 + 0.2 != 0.3 ?
這個問題也許是大家最關心的問題,也是最經典的JavaScript面試問題。不過學習了上面的知識之後,大家已經明白了問題產生的原因( 精度丟失 ),那麼具體是如何丟失的呢?
首先,0.1 + 0.2這個運算是十進位制的加法,上述提及,計算機處理十進位制的加法其實是先將十進位制轉化為二進位制之後再運算處理。那麼我們需要計算出0.1的二進位制、0.2的二進位制以及0.3的二進位制來進行對比校驗。
根據上述的計算方法,我們很容易得出0.1的二進位制是無限迴圈的,即
0.1D = (-1)^0 * 1.1001..(1001迴圈13次)1010B * 2^-4 0.2D = (-1)^0 * 1.1001..(1001迴圈13次)1010B * 2^-3 0.3D = (-1)^0 * 1.0011..(0011迴圈13次)0011B * 2^-2 複製程式碼
可以看出,當0.1,0.2轉化為二進位制的時候,有效數字都是52位(4 * 13 + 4),因為在64位精確度中,只能保持52位有效數字,如果沒有52位有效數字的約束,其實在第53位中,0.1轉二進位制本來是1,但是有了52位約束之後,根據二進位制的取捨 ,最後五位數就從1001 1(第53位) 變成了 1010。
我們可以手動計算一下0.1的二進位制加上0.2的二進位制

那麼相加結果轉換為十進位制其實等於0.30000000000000004,這就是為什麼0.1 + 0.2 != 0.3的原因了。
結尾
從一個詭異的問題出發,去理解為什麼會出現這樣的現象,以及裡面的原理,想必這就是一個程式設計師的執著,實事求是,刨根問底,就會得到更多的收穫。相信大家看完文章之後,對JavaScript的數值也會有更深的理解。