「乾貨」細說 Javascript 中的浮點數精度丟失問題(內附好課推薦)

前言

最近,朋友 L 問了我這樣一個問題:在 chrome 中的運算結果,爲什麼是這樣的?

0.55 * 100 // 55.00000000000001
0.56 * 100 // 56.00000000000001
0.57 * 100 // 56.99999999999999
0.58 * 100 // 57.99999999999999
0.59 * 100 // 59
0.60 * 100 // 60

雖然我告訴他說,這是由於浮點數精度問題導致的。但他還是不太明白,爲何有的結果輸出整數,有的是以 ...001 的小數結尾,有的卻是以 ...999 的小數結尾,跟預想中的有差異。

這其實牽涉到了計算機原理的知識,真要解釋清楚什麼是浮點數,恐怕得分好幾個章節了。想深入瞭解的同學,可以前往 這篇文章 細讀。今天我們僅討論浮點數運算結果的成因,以及如何實現我們期望的結果。

浮點數與 IEEE 754

在解釋什麼是浮點數之前,讓我們先從較爲簡單的小數點說起。

小數點,在數制中代表一種對齊方式。比如要比較 1000 和 200 哪個比較大,該怎麼做呢?必須把他們右對齊:

1000
 200

發現 1 比 0(前面補零)大,所以 1000 比較大。那麼如果要比較 1000 和 200.01 呢?這時候就不是右對齊了,而應該是以小數點對齊:

1000
 200.01

小數點的位置,在進製表示中是至關重要的。位置差一位整體就要差進制倍(十進制就是十倍)。在計算機中也是這樣,雖然計算機使用二進制,但在處理非整數時,也需要考慮小數點的位置問題。無法對齊小數點,就無法做加減法比較這樣的操作。

接下來的一個重要概念:在計算機中的小數有兩種,定點 和 浮點

定點的意思是,小數點固定在 32 位中的某個位置,前面的是整數,後面的是小數。小數點具體固定在哪裏,可以自己在程序中指定。定點數的優點是很簡單,大部分運算實現起來和整數一樣或者略有變化,但是缺點則是表示範圍太小,精度很差,不能充分運用存儲單元。

浮點數就是設計來克服這個缺點的,它相當於一個定點數加上一個階碼,階碼錶示將這個定點數的小數點移動若干位。由於可以用階碼移動小數點,因此稱爲浮點數。我們在寫程序時,用到小數的地方,用 float 類型表示,可以方便快速地對小數進行運算。

浮點數在 Javascript 中的存儲,與其他語言如 Java 和 Python 不同。所有數字(包括整數和小數)都只有一種類型 — Number。它的實現遵循 IEEE 754 標準,使用64位精度來表示浮點數。它是目前最廣泛使用的格式,該格式用 64 位二進制表示像下面這樣:

從上圖中可以看出,這 64 位分爲三個部分:

  • 符號位:1 位用於標誌位。用來表示一個數是正數還是負數
  • 指數位:11 位用於指數。這允許指數最大到 1024
  • 尾數位:剩下的 52 位代表的是尾數,超出的部分自動進一舍零

精度丟哪兒去了?

問:要把小數裝入計算機,總共分幾步?

答:3 步。
第一步:轉換成二進制
第二步:用二進制科學計算法表示
第三步:表示成 IEEE 754 形式
但第一步和第三步都有可能 丟失精度

十進制是給人看的。但在進行運算之前,必須先轉換爲計算機能處理的二進制。最後,當運算完畢後,再將結果轉換回十進制,繼續給人看。精度就丟失於這兩次轉換的過程中。

十進制轉二進制

接下來,就具體說說轉換的過程。來看一個簡單的例子:

如何將十進制的 168.45 轉換爲二進制?

讓我們拆爲兩個部分來解析:

1、整數部分。它的轉換方法是,除 2 取餘法。即每次將整數部分除以 2,餘數爲該位權上的數,而商繼續除以 2,餘數又爲上一個位權上的數,這個步驟一直持續下去,直到商爲 0 爲止,最後讀數時候,從最後一個餘數讀起,一直到最前面的一個餘數。

所以整數部分 168 的轉換過程如下:

  • 第一步,將 168 除以 2,商 84,餘數爲 0。
  • 第二步,將商 84 除以 2,商 42 餘數爲 0。
  • 第三步,將商 42 除以 2,商 21 餘數爲 0。
  • 第四步,將商 21 除以 2,商 10 餘數爲 1。
  • 第五步,將商 10 除以 2,商 5 餘數爲 0。
  • 第六步,將商 5 除以 2,商 2 餘數爲 1。
  • 第七步,將商 2 除以 2,商 1 餘數爲 0。
  • 第八步,將商 1 除以 2,商 0 餘數爲 1。
  • 第九步,讀數。因爲最後一位是經過多次除以 2 纔得到的,因此它是最高位。讀數的時候,從最後的餘數向前讀,即 10101000。

