Java 理論與實(shí)踐: 您的小數(shù)點(diǎn)到哪里去了?
許多程序員在其整個(gè)開發(fā)生涯中都不曾使用定點(diǎn)或浮點(diǎn)數(shù),可能的例外是,偶爾在計(jì)時(shí)測試或基準(zhǔn)測試程序中會(huì)用到。Java語言和類庫支持兩類非整數(shù)類型 ― IEEE 754 浮點(diǎn)(float
和double
,包裝類(wrapper class)為Float
和Double
),以及任意精度的小數(shù)(java.math.BigDecimal
)。在本月的 Java 理論和實(shí)踐中,Brian Goetz 探討了在 Java 程序中使用非整數(shù)類型時(shí)一些常碰到的陷阱和“gotcha”。請?jiān)诒疚牡?論壇上提出您對本文的想法,以饗筆者和其他讀者。(您也可以單擊本文頂部或底部的討論來訪問論壇)。
雖然幾乎每種處理器和編程語言都支持浮點(diǎn)運(yùn)算,但大多數(shù)程序員很少注意它。這容易理解 ― 我們中大多數(shù)很少需要使用非整數(shù)類型。除了科學(xué)計(jì)算和偶爾的計(jì)時(shí)測試或基準(zhǔn)測試程序,其它情況下幾乎都用不著它。同樣,大多數(shù)開發(fā)人員也容易忽略 java.math.BigDecimal
所提供的任意精度的小數(shù) ― 大多數(shù)應(yīng)用程序不使用它們。然而,在以整數(shù)為主的程序中有時(shí)確實(shí)會(huì)出人意料地需要表示非整型數(shù)據(jù)。例如,JDBC 使用 BigDecimal
作為 SQL DECIMAL
列的首選互換格式。
Java 語言支持兩種基本的浮點(diǎn)類型: float
和 double
,以及與它們對應(yīng)的包裝類 Float
和 Double
。它們都依據(jù) IEEE 754 標(biāo)準(zhǔn),該標(biāo)準(zhǔn)為 32 位浮點(diǎn)和 64 位雙精度浮點(diǎn)二進(jìn)制小數(shù)定義了二進(jìn)制標(biāo)準(zhǔn)。
IEEE 754 用科學(xué)記數(shù)法以底數(shù)為 2 的小數(shù)來表示浮點(diǎn)數(shù)。IEEE 浮點(diǎn)數(shù)用 1 位表示數(shù)字的符號,用 8 位來表示指數(shù),用 23 位來表示尾數(shù),即小數(shù)部分。作為有符號整數(shù)的指數(shù)可以有正負(fù)之分。小數(shù)部分用二進(jìn)制(底數(shù) 2)小數(shù)來表示,這意味著最高位對應(yīng)著值 ?(2 -1),第二位對應(yīng)著 ?(2 -2),依此類推。對于雙精度浮點(diǎn)數(shù),用 11 位表示指數(shù),52 位表示尾數(shù)。IEEE 浮點(diǎn)值的格式如圖 1 所示。
圖 1. IEEE 754 浮點(diǎn)數(shù)的格式

