為什么 0.1 + 0.2 = 0.3
為什么這么設(shè)計(Why’s THE Design)是一系列關(guān)于計算機領(lǐng)域中程序設(shè)計決策的文章,我們在這個系列的每一篇文章中都會提出一個具體的問題并從不同的角度討論這種設(shè)計的優(yōu)缺點、對具體實現(xiàn)造成的影響。
0.1 + 0.2 = 0.3 這個等式的成立看起來是理所當然的,然而前面的文章 為什么 0.1 + 0.2 = 0.300000004 分析了為什么這個等式在絕大多數(shù)的編程語言中都不成立,標準的浮點數(shù)可以通過 32 位單精度浮點數(shù)或者 64 位的雙精度浮點數(shù)保證有限的精度,所有正確實現(xiàn)浮點數(shù)的編程語言都會遇到如下所示的『錯誤』:
> 0.1 + 0.20.30000000000000004
浮點數(shù)作為編程語言中必不可少的概念,需要在性能和精度方面做出的權(quán)衡,過高的精度需要更多的位數(shù)以及更多的計算,過低的精度也無法滿足常見的計算需求,這種重要的決策會影響上層千千萬萬的應用和服務(wù),然而這個決策需要面對的問題與軟件工程中需要解決的問題也沒有太多區(qū)別 — 如何盡可能地利用有限地資源實現(xiàn)特定目的。

雖然浮點數(shù)提供了相對優(yōu)異的性能,但是在金融系統(tǒng)中使用精度低的浮點數(shù)會有非常嚴重的后果。假設(shè)我們在交易所或者銀行使用 64 位的雙精度浮點數(shù)存儲賬戶的余額,這時就存在被用戶攻擊的可能,用戶可以利用雙精度浮點數(shù)的精度限制造出更多余額:

當用戶分別先向賬戶中充值 0.1 單位和 0.2 單位的資產(chǎn)后,使用雙精度浮點數(shù)在計算時會得到 0.30000000000000004,用戶將這些資產(chǎn)全部提現(xiàn)可以得到 0.00000000000000004 的意外之財[^1],如果用戶重復的次數(shù)足夠多,就可以把銀行提破產(chǎn),大家加油,下面是一段使用浮點數(shù)處理充值和提現(xiàn)的代碼:
var balance float64 = 0func main() {deposit(.1)deposit(.2)if balance, ok := withdraw(0.30000000000000004); ok {fmt.Println(balance)}}func deposit(v float64) {balance += v}func withdraw(v float64) (float64, bool) {if v <= balance {balance -= vreturn v, true}return 0, false}
上面的代碼也只是理想的情況,今天的成熟金融系統(tǒng)不可能~~(其實不一定)~~犯這種低級的錯誤,但是一些新興的交易所中仍然存在這種可能,不過想要真正實施上述操作還是非常困難。如果我們可以控制的資源是無限的,自然就可以實現(xiàn)無限精度的小數(shù),然而資源永遠都是有限的,一些編程語言或者庫會通過下面的兩種方法提供精度更高的小數(shù)保證 0.1 + 0.2 = 0.3 這個等式的成立:
使用具有 128 位的高精度定點數(shù)或者無限精度的定點數(shù); 使用有理數(shù)類型和分數(shù)系統(tǒng)保證計算的精度;
上述這兩種方法都可以實現(xiàn)精度更高的小數(shù)系統(tǒng),但是兩者的原理卻略有不同,接下來我們將分析它們的設(shè)計原理。
十進制小數(shù)
在很多時候浮點數(shù)的精度損失都是因為不同進制的數(shù)據(jù)相關(guān)轉(zhuǎn)換造成的,正如我們在 為什么 0.1 + 0.2 = 0.300000004 一文中提到的,我們無法使用有限的二進制位數(shù)準確地表示十進制中的 0.1 和 0.2,這就造成了精度的損失,這些精度損失不斷累加在最后就可能累積成較大的錯誤:

如下圖所示,因為 0.25 和 0.5 兩個十進制的小數(shù)都可以用二進制的浮點數(shù)準確表示,所以使用浮點數(shù)計算 0.25 + 0.5 的結(jié)果也一定是準確的[^2]:

為了解決浮點數(shù)的精度問題,一些編程語言引入了十進制的小數(shù) Decimal。Decimal 在不同社區(qū)中都十分常見,如果編程語言沒有原生支持 Decimal,我們在開源社區(qū)也一定能夠找到使用特定語言實現(xiàn)的 Decimal 庫。Java 通過 BigDecimal 提供了無限精度的小數(shù),該類中包含三個關(guān)鍵的成員變量 intVal、scale 和 precision[^3]:
public class BigDecimal extends Number implements Comparable{ private BigInteger intVal;private int scale;private int precision = 0;...}
當我們使用 BigDecimal 表示 1234.56 時,BigDecimal 中的三個字段會分別以下的內(nèi)容:
intVal中存儲的是去掉小數(shù)點后的全部數(shù)字,即123456;scale中存儲的是小數(shù)的位數(shù),即2;prevision中存儲的是全部的有效位數(shù),小數(shù)點前 4 位,小數(shù)點后 2 位,即6;

