JavaScript 浮點數之迷:0.1 + 0.2 爲何不等於 0.3?

「0.1 + 0.2 = ?」 這個問題,你要是問小學生,他也許會立馬告訴你 0.3。可是在計算機的世界裏就沒有這麼簡單了,作爲一名程序開發者在你面試時若是有人這樣問你,當心陷阱嘍!html

你可能在哪裏見過 「0.1 + 0.2 = 0.30000000000000004」 可是知道這背後真正的原理嗎?是隻有 JavaScript 中存在嗎?帶着這些疑問本文將重點梳理這背後的原理及浮點數在計算機中的存儲機制。java

做者簡介:五月君,Nodejs Developer,慕課網認證做者,熱愛技術、喜歡分享的 90 後青年,歡迎關注 Nodejs技術棧 和 Github 開源項目 www.nodejs.rednode

經過本文你能學到什麼?

  • 浮點數先修知識,更好的幫你理解本文知識
  • IEEE 754 標準是什麼?
  • 0.1 在 IEEE 754 標準中是如何存儲的?
  • 0.1 + 0.2 等於多少?
  • 只有 JavaScript 中存在嗎?

先修知識

如下是一些基礎的,可能被你所忽略的知識,瞭解它頗有用,由於這些基礎知識在咱們的下文講解中都會應用到,若是你已掌握了它,能夠跳過本節。python

1. 計算機的內部是如何存儲的?一個浮點數 float a = 1 會存儲成 1.0 嗎?git

計算機內部都是採用二進制進行表示,即 0 1 編碼組成。在計算機中是沒有 1.0 的,它只認 0 1 編碼。github

2. 1bit 能夠存儲多少個整數?8bit 能夠存儲多少個整數?面試

N 個 bit 能夠存儲的整數是 2 的 N 次方個。8bit 爲 2 的 8 次方(2^{8}=256)。bash

3. 瞭解下科學計數法,下文講解會用到ui

在平常生活中遇到一個比較的大的數字,例如全國總人口數、每秒光速等,在物理上用這些大數表達很不方便,一般能夠採用科學計數法表達。編碼

如下爲 10 進制科學計數法表達式,底數爲 10 ,其中 1≤|a|<10,n 爲整數

a*10^n

