算錢?不過就是數字加減乘除之類的吧!然而Martin Fowler曾經說過:「這個世界上許多電腦都在處理錢,我老覺得疑惑的是,沒有任何主流程式語言把錢當成一級資料型態來看待。」這不禁讓人自問,算錢真的有那麼難?
- 惱人的小數點?
在Google搜尋中鍵入「算錢」,知道為什麼會出現「算錢用浮點,遲早被人扁」的建議搜尋嗎?實際上,在電腦中浮點數計算會有誤差這件事,許多開發 者並不知道,遇到1.0 - 0.8的結果會是0.19999999999999996的類似場合,抓著頭髮脫口What的大有人在,更怕的表面風平浪靜,直到有一天突然出現致命誤差,而被業務或者財 務會計人員追殺之時。
六、七十年代各型號電腦有著各式的浮點數表示法,後來IEEE 754浮點數運算標準出來一統江湖,簡單來說,IEEE 754會有符號位(Sign)、指數(Exponent)與小數(Fraction)部份,對於一個浮點數,必須先將數值轉為二進位,然後經過一些步驟儲存符號、指數與小 數,例如只有小數部份時,轉為二進位的第一步會是:
0.5 = 1/2 => 0.1
0.75 = 1/2 + 1/4 => 0.11
0.875 = 1/2 + 1/4 + 1/8 > 0.111
而當遇上0.1這類的數值時,會是1/16 + 1/32 + 1/256 + 1/512 +1/4096 + 1/8192 +...無止境下去,實際上電腦沒辦法無止境地儲存位數,勢必造成誤差,因此,不要將浮點數用於嚴謹的財務計算之類的場合,解決的方式之是使用整數並進行大數運算,例如 10.25可使用1025加上位數(scale)表示,基本上,程式語言通常都會有協助大數運算的程式庫(標準程式庫或者第三方程式庫),像是 Java的
java.math.BigDecimal
。然而使用這些程式庫會有一些必須注意的地方,例如,在使用
BigDecimal
的時候,建議使用BigDecimal(String)
而
不是BigDecimal(double)
來建立實例,因為像BigDecimal(0.1)
實
際上會是0.1000000000000000055511151231257827021181583404541015625,只有BigDecimal("0.1")
才
會是表示0.1,另一個要注意的是,BigDecimal(double)
並不等同於Double.valueOf(double)
後
呼叫BigDecimal(String)
,這個需求建議使用BigDecimal.valueOf(double)
。- 不同的進位捨去法
在算錢時會遇到必須進位捨去的情況,不同的國家會採用不同的方式,涉及錢的問題時,開發者必須清楚採用哪一種方式。一般人概念中最常有的捨入概念,應 該是四捨五入、無條件進位或捨去,如果是正值的話比較容易理解,5.4套用這三者取整數的結果分別會是5、6.0與5.0,在Java中可以分別使用
Math
的
靜態方法round()
、ceil()
與floor()
來計算,那
麼-5.4呢?套用這三個方法的結果分別會是-5、-5.0、-6.0!round()
實際上是向最接近數字方向的捨入操作,結果為-5並不意外;ceil()
是往正
方向的捨入,因此-5.4的正方向就是-5.0;floor()
是往負方向的捨入,-5.4的負方向是-6.0。Math
的
靜態方法round()
、ceil()
、floor()
只保留整數
部份,遇到想指定小數位數的時候,許多開發者會自行設計公式來計算,然而,可以使用BigDecimal
在一些計算操作時
指定捨入模式,早期的JDK是使用BigDecimal
的整數常數來列舉,從JDK5之後,可使用RoundingMode
列
舉型態的成員,round()
、ceil()
、floor()
的三
種捨入模式分別對應至HALF_UP
、CEILING
與FLOOR
。RoundingMode
的UP
捨入模式,會遠離0的方向,被捨棄的部份若不是0,左邊的數字
一律遞增1,因此5.4若只保留整數,操作後會是6,-5.4操作後會是-6;DOWN
會接近0的方向,5.4操作後會是
5,-5.4操作後會是-5;HALF_UP
與HALF_DOWN
都會向最接近數字方向進行捨
入操作,不同的是,若最接近的數字距離相同,前者進位而後者捨去,因此5.5的HALF_UP
會是6,而HALF_DOWN
會
是5。HALF_EVEN
的模式比較難理解一些,雖然會往最接近數字方向進行捨入操作,不過,若最接近的數字距離相同,是向相鄰
的偶數捨入,因此HALF_EVEN
時,若捨去部份的左邊為奇數,那麼行為就像是HALF_UP
,
因此5.5會成為6,若為偶數,那麼行為上就像是HALF_DOWN
,因此4.5會成為4,這種捨入法又稱銀行家捨入法
(Banker's rounding)或者四捨五入取偶數(round-to-even)。開發者必須得小心的是,搞清楚目前使用的程式庫對於捨入的行為究竟為何,Java的
Math.round()
採用的是HALF_UP
,
不過其他語言或程式庫不見得是如此,猜猜看,Python3的round()
函式採用的是哪種?答案是銀行家捨入法!因
此,Python3中round(5.5)
是6,而round(4.5)
會是4。- 為金錢建立模型
如果處理的不只有一種貨幣,那麼處理錢除了數量之外,還必須考量貨幣單位,以及貨幣之間的計算與轉換問題。從JDK1.4開始,基本上可使用
java.util.Currency
這
個類別來代表貨幣,而貨幣的量使用BigDecimal
來儲存,為了方便,可以定義一個Money
類
別來封裝這兩個物件,並提供加、減、乘、除等操作,以及大於、等於、小於等金錢比較,這時也就必須要根據不同國家或地區,採用不同的進位捨去觀念。使用
BigDecimal
在設定小數位數時,記得指定的是scale
相關參數或者使用scale
相
關方法,而不是precision
;precision
是指從數字最左邊不是0的數字開始,直
到最右邊使用的數字個數;在建構BigDecimal
時,也可以使用BigInteger
指定unscaled
值,
並使用scale
指定小數位數。使用BigDecimal
時記得它是不可變動物件,每個操作都
會傳回新的BigDecimal
實例。如果需要程式碼參考的話,Martin Flower在〈Representing money〉介紹過Money模式,其中也提供了程式碼實作,不過,其中少了對貨幣的格式化描述,在格式化方面,Java可以使用
java.text.DecimalFormat
來
達到,類似地,格式化時會涉及捨入問題,DecimalFormat
從JDK6開始也接受了RoundingMode
的
設定。- 第三方程式庫或JSR354
如果不想從頭自行實作這一切的話,可以考慮第三方程式庫,像是Joda-Money, 它是由Stephen Colebourne建立,一個很精簡的程式庫,建議閱讀它的原始碼,這不需要花費很長的時間,也能從中瞭解到如何使用這個程式庫。Joda-Money缺少貨幣轉換方 案,雖然
Money
類別提供了convertedTo
方法,然而必須自行獲取匯率並封裝為BigDecimal
實
例,然後呼叫convertedTo
時一併指定CurrencyUnit
與RoundingMode
。Java標準本身為了解決貨幣問題,制定了JSR354金錢與貨幣API(Money and Currency API),它本來打算放到Java 9,後來JSR354成員覺得太燥進了,決定不放入Java 9,而是做為一個獨立規格,專案名稱為JavaMoney(https://goo.gl/UbbiMu)。JSR354提供貨幣轉換方案,在匯率轉換上,目前有基於歐洲 央行(European Central Bank)與國際貨幣基金(International Monetary Fund)公佈數據的預設實作。
涉及錢的問題,從浮點數、BigDecimal、各種捨入模式、金錢模型、格式化到貨幣轉換,算錢可真是不是件容易的事,身為開發者的你,是否曾經認 真地搞清楚每個環節了呢?