2、小數部分。它的轉換方法是,乘 2 取整法。即將小數部分乘以 2,然後取整數部分,剩下的小數部分繼續乘以 2,然後再取整數部分,剩下的小數部分又乘以 2,一直取到小數部分爲 0 爲止。如果永遠不能爲零,就同十進制數的四捨五入一樣,按照要求保留多少位小數時,就根據後面一位是 0 還是 1 進行取捨。如果是 0 就舍掉,如果是 1 則入一位,換句話說就是,0 舍 1 入。讀數的時候,要從前面的整數開始,讀到後面的整數。

所以小數部分 0.45 (保留到小數點第四位)的轉換過程如下:

  • 第一步,將 0.45 乘以 2,得 0.9,則整數部分爲 0,小數部分爲 0.9。
  • 第二步, 將小數部分 0.9 乘以 2,得 1.8,則整數部分爲 1,小數部分爲 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。因此,這個時候只好學習十進制的方法進行四捨五入了。但是二進制只有 0 和 1 兩個,於是就出現 0 舍 1 入的 “口訣” 了,這也是計算機在轉換中會產生誤差的根本原因。但是由於保留位數很多,精度很高,所以可以忽略不計。

這樣,我們就可以得出十進制數 168.45 轉換爲二進制的結果,約等於 10101000.0111。

二進制轉十進制

它的轉換方法相對簡單些,按權相加法。就是將二進制每位上的數乘以權,然後相加之和即是十進制數。其中有兩個注意點:要知道二進制每位的權值,要能求出每位的值。

所以,將剛纔的二進制 10101000.0111 轉換爲十進制,得到的結果就是 168.4375,再四捨五入一下,即 168.45。

解決方案

正如本文開頭所提到的,在 JavaScript 中進行浮點數的運算,會有不少奇葩的問題。在明白了產生問題的根本原因之後,當然是想辦法解決啦~

一個簡單粗暴的建議是,使用像 mathjs 這樣的庫。它的 API 也挺簡單的:

// load math.js
const math = require('mathjs')

// functions and constants
math.round(math.e, 3)             // 2.718
math.atan2(3, -3) / math.pi       // 0.75

// expressions
math.eval('12 / (2.3 + 0.7)')     // 4
math.eval('12.7 cm to inch')      // 5 inch
math.eval('sin(45 deg) ^ 2')      // 0.5

// chaining
math.chain(3)
    .add(4)
    .multiply(2)
    .done()  // 14

但如果在工程中,沒有太多需要進行運算的場景的話,就不建議這麼做了。畢竟引入三方庫也是有成本的,無論是學習 API,還是引入庫之後,帶來打包後的文件體積增積。

那麼,不引入庫該怎麼處理浮點數呢?

可以從需求出發。例如,本文開頭的例子。可以猜想到,需求可能是要把小數轉爲百分比,通常會保留兩位小數。而在一些對數字較爲敏感的業務場景中,可能並不希望對數字進行四捨五入,所以 toFixed() 方法就沒法用了。

一種思路是,將小數點像右多移動 n 位,取整後再除以 (10 * n)。比如這樣:

0.58 * 10000 / 100 // => 58

ok,搞定~

特別需要注意的是,在需要四捨五入的場景下,我們會習慣用到內置方法 toFixed(),但它存在一些問題:

1.35.toFixed(1) // 1.4 正確
1.335.toFixed(2) // 1.33  錯誤
1.3335.toFixed(3) // 1.333 錯誤
1.33335.toFixed(4) // 1.3334 正確
1.333335.toFixed(5)  // 1.33333 錯誤
1.3333335.toFixed(6) // 1.333333 錯誤

另外,它的返回結果類型是 String。不能直接拿來做運算,因爲計算機會認爲是 字符串拼接

總結

計算機在做運算的時候,會分三個步驟。其中,將十進制轉爲二進制,再將二進制轉爲十進制的時候,都會產生精度丟失。

使用庫,是最簡單粗暴的解決方案。但如果使用不頻繁,還是要根據需求,手動解決。在使用內置方法 toFixed() 的時候,要特別注意它的返回類型,不要直接拿來做運算。

好課推薦

近期公衆號後臺有多位讀者留言,金三銀四求職卻頻頻遇阻,詢問有沒有什麼體系性、針對性的內容可以看看。

最近我正好在 gitChat 上看到了,來自百度的大佬 LucasHC侯策) 的系列課程《前端開發 核心知識進階》,因爲拜讀過大佬寫的書 《React 狀態管理與同構實戰》,所以就買了這門課程。

這門課程 共 50 講,從 36 個熱門主題 切入講解高頻面試題,以及會深度剖析底層原理,乾貨滿滿,甚至還有不少大佬自己作爲 “BAT” 面試官多年的 “私房題”,以及面試時遇到的 “經典題”,非常實用了。

而且剛好現在在搞 特價 69 元,特價到5月7號結束,沒幾天了。掃描下圖二維碼就可以學習,需要的拿走不謝。

PS:歡迎關注我的公衆號 “超哥前端小棧”,交流更多的想法與技術。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章