因?yàn)橛每茖W(xué)記數(shù)法可以有多種方式來表示給定數(shù)字,所以要規(guī)范化浮點(diǎn)數(shù),以便用底數(shù)為 2 并且小數(shù)點(diǎn)左邊為 1 的小數(shù)來表示,按照需要調(diào)節(jié)指數(shù)就可以得到所需的數(shù)字。所以,例如,數(shù) 1.25 可以表示為尾數(shù)為 1.01,指數(shù)為 0: (-1) 0*1.01 2*2 0
數(shù) 10.0 可以表示為尾數(shù)為 1.01,指數(shù)為 3: (-1) 0*1.01 2*2 3
除了編碼所允許的值的標(biāo)準(zhǔn)范圍(對于 float
,從 1.4e-45 到 3.4028235e+38),還有一些表示無窮大、負(fù)無窮大、 -0
和 NaN(它代表“不是一個(gè)數(shù)字”)的特殊值。這些值的存在是為了在出現(xiàn)錯(cuò)誤條件(譬如算術(shù)溢出,給負(fù)數(shù)開平方根,除以 0
等)下,可以用浮點(diǎn)值集合中的數(shù)字來表示所產(chǎn)生的結(jié)果。
這些特殊的數(shù)字有一些不尋常的特征。例如, 0
和 -0
是不同值,但在比較它們是否相等時(shí),被認(rèn)為是相等的。用一個(gè)非零數(shù)去除以無窮大的數(shù),結(jié)果等于 0
。特殊數(shù)字 NaN 是無序的;使用 ==
、 <
和 >
運(yùn)算符將 NaN 與其它浮點(diǎn)值比較時(shí),結(jié)果為 false
。如果 f
為 NaN,則即使 (f == f)
也會(huì)得到 false
。如果想將浮點(diǎn)值與 NaN 進(jìn)行比較,則使用 Float.isNaN()
方法。表 1 顯示了無窮大和 NaN 的一些屬性。
表達(dá)式 | 結(jié)果 |
Math.sqrt(-1.0)
|
-> NaN
|
0.0 / 0.0
|
-> NaN
|
1.0 / 0.0
|
-> 無窮大
|
-1.0 / 0.0
|
-> 負(fù)無窮大
|
NaN + 1.0
|
-> NaN
|
無窮大 + 1.0
|
-> 無窮大
|
無窮大 + 無窮大
|
-> 無窮大
|
NaN > 1.0
|
-> false
|
NaN == 1.0
|
-> false
|
NaN < 1.0
|
-> false
|
NaN == NaN
|
-> false
|
0.0 == -0.01
|
-> true
|
基本浮點(diǎn)類型和包裝類浮點(diǎn)有不同的比較行為
使事情更糟的是,在基本 float
類型和包裝類 Float
之間,用于比較 NaN 和 -0
的規(guī)則是不同的。對于 float
值,比較兩個(gè) NaN 值是否相等將會(huì)得到 false
,而使用 Float.equals()
來比較兩個(gè) NaN Float
對象會(huì)得到 true
。造成這種現(xiàn)象的原因是,如果不這樣的話,就不可能將 NaN Float
對象用作 HashMap
中的鍵。類似的,雖然 0
和 -0
在表示為浮點(diǎn)值時(shí),被認(rèn)為是相等的,但使用 Float.compareTo()
來比較作為 Float
對象的 0
和 -0
時(shí),會(huì)顯示 -0
小于 0
。
![]() ![]() |
![]()
|
由于無窮大、NaN 和 0
的特殊行為,當(dāng)應(yīng)用浮點(diǎn)數(shù)時(shí),可能看似無害的轉(zhuǎn)換和優(yōu)化實(shí)際上是不正確的。例如,雖然好象 0.0-f
很明顯等于 -f
,但當(dāng) f
為 0
時(shí),這是不正確的。還有其它類似的 gotcha,表 2 顯示了其中一些 gotcha。
這個(gè)表達(dá)式…… | 不一定等于…… | 當(dāng)…… |
0.0 - f
|
-f
|
f 為 0 |
f < g
|
! (f >= g)
|
f 或 g 為 NaN |
f == f
|
true
|
f 為 NaN |
f + g - g
|
f
|
g 為無窮大或 NaN |
浮點(diǎn)運(yùn)算很少是精確的。雖然一些數(shù)字(譬如 0.5
)可以精確地表示為二進(jìn)制(底數(shù) 2)小數(shù)(因?yàn)?0.5
等于 2 -1),但其它一些數(shù)字(譬如 0.1
)就不能精確的表示。因此,浮點(diǎn)運(yùn)算可能導(dǎo)致舍入誤差,產(chǎn)生的結(jié)果接近 ― 但不等于 ― 您可能希望的結(jié)果。例如,下面這個(gè)簡單的計(jì)算將得到 2.600000000000001
,而不是 2.6
:
|
類似的, .1*26
相乘所產(chǎn)生的結(jié)果不等于 .1
自身加 26 次所得到的結(jié)果。當(dāng)將浮點(diǎn)數(shù)強(qiáng)制轉(zhuǎn)換成整數(shù)時(shí),產(chǎn)生的舍入誤差甚至更嚴(yán)重,因?yàn)閺?qiáng)制轉(zhuǎn)換成整數(shù)類型會(huì)舍棄非整數(shù)部分,甚至對于那些“看上去似乎”應(yīng)該得到整數(shù)值的計(jì)算,也存在此類問題。例如,下面這些語句:
|
將得到以下輸出:
|
這可能不是您起初所期望的。
![]() ![]() |
![]()
|
由于存在 NaN 的不尋常比較行為和在幾乎所有浮點(diǎn)計(jì)算中都不可避免地會(huì)出現(xiàn)舍入誤差,解釋浮點(diǎn)值的比較運(yùn)算符的結(jié)果比較麻煩。
最好完全避免使用浮點(diǎn)數(shù)比較。當(dāng)然,這并不總是可能的,但您應(yīng)該意識(shí)到要限制浮點(diǎn)數(shù)比較。如果必須比較浮點(diǎn)數(shù)來看它們是否相等,則應(yīng)該將它們差的絕對值同一些預(yù)先選定的小正數(shù)進(jìn)行比較,這樣您所做的就是測試它們是否“足夠接近”。(如果不知道基本的計(jì)算范圍,可以使用測試“abs(a/b - 1) < epsilon”,這種方法比簡單地比較兩者之差要更準(zhǔn)確)。甚至測試看一個(gè)值是比零大還是比零小也存在危險(xiǎn) ―“以為”會(huì)生成比零略大值的計(jì)算事實(shí)上可能由于積累的舍入誤差會(huì)生成略微比零小的數(shù)字。
NaN 的無序性質(zhì)使得在比較浮點(diǎn)數(shù)時(shí)更容易發(fā)生錯(cuò)誤。當(dāng)比較浮點(diǎn)數(shù)時(shí),圍繞無窮大和 NaN 問題,一種避免 gotcha 的經(jīng)驗(yàn)法則是顯式地測試值的有效性,而不是試圖排除無效值。在清單 1 中,有兩個(gè)可能的用于特性的 setter 的實(shí)現(xiàn),該特性只能接受非負(fù)數(shù)值。第一個(gè)實(shí)現(xiàn)會(huì)接受 NaN,第二個(gè)不會(huì)。第二種形式比較好,因?yàn)樗@式地檢測了您認(rèn)為有效的值的范圍。
清單 1. 需要非負(fù)浮點(diǎn)值的較好辦法和較差辦法
|
一些非整數(shù)值(如幾美元和幾美分這樣的小數(shù))需要很精確。浮點(diǎn)數(shù)不是精確值,所以使用它們會(huì)導(dǎo)致舍入誤差。因此,使用浮點(diǎn)數(shù)來試圖表示象貨幣量這樣的精確數(shù)量不是一個(gè)好的想法。使用浮點(diǎn)數(shù)來進(jìn)行美元和美分計(jì)算會(huì)得到災(zāi)難性的后果。浮點(diǎn)數(shù)最好用來表示象測量值這類數(shù)值,這類值從一開始就不怎么精確。
![]() ![]() |
![]()
|
從 JDK 1.3 起,Java 開發(fā)人員就有了另一種數(shù)值表示法來表示非整數(shù): BigDecimal
。 BigDecimal
是標(biāo)準(zhǔn)的類,在編譯器中不需要特殊支持,它可以表示任意精度的小數(shù),并對它們進(jìn)行計(jì)算。在內(nèi)部,可以用任意精度任何范圍的值和一個(gè)換算因子來表示 BigDecimal
,換算因子表示左移小數(shù)點(diǎn)多少位,從而得到所期望范圍內(nèi)的值。因此,用 BigDecimal
表示的數(shù)的形式為 unscaledValue*10 -scale
。
用于加、減、乘和除的方法給 BigDecimal
值提供了算術(shù)運(yùn)算。由于 BigDecimal
對象是不可變的,這些方法中的每一個(gè)都會(huì)產(chǎn)生新的 BigDecimal
對象。因此,因?yàn)閯?chuàng)建對象的開銷, BigDecimal
不適合于大量的數(shù)學(xué)計(jì)算,但設(shè)計(jì)它的目的是用來精確地表示小數(shù)。如果您正在尋找一種能精確表示如貨幣量這樣的數(shù)值,則 BigDecimal
可以很好地勝任該任務(wù)。
如浮點(diǎn)類型一樣, BigDecimal
也有一些令人奇怪的行為。尤其在使用 equals()
方法來檢測數(shù)值之間是否相等時(shí)要小心。 equals()
方法認(rèn)為,兩個(gè)表示同一個(gè)數(shù)但換算值不同(例如, 100.00
和 100.000
)的 BigDecimal
值是不相等的。然而, compareTo()
方法會(huì)認(rèn)為這兩個(gè)數(shù)是相等的,所以在從數(shù)值上比較兩個(gè) BigDecimal
值時(shí),應(yīng)該使用 compareTo()
而不是 equals()
。
另外還有一些情形,任意精度的小數(shù)運(yùn)算仍不能表示精確結(jié)果。例如, 1
除以 9
會(huì)產(chǎn)生無限循環(huán)的小數(shù) .111111...
。出于這個(gè)原因,在進(jìn)行除法運(yùn)算時(shí), BigDecimal
可以讓您顯式地控制舍入。 movePointLeft()
方法支持 10 的冪次方的精確除法。
SQL-92 包括 DECIMAL
數(shù)據(jù)類型,它是用于表示定點(diǎn)小數(shù)的精確數(shù)字類型,它可以對小數(shù)進(jìn)行基本的算術(shù)運(yùn)算。一些 SQL 語言喜歡稱此類型為 NUMERIC
類型,其它一些 SQL 語言則引入了 MONEY
數(shù)據(jù)類型,MONEY 數(shù)據(jù)類型被定義為小數(shù)點(diǎn)右側(cè)帶有兩位的小數(shù)。
如果希望將數(shù)字存儲(chǔ)到數(shù)據(jù)庫中的 DECIMAL
字段,或從 DECIMAL
字段檢索值,則如何確保精確地轉(zhuǎn)換該數(shù)字?您可能不希望使用由 JDBC PreparedStatement
和 ResultSet
類所提供的 setFloat()
和 getFloat()
方法,因?yàn)楦↑c(diǎn)數(shù)與小數(shù)之間的轉(zhuǎn)換可能會(huì)喪失精確性。相反,請使用 PreparedStatement
和 ResultSet
的 setBigDecimal()
及 getBigDecimal()
方法。
對于 BigDecimal
,有幾個(gè)可用的構(gòu)造函數(shù)。其中一個(gè)構(gòu)造函數(shù)以雙精度浮點(diǎn)數(shù)作為輸入,另一個(gè)以整數(shù)和換算因子作為輸入,還有一個(gè)以小數(shù)的 String
表示作為輸入。要小心使用 BigDecimal(double)
構(gòu)造函數(shù),因?yàn)槿绻涣私馑瑫?huì)在計(jì)算過程中產(chǎn)生舍入誤差。請使用基于整數(shù)或 String
的構(gòu)造函數(shù)。
對于 BigDecimal
,有幾個(gè)可用的構(gòu)造函數(shù)。其中一個(gè)構(gòu)造函數(shù)以雙精度浮點(diǎn)數(shù)作為輸入,另一個(gè)以整數(shù)和換算因子作為輸入,還有一個(gè)以小數(shù)的 String
表示作為輸入。要小心使用 BigDecimal(double)
構(gòu)造函數(shù),因?yàn)槿绻涣私馑瑫?huì)在計(jì)算過程中產(chǎn)生舍入誤差。請使用基于整數(shù)或 String
的構(gòu)造函數(shù)。
如果使用 BigDecimal(double)
構(gòu)造函數(shù)不恰當(dāng),在傳遞給 JDBC setBigDecimal()
方法時(shí),會(huì)造成似乎很奇怪的 JDBC 驅(qū)動(dòng)程序中的異常。例如,考慮以下 JDBC 代碼,該代碼希望將數(shù)字 0.01
存儲(chǔ)到小數(shù)字段:
|
在執(zhí)行這段似乎無害的代碼時(shí)會(huì)拋出一些令人迷惑不解的異常(這取決于具體的 JDBC 驅(qū)動(dòng)程序),因?yàn)?0.01
的雙精度近似值會(huì)導(dǎo)致大的換算值,這可能會(huì)使 JDBC 驅(qū)動(dòng)程序或數(shù)據(jù)庫感到迷惑。JDBC 驅(qū)動(dòng)程序會(huì)產(chǎn)生異常,但可能不會(huì)說明代碼實(shí)際上錯(cuò)在哪里,除非意識(shí)到二進(jìn)制浮點(diǎn)數(shù)的局限性。相反,使用 BigDecimal("0.01")
或 BigDecimal(1, 2)
構(gòu)造 BigDecimal
來避免這類問題,因?yàn)檫@兩種方法都可以精確地表示小數(shù)。
![]() ![]() |
![]()
|
在 Java 程序中使用浮點(diǎn)數(shù)和小數(shù)充滿著陷阱。浮點(diǎn)數(shù)和小數(shù)不象整數(shù)一樣“循規(guī)蹈矩”,不能假定浮點(diǎn)計(jì)算一定產(chǎn)生整型或精確的結(jié)果,雖然它們的確“應(yīng)該”那樣做。最好將浮點(diǎn)運(yùn)算保留用作計(jì)算本來就不精確的數(shù)值,譬如測量。如果需要表示定點(diǎn)數(shù)(譬如,幾美元和幾美分),則使用 BigDecimal
。
posted on 2006-08-24 17:51 Binary 閱讀(252) 評論(0) 編輯 收藏 所屬分類: j2se