數據精度問題自查手冊

前言

在數據敏感的業務場景中,經常會碰到數據精度問題,尤爲在金額顯示、佔比統計等地方,該問題尤其顯著。因爲數據的每一位有效數字都包含真實的業務語義,一點點誤差甚至可能影響業務決策,這讓問題的嚴重性上升了幾個階梯。javascript

那,什麼是精度丟失?

一言以概之,凡是在運行過程當中,致使數值存在不可逆轉換時,就是精度丟失。前端

諸如:java

  • 人均交易額、佔比這類計算得出的除法得到的指標(分子/分母)時,若是盲目的直接從該結果去推算分子數值時,極可能就存在精度丟失
  • 浮點數計算結果,會出現很長尾的小數

這兩種廣義上來講都是精度丟失,但第一種狀況能夠經過更改技術方案等方式進行規避。更多時候,所謂的精度問題,單指第二類問題。而面對這類問題時,若是沒有掌握原理,每每會只知其一;不知其二,對結論印象不深,再次碰到問題只能一查再查。git

計算機原理真香

數值的精度問題,實際上是很是基礎的計算機原理知識。一般,js的系統知識書籍(基礎類型章節)通常也會提到,但像我這樣的非科班前端開發,每每在這方面的知識儲備很是薄弱;並且,即便學習過了,也會由於第一次學習時沒體感,沒有實際場景去強化認知,掌握的也不深入。github

因此,在後續的業務開發中,有必要從新整理下遇到的問題,從遇到的問題出發,追根溯源,才能更深入地掌握知識點。面試

真實的Number

(本章節爲基礎的規範介紹,有助於加深認知,非必要知識,尤爲是存儲形式,大部分問題的解答只需有概念便可。)算法

有別於其餘語言會出現各種int、uint、float,JS語言只有一種數值類型——Number,它的背後是標準的雙精度浮點數實現(其餘語言通常稱該類型爲double或float64),這也就意味着,前端全部出現的數值,其實背後都是小數。bash

看一下雙精度浮點數的內存模型(這幅維基百科的示意圖真是每篇精度文章都會引用~):
post


總共64位,分紅了三部分:符號(sign)、指數(exponent)、尾數(fraction)。即,最終每個數值均可以表示成: (-1)^S * 2^E * M

存儲形式

這篇文章介紹了一個很是簡單的轉換方式,拿一個數值實際體驗一下過程,例如34.1學習

第一步,取整數部分——34,經過除2取餘數:

計算過程 結果 餘數
34/2 17 0
17/2 8 1
8/2 4 0
4/2 2 0
2/2 1 0
1/2 0 1

第二步,取小數部分——0.1,經過乘2取整數。若是結果大於1,則取1,不然取0:

計算過程 結果 整數
0.1*2 0.2 0
0.2*2 0.4 0
0.4*2 0.8 0
0.8*2 1.6 1
0.6*2 1.2 1
0.2*2 0.4 0
... ... ...

第三步,拼接結果,整數部分結果是從下往上取,小數部分則是從上往下取。結果爲:(34.1)10 = (100010.0_0011_0011_0011...)2

ps:爲了閱讀清晰,使用下劃線分隔符~該特性將在Chrome75到來,諸如Rust已經具有

第四步,轉換爲科學計數法(二進制版),(34.1)10 = 1.00010_0_0011_0011... * 2(5)10 。到此,已經能夠獲取到公式中各個值所對應的結果了:

  • S = 0
  • E = (5 + 1023)10 = (100_0000_0100)2
  • M = (00010_0_0011_0011...)2

最終的34.1的內存存儲爲:0  100_0000_0100  00010_0_0011_0011_0011_0011_0011_0011_0011_0011_0011_00 11_0011_01。(我反正是瞎了)

對於這個結果,還須要幾點補充說明:

爲何指數E的結果須要+1023?

指數部分有11位bit。使用無符號表示,能夠表示範圍0~2047,其中0和2047爲非規約形式,有特殊意義(詳見wiki,不作展開了),那剩餘的範圍是1~2046;若是使用帶符號表示,能夠表示範圍-1024~1023。由於實際指數是能夠存在負值的,爲了不使用符號表示法,就加入了這個偏移量。