BigDecimal 這種使用多個整數(shù)的方法避開了二進制無法準確表示部分十進制小數(shù)的問題,因為 BigInteger 可以使用數(shù)組表示任意長度的整數(shù),所以如果機器的內(nèi)存資源是無限的,BigDecimal 在理論上也可以表示無限精度的小數(shù)。
雖然部分編程語言實現(xiàn)了理論上無限精度的 BigDecimal,但是在實際應用中我們大多不需要無限的精度保證,C# 等編程語言通過 16 字節(jié)的 Decimal 提供的 28 ~ 29 位的精度,而在金融系統(tǒng)中使用 16 字節(jié)的 Decimal 一般就可以保證數(shù)據(jù)計算的準確性了[^4]。
有理數(shù)
使用 Decimal 和 BigDecimal 雖然可以在很大程度上解決浮點數(shù)的精度問題,但是它們在遇到無限小數(shù)時仍然無能為力,使用十進制的小數(shù)永遠無法準確地表示 1/3,無論使用多少位小數(shù)都無法避免精度的損失:

當我們遇到這種情況時,使用有理數(shù)(Rational)是解決類似問題的最好方法,部分編程語言因為科學計算的需求會將有理數(shù)作為標準庫的一部分,例如:Julia[^5] 和 Haskell[^6]。分數(shù)是有理數(shù)的重要組成部分,使用分數(shù)可以準確的表示 1/10、1/5 和 1/3,Julia 作為科學計算中的常用編程語言,我們可以使用如下所示的方式表示分數(shù):
julia> 1//31//3julia> numerator(1//3)1julia> denominator(1//3)3
這種解決精度問題的方法更接近原始的數(shù)學公式,分數(shù)的分子和分母是有理數(shù)結(jié)構(gòu)體中的兩個變量,多個分數(shù)的加減乘除操作與數(shù)學中對分數(shù)的計算沒有任何區(qū)別,自然也就不會造成精度的損失,我們可以簡單了解一下 Java 中有理數(shù)的實現(xiàn)[^7]:
public class Rational implements Comparable{ private int num; // the numeratorprivate int den; // the denominatorpublic double toDouble() {return (double) num / den;}...}
上述類中的 num 和 den 分別表示分數(shù)的分子和分母,它提供的 toDouble 方法可以將當前有理數(shù)轉(zhuǎn)換成浮點數(shù),因為浮點數(shù)在軟件工程中雖然更加常用,當我們需要嚴密的科學計算時,可以使用有理數(shù)完成絕大多數(shù)的計算,并在最后轉(zhuǎn)換回浮點數(shù)以減少可能出現(xiàn)的誤差。
然而需要注意的是,這種使用有理數(shù)計算的方式不僅在使用上相對比較麻煩,它在性能上也無法與浮點數(shù)進行比較,一次常見的加減法就需要使用幾倍于浮點數(shù)操作的匯編指令,所以在非必要的場景中一定要盡量避免。
總結(jié)
想要保證 0.1 + 0.2 = 0.3 這個公式的成立并不是一件復雜的事情,作者相信除了文中介紹的這些方案之外,我們還會有其他的實現(xiàn)方式,但是文中介紹的方案是最為常見的兩種,我們再來回顧一下如何使 0.1 + 0.2 = 0.3 這個公式成立:
使用十進制的兩個整數(shù) — 整數(shù)值和指數(shù)表示有限精度或者無限精度的小數(shù),一些編程語言使用 128 位的 Decimal表示具有 28 ~ 29 位精度的數(shù)字,而一些編程語言使用BigDecimal表示無限精度的數(shù)字;使用十進制的兩個整數(shù) — 分子和分母表示準確的分數(shù),可以減少浮點數(shù)計算帶來的精度損失;
有理數(shù)和小數(shù)是數(shù)學中的概念,數(shù)學是一門非常嚴謹和精確的學科,通過引入大量的概念和符號,數(shù)學中的計算可以實現(xiàn)絕對的準確;但是軟件工程作為一門工程,它需要在復雜的物理世界,利用有限的資源解決有限的問題,所以我們需要在多個方案之間做出權(quán)衡和選擇,數(shù)學中的有理數(shù)和無理數(shù)其實都可以在軟件中實現(xiàn),但是在使用時一定要想清楚 — 為了得到這些我們犧牲了什么?到最后,我們還是來看一些比較開放的相關(guān)問題,有興趣的讀者可以仔細思考一下下面的問題:
你最常用的編程語言中小數(shù)的結(jié)構(gòu)體是什么樣的,包含了哪些字段? 浮點數(shù)、小數(shù)和有理數(shù)三種不同的策略在加減乘除四則運算上的性能如何?
如果對文章中的內(nèi)容有疑問或者想要了解更多軟件工程上一些設(shè)計決策背后的原因,可以在博客下面留言,作者會及時回復本文相關(guān)的疑問并選擇其中合適的主題作為后續(xù)的內(nèi)容。
推薦閱讀
站長 polarisxu
自己的原創(chuàng)文章
不限于 Go 技術(shù)
職場和創(chuàng)業(yè)經(jīng)驗
Go語言中文網(wǎng)
每天為你
分享 Go 知識
Go愛好者值得關(guān)注