例如,0.1 的科學計數法表示爲 0.1 = 1 * 10^{-1}。(一個數的 -1 次方等於該數的倒數,例如 10^{-1} = \frac{10}{1}

在 IEEE 754 標準中也相似,只不過它是以一個二進制數來表示,底數爲 2,如下爲 0.1 的二進制表達式:

1.10011001100110011(0011 無限循環) *  2^{-4}

4. 十進制小數如何轉二進制?

十進制小數轉二進制,小數部分,乘 2 取整數,若乘以後的小數部分不爲 0,繼續乘以 2 直到小數部分爲 0 ,將取出的整數正向排序。

例如: 0.1 轉二進制

0.1 * 2 = 0.2 --------------- 取整數 0,小數 0.2
0.2 * 2 = 0.4 --------------- 取整數 0,小數 0.4
0.4 * 2 = 0.8 --------------- 取整數 0,小數 0.8
0.8 * 2 = 1.6 --------------- 取整數 1,小數 0.6
0.6 * 2 = 1.2 --------------- 取整數 1,小數 0.2
0.2 * 2 = 0.4 --------------- 取整數 0,小數 0.4
0.4 * 2 = 0.8 --------------- 取整數 0,小數 0.8
0.8 * 2 = 1.6 --------------- 取整數 1,小數 0.6
0.6 * 2 = 1.2 --------------- 取整數 1,小數 0.2
...
複製代碼

最終 0.1 的二進制表示爲 0.00110011... 後面將會 0011 無限循環,所以二進制沒法精確的保存相似 0.1 這樣的小數。那這樣無限循環也不是辦法,又該保存多少位呢?也就有了咱們接下來要重點講解的 IEEE 754 標準。

IEEE 754

IEEE 754 是 IEEE 二進制浮點數算術標準的簡稱,在這以前各家計算機公司的各型號計算機,有着千差萬別的浮點數表示方式,這對數據交換、計算機協同工做形成了極大不便,該標準的出現則解決了這一亂象,目前已成爲業界通用的浮點數運算標準。

IEEE 754 經常使用的兩種浮點數值的表示方式爲:單精確度(32位)、雙精確度(64位)。例如, C 語言中的 float 一般是指 IEEE 單精確度,而 double 是指雙精確度。

雙精確度(64位)

這裏重點講解下雙精確度(64位)(JS 中使用),單精確度(32 位)同理。

在 JavaScript 中不論小數仍是整數只有一種數據類型表示,這就是 Number 類型,其遵循 IEEE 754 標準,使用雙精度浮點數(double)64 位(8 字節)來存儲一個浮點數(因此在 JS 中 1 === 1.0)。其中可以真正決定數字精度的是尾部,即 2^{53-1}

64Bits 分爲如下 3 個部分:

  • sign bit(S,符號):用來表示正負號,0 爲 正 1 爲 負(1 bit)
  • exponent(E,指數):用來表示次方數(11 bits)
  • mantissa(M,尾數):用來表示精確度 1 <= M < 2(53 bits)

二進制數公式 V

根據 IEEE 754 標準,任意二進制數 V 均可用以下公式表示:

V = (-1)^s * M * 2^{E}

符號 S

符號位的做用是什麼?你可能會有此疑惑,在計算機中一切萬物都以二進制表示,那麼二進制中又以 0 1 存儲,你可能想用負號(-)表示負數,對不起這是不支持的,爲了表示負數一般把最高位看成符號位來表示,這個符號位就表示了正負數,0 表示正數(+),1 表示負數(-)。

順便拋出幾個問題

1. 計算機的世界中是否有減法?1 - 1 是如何實現的?

2. 十進制數 1 的二進制爲 0000 0001,-1 對應的二進制是什麼?用 1000 0001 表示 -1 對嗎?

尾數 M

IEEE 754 規定,在計算機內部保存 M 時,默認這個數的第一位老是 1,所以能夠被捨去,只保存後面部分,這樣能夠節省 1 位有效數字,對於雙精度 64 位浮點數,M 爲 52 位,將第一位的 1 捨去,能夠保存的有效數字爲 52 + 1 = 53 位。

在雙精確度浮點數下二進制數公式 V 演變以下所示:

V = (-1)^s * M + 1 * 2^{E}

指數 E

E 爲一個無符號整數,在雙精度浮點數中 E 爲 11 位,取值範圍爲 2^{11} = 2048,即表示的範圍爲 0 ~ 2047。

中間值: 因爲科學計數法中的 E 是能夠出現負數的,IEEE 754 標準規定指數偏移值的固定值2^{e-1}-1,以雙精度浮點數爲例:2^{11-1}-1=1023,這個固定值也能夠理解爲中間值。同理單精度浮點數爲 2^{8-1}-1=127

正負範圍: 雙精確度 64 位中間值爲 1023,負數爲 [0, 1022] 正數爲 [1024, 2047]。

雙精確度浮點數下二進制數公式 V 最終演變以下所示:

V = (-1)^s * M + 1 * 2^{E + 1023}

0.1 在 IEEE 754 標準中是如何存儲的?

1. 「0.1」 轉爲二進制

不知道怎麼轉換的,參考上面 先修知識十進制小數轉二進制

0.000110011001100110011(0011) // 0011 將會無限循環
複製代碼

2. 二進制浮點數的科學計數法表示

任何一個數均可以用科學計數法表示,0.1 的二進制科學計數法表示以下所示:

1.10011001100110011(0011 無限循環) *  2^{-4}

以上結果相似於十進制科學計數法表示:

0.0001234567 = 1.234567 * 10^{-4}

3. IEEE 754 存儲

0.1 的二進制表示以下所示:

1.1001100110011001100110011001100110011001100110011001*2^{-4}

3.1 符號位

因爲 0.1 爲整數,因此符號位 S = 0

3.2 指數位

E = -4,實際存儲爲 -4 + 1023 = 1019,二進制爲 1111111011,E 爲 11 位,最終爲 01111111011

3.3 尾數位

在 IEEE 754 中,循環位就不能在無限循環下去了,在雙精確度 64 位下最多存儲的有效整數位數爲 52 位,會採用 就近舍入(round to nearest)模式(進一舍零) 進行存儲

11001100110011001100110011001100110011001100110011001 // M 捨去首位的 1,獲得以下
1001100110011001100110011001100110011001100110011001 // 0 舍 1 入,獲得以下
1001100110011001100110011001100110011001100110011010 // 最終存儲
複製代碼

3.4 最終存儲結果

0	01111111011	1001100110011001100110011001100110011001100110011010
複製代碼

binaryconvert.com/convert_dou…

0.1 + 0.2 等於多少?

上面咱們講解了浮點數 0.1 採用 IEEE 754 標準的存儲過程,0.2 也同理,能夠本身推理下,0.一、0.2 對應的二進制分別以下所示:

S  E            M
0  01111111011  1001100110011001100110011001100110011001100110011010 // 0.1
0  01111111100  1001100110011001100110011001100110011001100110011010 // 0.2
複製代碼

浮點數運算三步驟

  • 對階
  • 求和
  • 規格化

對階

浮點數加減首先要判斷兩數的指數位是否相同(小數點位置是否對齊),若兩數指數位不一樣,須要對階保證指數位相同。

對階時遵照小階向大階看齊原則,尾數向右移位,每移動一位,指數位加 1 直到指數位相同,即完成對階。

本示例,0.1 的階碼爲 -4 小於 0.2 的階碼 -3,故對 0.1 作移碼操做

// 0.1 移動以前
0  01111111011  1001100110011001100110011001100110011001100110011010 

// 0.1 右移 1 位以後尾數最高位空出一位,(0 舍 1 入,此處捨去末尾 0)
0  01111111100   100110011001100110011001100110011001100110011001101(0) 

// 0.1 右移 1 位完成
0  01111111100  1100110011001100110011001100110011001100110011001101
複製代碼

尾數右移 1 位以後最高位空出來了,如何填補呢?涉及兩個概念:

  • 邏輯右移:最高位永遠補 0
  • 算術右移:不改變最高位值,是 1 補 1,是 0 補 0,尾數部分咱們是有隱藏掉最高位是 1 的,不明白的再看看上面 3.3 尾數位 有講解捨去 M 位 1。

尾數求和

兩個尾數直接求和

0  01111111100   1100110011001100110011001100110011001100110011001101 // 0.1 
+ 0  01111111100   1001100110011001100110011001100110011001100110011010 // 0.2
= 0  01111111100 100110011001100110011001100110011001100110011001100111 // 產生進位,待處理
複製代碼

或者如下方式:

0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
 10.0110011001100110011001100110011001100110011001100111
複製代碼

規格化和舍入

因爲產生進位,階碼須要 + 1,對應的十進制爲 1021,此時階碼爲 1021 - 1023(64 位中間值)= -2,此時符號位、指數位以下所示:

S  E
= 0  01111111101
複製代碼

尾部進位 2 位,去除最高位默認的 1,因最低位爲 1 需進行舍入操做(在二進制中是以 0 結尾的),舍入的方法就是在最低有效位上加 1,若爲 0 則直接捨去,若爲 1 繼續加 1

100110011001100110011001100110011001100110011001100111 // + 1
=  00110011001100110011001100110011001100110011001101000 // 去除最高位默認的 1
=  00110011001100110011001100110011001100110011001101000 // 最後一位 0 捨去
=  0011001100110011001100110011001100110011001100110100  // 尾數最後結果      
複製代碼

IEEE 754 中最終存儲以下:

0  01111111101 0011001100110011001100110011001100110011001100110100
複製代碼

最高位爲 1,獲得的二進制數以下所示:

2^-2 * 1.0011001100110011001100110011001100110011001100110100
複製代碼

轉換爲十進制以下所示:

0.30000000000000004
複製代碼

只有 JavaScript 中存在嗎?

這顯然不是的,這在大多數語言中基本上都會存在此問題(大都是基於 IEEE 754 標準),讓咱們看下 0.1 + 0.2 在一些經常使用語言中的運算結果。

JavaScript

推薦一個用於任意精度十進制和非十進制算術的 JavaScript 庫 github.com/MikeMcl/big…

console.log(.1 + .2); // 0.30000000000000004

// bignumber.js 解決方案
const BigNumber = require('bignumber.js');
const x = new BigNumber(0.1);
const y = 0.2

console.log(parseFloat(x.plus(y)));
複製代碼

Python

Python2 的 print 語句會將 0.30000000000000004 轉換爲字符串並將其縮短爲 「0.3」,可使用 print(repr(.1 + .2)) 獲取所須要的浮點數運算結果。這一問題在 Python3 中已修復。

# Python2
print(.1 + .2) # 0.3
print(repr(.1 + .2)) # 0.30000000000000004

# Python3
print(.1 + .2) # 0.30000000000000004
複製代碼

Java

Java 中使用了 BigDecimal 類內置了對任意精度數字的支持。

System.out.println(.1 + .2); // 0.30000000000000004

System.out.println(.1F + .2F); // 0.3
複製代碼

總結

推算 0.1 + 0.2 爲何不等於 0.3 這個過程是乏味和有趣並存的,由於它很難理解,可是一旦你掌握了它,能讓你更深入的認識到其中的存儲、運算機制,從而理解結果爲何是 0.30000000000000004。

最後作個總結,因爲計算機底層存儲都是基於二進制的,須要事先由十進制轉換爲二進制存儲與運算,這整個轉換過程當中,相似於 0.一、0.2 這樣的數是無窮盡的,沒法用二進制數精確表示。JavaScript 採用的是 IEEE 754 雙精確度標準,可以有效存儲的位數爲 52 位,因此就須要作舍入操做,這無可避免的會引發精度丟失。另外咱們在 0.1 與 0.2 相加作對階、求和、舍入過程當中也會產生精度的丟失。

Reference

相關文章
相關標籤/搜索