至於,爲何不使用符號?我沒什麼太深入的體感。不過能夠確定的是,目的必定是爲了後續的計算處理方便。好比:若是無符號,能夠直接比較大小?

爲何尾數M的結果省略了整數部分?

這是由於,既然數值必定能夠表示成科學計數法,那尾數M的整數部分必然是1。

爲何?若是實在想不明白,能夠參考十進制的科學計數法,整數部分必定是1~9,由於一旦超過9,就會納入指數,即,整數部分爲1~【進制-1】。那在二進制的科學計數法中,整數部分爲1~1,則必然是1。

此外,這裏還有另外一點好處,經過省略整數部分,這個「1」就不須要佔用存儲了,相對的,小數部分能夠多一位有效數字。

如何表示無限循環的尾數部分?

正如上例中的34.1,它的尾數部分就是無限循環,若是超出了存儲位數,則勢必要進行舍入。

實際上,存在多種舍入規則:

  • 舍入到最接近
  • 朝+∞方向舍入
  • 朝-∞方向舍入
  • 朝0方向舍入

也不作展開了,具體能夠繼續查閱wiki。默認理解下,「0舍1入」的規則夠用了。

觸類旁通

Number.MAX_SAFE_INTEGER

Number類上的一個靜態屬性,值爲9007199254740991。這個數是怎麼來的呢?

由於Number的尾數有53位,理論上能表示的、精確的最大整數即爲2-1,這也正是MAX_SAFE_INTEGER。超過這個值的數值,由於有效數字有限,Number已經沒法精確表示了。

然而指數部分最大值是1023,因此理論上Number能表示的最大值應該至少達到2纔對,那這個區間(2~2)的如何存儲呢?我沒有太深刻思考,原理上應該也是經過舍入規則去理解,不過仍是不展開了,留個坑位~

題外話:
不少面試題裏都包含了大整數的考點。考的是兩處,第一點是,是否意識到了面試題中存在大整數問題;第二點是,如何用程序模擬手算過程。

不過我比較好奇的是,假如面試者使用了BigInt來完成大整數的四則運算(跳過第二個考點)是否是也算合格?【笑

Number.EPSILON

一樣是Number類上的一個靜態屬性,值爲2.220446049250313e-16。這個數又是怎麼來的?

一樣和尾數相關,理論上能表示的最小尾數是1.00000000_00000000_00000000_00000000_00000000_00000000_0001,也就是EPSILON。

1/0和0/0的不一樣結果

使用浮點數的語言,不只僅是JS,對於這兩個結果返回都是Infinity和NaN,1/0比較好理解,0/0在wiki中,有不少例子來證實這個結果是沒法預期的。

選取一種:

0 * X = 0,那 0/0 = X。也就是0/0能夠是任意值,這個結果是沒法成立的。

能精確表示的十進制有效位數

通常來講,double類型的有效位數,結論是16位。不過,目前我還沒看到很是嚴謹的說明過程,現有的解釋方式略做搬運:

  1. MAX_SAFE_INTEGER是9007199254740991,它的位數就是16
  2. EPSILON它能精確到小數點後15位,再加上整數位,因此,有效位數是16

爲何不推薦使用位運算

lint規則中通常是不建議在JS代碼中使用位運算的。

第一點是,不便於維護,考慮到前端開發廣泛對位運算不感冒;
第二點是,如兩次取反(~~3.11)、或0(3.11 | 0)這種取整操做,其背後,其實是將64位的雙精度浮點數轉成了32位整數。若是對此沒有明確的認知,能確保程序運行時的入參一定是32位整數範圍內的話,就很容易埋坑,不如老老實實的使用Math.floorMath.round

const n = 2**32 + 0.1 // 4294967296.1

~~n // 指望是2^32,但其實結果是0

Math.floor(n) // 符合預期
複製代碼

Number的計算

明白了真實的Number,很容易就理解了——因爲一個小數沒法用二進制精準表示,勢必存在精度丟失,也就很天然地會出現諸如經典的「0.1+0.2 ≠ 0.3」問題。但與此同時,我產生了一個疑問,兩個精度丟失的純小數是否能得出一個精準表示的數值?

