摘要:前言在數據敏感的業務場景中,常常會碰到數據精度問題,尤其在金額顯示占比統計等地方,該問題尤為顯著。計算機原理真香數值的精度問題,其實是非?;A的計算機原理知識。
前言在數據敏感的業務場景中,常常會碰到數據精度問題,尤其在金額顯示、占比統計等地方,該問題尤為顯著。由于數據的每一位有效數字都包含真實的業務語義,一點點偏差甚至可能影響業務決策,這讓問題的嚴重性上升了幾個階梯。
那,什么是精度丟失?一言以概之,凡是在運行過程中,導致數值存在不可逆轉換時,就是精度丟失。
諸如:
人均交易額、占比這類計算得出的除法獲得的指標(分子/分母)時,如果盲目的直接從該結果去推算分子數值時,很可能就存在精度丟失
浮點數計算結果,會出現很長尾的小數
這兩種廣義上來說都是精度丟失,但第一種情況可以通過更改技術方案等方式進行規避。更多時候,所謂的精度問題,單指第二類問題。而面對這類問題時,如果沒有掌握原理,往往會一知半解,對結論印象不深,再次碰到問題只能一查再查。
計算機原理真香數值的精度問題,其實是非?;A的計算機原理知識。通常,js的系統知識書籍(基礎類型章節)一般也會提到,但像我這樣的非科班前端開發,往往在這方面的知識儲備非常薄弱;而且,即使學習過了,也會因為第一次學習時沒體感,沒有實際場景去強化認知,掌握的也不深刻。
所以,在后續的業務開發中,有必要重新整理下遇到的問題,從遇到的問題出發,追根溯源,才能更深刻地掌握知識點。
真實的Number(本章節為基礎的規范介紹,有助于加深認知,非必要知識,尤其是存儲形式,大部分問題的解答只需有概念即可。)
有別于其他語言會出現各類int、uint、float,JS語言只有一種數值類型——Number,它的背后是標準的雙精度浮點數實現(其他語言一般稱該類型為double或float64),這也就意味著,前端所有出現的數值,其實背后都是小數。
看一下雙精度浮點數的內存模型(這幅維基百科的示意圖真是每篇精度文章都會引用~):
存儲形式
這篇文章介紹了一個非常簡單的轉換方式,拿一個數值實際體驗一下過程,例如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。(我反正是瞎了)
對于這個結果,還需要幾點補充說明:
指數部分有11位bit。使用無符號表示,可以表示范圍0~2047,其中0和2047為非規約形式,有特殊意義(詳見wiki,不做展開了),那剩余的范圍是1~2046;如果使用帶符號表示,可以表示范圍-1024~1023。因為實際指數是可以存在負值的,為了避免使用符號表示法,就加入了這個偏移量。
至于,為什么不使用符號?我沒什么太深刻的體感。不過可以肯定的是,目的一定是為了后續的計算處理方便。比如:如果無符號,可以直接比較大小?
這是因為,既然數值一定可以表示成科學計數法,那尾數M的整數部分必然是1。
為什么?如果實在想不明白,可以參考十進制的科學計數法,整數部分一定是1~9,因為一旦超過9,就會歸入指數,即,整數部分為1~【進制-1】。那在二進制的科學計數法中,整數部分為1~1,則必然是1。
此外,這里還有另一點好處,通過省略整數部分,這個“1”就不需要占用存儲了,相對的,小數部分可以多一位有效數字。
正如上例中的34.1,它的尾數部分就是無限循環,如果超出了存儲位數,則勢必要進行舍入。
實際上,存在多種舍入規則:
舍入到最接近
朝+∞方向舍入
朝-∞方向舍入
朝0方向舍入
也不做展開了,具體可以繼續查閱wiki。默認理解下,“0舍1入”的規則夠用了。
舉一反三Number類上的一個靜態屬性,值為9007199254740991。這個數是怎么來的呢?
因為Number的尾數有53位,理論上能表示的、精確的最大整數即為2-1,這也正是MAX_SAFE_INTEGER。超過這個值的數值,因為有效數字有限,Number已經無法精確表示了。
然而指數部分最大值是1023,所以理論上Number能表示的最大值應該至少達到2才對,那這個區間(2~2)的如何存儲呢?我沒有太深入思考,原理上應該也是通過舍入規則去理解,不過還是不展開了,留個坑位~
題外話:
很多面試題里都包含了大整數的考點??嫉氖莾商帲谝稽c是,是否意識到了面試題中存在大整數問題;第二點是,如何用程序模擬手算過程。
不過我比較好奇的是,假如面試者使用了BigInt來完成大整數的四則運算(跳過第二個考點)是不是也算合格?【笑
同樣是Number類上的一個靜態屬性,值為2.220446049250313e-16。這個數又是怎么來的?
同樣和尾數相關,理論上能表示的最小尾數是1.00000000_00000000_00000000_00000000_00000000_00000000_0001,也就是EPSILON。
一般來說,double類型的有效位數,結論是16位。不過,目前我還沒看到非常嚴謹的說明過程,現有的解釋方式略作搬運:
MAX_SAFE_INTEGER是9007199254740991,它的位數就是16
EPSILON它能精確到小數點后15位,再加上整數位,所以,有效位數是16
lint規則中一般是不建議在JS代碼中使用位運算的。
第一點是,不便于維護,考慮到前端開發普遍對位運算不感冒;
第二點是,如兩次取反(~~3.11)、或0(3.11 | 0)這種取整操作,其背后,實際上是將64位的雙精度浮點數轉成了32位整數。如果對此沒有明確的認知,能確保程序運行時的入參必定是32位整數范圍內的話,就很容易埋坑,不如老老實實的使用Math.floor或Math.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"
要解決這類問題,一般需要轉整數計算,不僅可以保證精度,也能輸出符合業務預期的位數。這也是絕大部分輕量庫的方案,基本原理是:
求出入參的最大位數
轉為整數計算
最后輸出結果時再除去最大位數
當然,這種方案的缺陷是,過程中一般無法顧及超出范圍的大數。
類庫一步步了解了各種場景下出現的問題,這時候再去選擇類庫,就有底氣的多,畢竟對于各種問題的解決已初步具備思路,不會只停留在知其然而不知其所以然的境界。而使用成熟類庫的好處是,它考慮的邊界條件更多、邏輯更完備,運行時的穩定性更高。
我列舉幾個類庫,不過使用不深,就請自行查閱啦~
Mathjs
BigNumber.js
Decimal.js(同一位大師)
Big.js(我不知道這位大師的三個庫具體區別是什么。。。)
number-precision,輕量級方案
Binary numbers – floating point conversion
JavaScript 浮點數陷阱及解法
如何避開JavaScript浮點數計算精度問題
IEEE 754
從0.1+0.2=0.30000000000000004再看JS中的Number類型
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/7260.html
摘要:浮點數類型包括單精度浮點數和雙精度浮點數。小結通過浮點數精度的問題,了解到浮點數的小數用二進制的表示。以后,在使用浮點數運算的時候,一定要慎之又慎,細節決定成敗。 概述 記錄下,工作中遇到的坑 ... 關于 PHP 浮點數運算,特別是金融行業、電子商務訂單管理、數據報表等相關業務,利用浮點數進行加減乘除時,稍不留神運算結果就會出現偏差,輕則損失幾十萬,重則會有信譽損失,甚至吃上官司,我...
摘要:輾轉流傳出班車手冊后發現搜索實在是太不方便了,于是有了一個主義,想做一個可以搜索房子地址,找出附近班車點類似大眾點評的定位搜索附近餐館的功能。 起因 七月份要去某廠報道了,異地租房的時候發現想租一個有公司班車的地方,卻不知道哪里有班車。輾轉流傳出班車手冊后發現搜索實在是太不方便了,于是有了一個主義,想做一個可以搜索房子地址,找出附近班車點(類似大眾點評的定位搜索附近餐館的功能)?,F在做...
閱讀 1961·2021-09-09 09:33
閱讀 1107·2019-08-30 15:43
閱讀 2646·2019-08-30 13:45
閱讀 3297·2019-08-29 11:00
閱讀 845·2019-08-26 14:01
閱讀 3559·2019-08-26 13:24
閱讀 471·2019-08-26 11:56
閱讀 2683·2019-08-26 10:27