為什么 0.1 + 0.2 = 0.3
0.1 + 0.2 = 0.3 這個(gè)等式的成立看起來是理所當(dāng)然的,然而前面的文章 為什么 0.1 + 0.2 = 0.300000004 分析了為什么這個(gè)等式在絕大多數(shù)的編程語言中都不成立,標(biāo)準(zhǔn)的浮點(diǎn)數(shù)可以通過 32 位單精度浮點(diǎn)數(shù)或者 64 位的雙精度浮點(diǎn)數(shù)保證有限的精度,所有正確實(shí)現(xiàn)浮點(diǎn)數(shù)的編程語言都會(huì)遇到如下所示的『錯(cuò)誤』:
> 0.1 + 0.20.30000000000000004
浮點(diǎn)數(shù)作為編程語言中必不可少的概念,需要在性能和精度方面做出的權(quán)衡,過高的精度需要更多的位數(shù)以及更多的計(jì)算,過低的精度也無法滿足常見的計(jì)算需求,這種重要的決策會(huì)影響上層千千萬萬的應(yīng)用和服務(wù),然而這個(gè)決策需要面對(duì)的問題與軟件工程中需要解決的問題也沒有太多區(qū)別 — 如何盡可能地利用有限地資源實(shí)現(xiàn)特定目的。
圖 1 - 性能和精度的權(quán)衡雖然浮點(diǎn)數(shù)提供了相對(duì)優(yōu)異的性能,但是在金融系統(tǒng)中使用精度低的浮點(diǎn)數(shù)會(huì)有非常嚴(yán)重的后果。假設(shè)我們?cè)诮灰姿蛘咩y行使用 64 位的雙精度浮點(diǎn)數(shù)存儲(chǔ)賬戶的余額,這時(shí)就存在被用戶攻擊的可能,用戶可以利用雙精度浮點(diǎn)數(shù)的精度限制造出更多余額:
圖 2 - 金融系統(tǒng)與浮點(diǎn)數(shù)當(dāng)用戶分別先向賬戶中充值 0.1 單位和 0.2 單位的資產(chǎn)后,使用雙精度浮點(diǎn)數(shù)在計(jì)算時(shí)會(huì)得到 0.30000000000000004,用戶將這些資產(chǎn)全部提現(xiàn)可以得到 0.00000000000000004 的意外之財(cái)[^1],如果用戶重復(fù)的次數(shù)足夠多,就可以把銀行提破產(chǎn),大家加油,下面是一段使用浮點(diǎ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)不可能~~(其實(shí)不一定)~~犯這種低級(jí)的錯(cuò)誤,但是一些新興的交易所中仍然存在這種可能,不過想要真正實(shí)施上述操作還是非常困難。如果我們可以控制的資源是無限的,自然就可以實(shí)現(xiàn)無限精度的小數(shù),然而資源永遠(yuǎn)都是有限的,一些編程語言或者庫會(huì)通過下面的兩種方法提供精度更高的小數(shù)保證 0.1 + 0.2 = 0.3 這個(gè)等式的成立:
- 使用具有 128 位的高精度定點(diǎn)數(shù)或者無限精度的定點(diǎn)數(shù);
- 使用有理數(shù)類型和分?jǐn)?shù)系統(tǒng)保證計(jì)算的精度;
上述這兩種方法都可以實(shí)現(xiàn)精度更高的小數(shù)系統(tǒng),但是兩者的原理卻略有不同,接下來我們將分析它們的設(shè)計(jì)原理。
十進(jìn)制小數(shù)
在很多時(shí)候浮點(diǎn)數(shù)的精度損失都是因?yàn)椴煌M(jìn)制的數(shù)據(jù)相關(guān)轉(zhuǎn)換造成的,正如我們?cè)?為什么 0.1 + 0.2 = 0.300000004 一文中提到的,我們無法使用有限的二進(jìn)制位數(shù)準(zhǔn)確地表示十進(jìn)制中的 0.1 和 0.2,這就造成了精度的損失,這些精度損失不斷累加在最后就可能累積成較大的錯(cuò)誤:
圖 3 - 二進(jìn)制與十進(jìn)制精度的損失如下圖所示,因?yàn)?0.25 和 0.5 兩個(gè)十進(jìn)制的小數(shù)都可以用二進(jìn)制的浮點(diǎn)數(shù)準(zhǔn)確表示,所以使用浮點(diǎn)數(shù)計(jì)算 0.25 + 0.5 的結(jié)果也一定是準(zhǔn)確的[^2]:
圖 4 - 0.25 和 0.5 的浮點(diǎn)數(shù)表示為了解決浮點(diǎn)數(shù)的精度問題,一些編程語言引入了十進(jìn)制的小數(shù) Decimal。Decimal 在不同社區(qū)中都十分常見,如果編程語言沒有原生支持 Decimal,我們?cè)陂_源社區(qū)也一定能夠找到使用特定語言實(shí)現(xiàn)的 Decimal 庫。Java 通過 BigDecimal 提供了無限精度的小數(shù),該類中包含三個(gè)關(guān)鍵的成員變量 intVal、scale 和 precision[^3]:
public class BigDecimal extends Number implements Comparable{ private BigInteger intVal;private int scale;private int precision = 0;...}
當(dāng)我們使用 BigDecimal 表示 1234.56 時(shí),BigDecimal 中的三個(gè)字段會(huì)分別以下的內(nèi)容:
intVal中存儲(chǔ)的是去掉小數(shù)點(diǎn)后的全部數(shù)字,即123456;scale中存儲(chǔ)的是小數(shù)的位數(shù),即2;prevision中存儲(chǔ)的是全部的有效位數(shù),小數(shù)點(diǎn)前 4 位,小數(shù)點(diǎn)后 2 位,即6;
圖 5 - BigDecimal 實(shí)現(xiàn)BigDecimal 這種使用多個(gè)整數(shù)的方法避開了二進(jìn)制無法準(zhǔn)確表示部分十進(jìn)制小數(shù)的問題,因?yàn)?BigInteger 可以使用數(shù)組表示任意長(zhǎng)度的整數(shù),所以如果機(jī)器的內(nèi)存資源是無限的,BigDecimal 在理論上也可以表示無限精度的小數(shù)。
雖然部分編程語言實(shí)現(xiàn)了理論上無限精度的 BigDecimal,但是在實(shí)際應(yīng)用中我們大多不需要無限的精度保證,C# 等編程語言通過 16 字節(jié)的 Decimal 提供的 28 ~ 29 位的精度,而在金融系統(tǒng)中使用 16 字節(jié)的 Decimal 一般就可以保證數(shù)據(jù)計(jì)算的準(zhǔn)確性了[^4]。
有理數(shù)
使用 Decimal 和 BigDecimal 雖然可以在很大程度上解決浮點(diǎn)數(shù)的精度問題,但是它們?cè)谟龅綗o限小數(shù)時(shí)仍然無能為力,使用十進(jìn)制的小數(shù)永遠(yuǎn)無法準(zhǔn)確地表示 1/3,無論使用多少位小數(shù)都無法避免精度的損失:
圖 6 - 無限小數(shù)的精度問題當(dāng)我們遇到這種情況時(shí),使用有理數(shù)(Rational)是解決類似問題的最好方法,部分編程語言因?yàn)榭茖W(xué)計(jì)算的需求會(huì)將有理數(shù)作為標(biāo)準(zhǔn)庫的一部分,例如:Julia[^5] 和 Haskell[^6]。分?jǐn)?shù)是有理數(shù)的重要組成部分,使用分?jǐn)?shù)可以準(zhǔn)確的表示 1/10、1/5 和 1/3,Julia 作為科學(xué)計(jì)算中的常用編程語言,我們可以使用如下所示的方式表示分?jǐn)?shù):
julia> 1//31//3julia> numerator(1//3)1julia> denominator(1//3)3
這種解決精度問題的方法更接近原始的數(shù)學(xué)公式,分?jǐn)?shù)的分子和分母是有理數(shù)結(jié)構(gòu)體中的兩個(gè)變量,多個(gè)分?jǐn)?shù)的加減乘除操作與數(shù)學(xué)中對(duì)分?jǐn)?shù)的計(jì)算沒有任何區(qū)別,自然也就不會(huì)造成精度的損失,我們可以簡(jiǎn)單了解一下 Java 中有理數(shù)的實(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 分別表示分?jǐn)?shù)的分子和分母,它提供的 toDouble 方法可以將當(dāng)前有理數(shù)轉(zhuǎn)換成浮點(diǎn)數(shù),因?yàn)楦↑c(diǎn)數(shù)在軟件工程中雖然更加常用,當(dāng)我們需要嚴(yán)密的科學(xué)計(jì)算時(shí),可以使用有理數(shù)完成絕大多數(shù)的計(jì)算,并在最后轉(zhuǎn)換回浮點(diǎn)數(shù)以減少可能出現(xiàn)的誤差。
然而需要注意的是,這種使用有理數(shù)計(jì)算的方式不僅在使用上相對(duì)比較麻煩,它在性能上也無法與浮點(diǎn)數(shù)進(jìn)行比較,一次常見的加減法就需要使用幾倍于浮點(diǎn)數(shù)操作的匯編指令,所以在非必要的場(chǎng)景中一定要盡量避免。
總結(jié)
想要保證 0.1 + 0.2 = 0.3 這個(gè)公式的成立并不是一件復(fù)雜的事情,作者相信除了文中介紹的這些方案之外,我們還會(huì)有其他的實(shí)現(xiàn)方式,但是文中介紹的方案是最為常見的兩種,我們?cè)賮砘仡櫼幌氯绾问?0.1 + 0.2 = 0.3 這個(gè)公式成立:
- 使用十進(jìn)制的兩個(gè)整數(shù) — 整數(shù)值和指數(shù)表示有限精度或者無限精度的小數(shù),一些編程語言使用 128 位的
Decimal表示具有 28 ~ 29 位精度的數(shù)字,而一些編程語言使用BigDecimal表示無限精度的數(shù)字; - 使用十進(jìn)制的兩個(gè)整數(shù) — 分子和分母表示準(zhǔn)確的分?jǐn)?shù),可以減少浮點(diǎn)數(shù)計(jì)算帶來的精度損失;
有理數(shù)和小數(shù)是數(shù)學(xué)中的概念,數(shù)學(xué)是一門非常嚴(yán)謹(jǐn)和精確的學(xué)科,通過引入大量的概念和符號(hào),數(shù)學(xué)中的計(jì)算可以實(shí)現(xiàn)絕對(duì)的準(zhǔn)確;但是軟件工程作為一門工程,它需要在復(fù)雜的物理世界,利用有限的資源解決有限的問題,所以我們需要在多個(gè)方案之間做出權(quán)衡和選擇,數(shù)學(xué)中的有理數(shù)和無理數(shù)其實(shí)都可以在軟件中實(shí)現(xiàn),但是在使用時(shí)一定要想清楚 — 為了得到這些我們犧牲了什么?到最后,我們還是來看一些比較開放的相關(guān)問題,有興趣的讀者可以仔細(xì)思考一下下面的問題:
- 你最常用的編程語言中小數(shù)的結(jié)構(gòu)體是什么樣的,包含了哪些字段?
- 浮點(diǎn)數(shù)、小數(shù)和有理數(shù)三種不同的策略在加減乘除四則運(yùn)算上的性能如何?