(因爲雙精度浮點數實在位數太多了。。。寫得累,下面都使用單精度浮點數表意,雙精度的狀況能夠同理類推。)

嚴格來講,浮點數計算須要通過:對階、尾數求和、規約化、舍入、溢出判斷(詳細內容,能夠參閱此文)。若是嚴格按照步驟進行,有些過於死板,並且其中有更多的概念須要消化,這裏僅僅是爲了加深體感,因此使用更「小學」的方式來解決這個問題。

在進行具體計算前,須要先掌握:

  • 如何將十進制轉爲二進制,上一章介紹過了
  • 有效數字位數,單精度浮點數尾數部分爲23位,相應的,能表示的有效位數爲24位(爲何?),上一章也介紹過了
  • 手算加法

0.1 + 0.4

將0.1和0.4轉爲二進制(不須要轉爲科學計數法,便可跳過對階步驟),結果是:

  • 0.1 = 0.0_0011_0011_0011_0011_0011_0011_01,保留24位有效數字,根據「0舍1入」進位
  • 0.4 = 0.0_1100_1100_1100_1100_1100_1101,保留24位有效數字,根據「0舍1入」進位

能夠看到,0.1和0.4都是存在進位的,它的存儲值比真實值都要大,那兩個比真實值大的數的是如何剛好相加得出0.5的呢?

核心關鍵點,其實在於這個**「有效位數」**,咱們手算一下,把這兩個值直接相加,如今位數已經對齊了:

0.0_0011_0011_0011_0011_0011_0011_01
+      0.0_1100_1100_1100_1100_1100_1101
-----------------------------------------------
       0.1_0000_0000_0000_0000_0000_0000_(01)
複製代碼

0.1就是0.5,實在是太巧了!偏差正好被排除在有效位數以外!也就是,兩個丟失精度的數值計算後剛好精度復原了。

好奇心如我,以爲這裏應該是能夠用數學方式去證實,無整數部分的小數計算,偏差必定會控制在相對小的範圍以內的。不然,若是按照常規理解,隨着計算進行,偏差會無休止的膨脹下去。

固然,這種證實過程確定很專業,估計真展現在我面前,我也看不懂。我等普通吃瓜開發,仍是隻管喊666就成了~

0.1 * 10

掌握了加減法,就天然會對乘法產生新的疑惑(主要是解決精度問題中很常見的辦法是轉爲整數)。既然,0.1是沒法精確表示的,而1和10做爲整數又是能夠精確表示的,那這裏的結果「1」是精確的「1」,仍是一個很是近似的小數?若是是精確的,丟失精度的小數是如何轉爲精確的整數的呢?

浮點數的乘法有特別算法(Booth算法)能夠細講的,不過在此也不作具體展開。

基本原理上來講,就是將乘法簡化爲「移位 + 加減法」。在本例中,10能夠拆爲2 + 2,繼續手算:

0.1 * 10 = 0.1 * 2^3 + 0.1 * 2

       0.1100_1100_1100_1100_1100_1101
+      0.0011_0011_0011_0011_0011_0011_01
---------------------------------------------------
       1.0000_0000_0000_0000_0000_0000_(01)
複製代碼

是否是又一次感慨世界的奇妙?和上一例結果同樣,偏差再一次被命運排除在有效位數以外,amazing~~

不過,須要注意的是,這兩個示例都限定在了無整數部分的小數計算(也多是整數部分須要知足什麼條件才能夠)。若是整數部分存在有效數字,會不一樣程度的擠壓小數部分可用的尾數有效位數,就有可能致使沒法出現這些神奇結果了。

/10 和 *0.1 的區別

這個區別能夠簡單的進行求證。只需提升結果的精度表示,就能夠看到差別:

(6 / 10).toPrecision(17)  // "0.59999999999999998"
(6 * 0.1).toPrecision(17) // "0.60000000000000009"
複製代碼

究其緣由,0.1是沒法精確表示的,而10是能夠精確表示的,因此和一個能夠準確表示的數進行計算,勢必精度會高於和沒法準確表示的數進行計算。

