算錢學問大




算錢?不過就是數字加減乘除之類的吧!然而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_UPCEILINGFLOOR

RoundingModeUP捨入模式,會遠離0的方向,被捨棄的部份若不是0,左邊的數字 一律遞增1,因此5.4若只保留整數,操作後會是6,-5.4操作後會是-6;DOWN會接近0的方向,5.4操作後會是 5,-5.4操作後會是-5;HALF_UPHALF_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相 關方法,而不是precisionprecision是指從數字最左邊不是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時一併指定CurrencyUnitRoundingMode

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、各種捨入模式、金錢模型、格式化到貨幣轉換,算錢可真是不是件容易的事,身為開發者的你,是否曾經認 真地搞清楚每個環節了呢?