這就是典型的偏差累計,當結果是沒法精確表示的時候,以前那神奇的偏差清除彷佛就沒那麼靈驗了。因此,若是有必要,計算過程當中,能夠有意識的儘可能使用整數。

解決方式

toFixed

這是最基礎的解法。不過須要注意的是,當尾數是5的時候,它的結果每每不符合預期。

這篇文章裏,舉了個例子:

(1.005).toFixed(2) // 結果是1.00,而不是1.01
// 文中給出的解釋是將該數值進行更高精度展現,確實該數值的四捨五入確實是1.00
(1.005).toPrecision(17) // '1.0049999999999999'
複製代碼

然而,評論中,被人錘了:

(1.105).toPrecision(17) // '1.1050000000000000'
(1.105).toFixed(2) // 結果是1.10
複製代碼

這是爲何?

思路上沒有問題,只是,精度還不夠。若是咱們按照規範理解toFixed,那核心在於這一步驟:

Let n be an integer for which the exact mathematical value of n ÷ 10 – x is as close to zero as possible. If there are two such n, pick the larger n.

套用在這個例子中就是:

n / 100 - 1.105 // n爲整數,儘量讓結果趨於0,最終計算偏差取17位精度
n = 110, // -0.0049999999999998934
n = 105, // 0.0050000000000001155
複製代碼

確實n = 110時,結果更接近0,也就是toFixed的結果是1.10。

固然,使用取高精度方式去求解也何嘗不可,只是,實際規範過程當中,能夠注意到,這一步計算會把整數部分以及小數點後的n(toFixed參數)位所有歸0,因此若是須要正確的觀測當前值,須要toPrecision(17 + n),也就是:

(1.105).toPrecision(19) // 1.104999999999999982
// 也就能夠正確推出toFixed(2)的結果是1.10了
複製代碼

Math.round

這裏補充一點,通常場景中,若是想獲取四捨五入的整數,每每會使用Math.round。但須要注意,這裏依然有不符合預期的結果:

Math.round(1.005 * 100) / 100 // 結果是1,而不是指望的1.1
Math.round(-0.5) // 結果是0,而不是指望的-1
複製代碼

第一例的問題實際上是1.005沒法轉爲精確的整數致使的:1.005 * 1000 = 1004.9999999999999。因此只須要額外的多進行一次轉換便可。

第二例的問題實際上是符合規範的,Math.round的結果是取更靠近+∞方向,而不是常規理解的遠離0,因此碰到負數,更保險的作法應該是使用絕對值再加符號位。

toPrecision

上文提過雙精度浮點數能精確表示的位數是16位。若是toFixed使用時沒有注意整數部分,也會致使預期以外的錯誤:

(1234123412341234.3).toFixed(2) // 1234123412341234.25
複製代碼

既然toFixed有種種問題,而Number自己能達到的精度是16位,那其實,數值運算後的最終結果只要進行Number.parseFloat(num.toPrecision(16))處理便可。

轉整數計算

toPrecision能夠避免絕大部分的小數點位數過長的問題。但,這可能致使結果和業務輸入的位數不一致,例如:

add(0.11, 0.19) => '0.30'
add(0.11, 0.100) => '0.210'
複製代碼

要解決這類問題,通常須要轉整數計算,不只能夠保證精度,也能輸出符合業務預期的位數。這也是絕大部分輕量庫的方案,基本原理是:

  1. 求出入參的最大位數
  2. 轉爲整數計算
  3. 最後輸出結果時再除去最大位數

固然,這種方案的缺陷是,過程當中通常沒法顧及超出範圍的大數。

類庫

一步步瞭解了各類場景下出現的問題,這時候再去選擇類庫,就有底氣的多,畢竟對於各類問題的解決已初步具有思路,不會只停留在知其然而不知其因此然的境界。而使用成熟類庫的好處是,它考慮的邊界條件更多、邏輯更完備,運行時的穩定性更高。

我列舉幾個類庫,不過使用不深,就請自行查閱啦~






參考

相關文章
相關標籤/搜索