算錢?不過就是數字加減乘除之類的吧!然而,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,不過,其他語言或程式庫不見得是如此,猜猜看,Python 3的round()函式採用的是哪種?答案是銀行家捨入法!因此,Python 3中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〉(https://goo.gl/aNWEao)介紹過Money模式,其中也提供了程式碼實作,不過,其中少了對貨幣的格式化描述,在格式化方面,Java可以使用java.text.DecimalFormat來達到,類似地,由於格式化時,會涉及捨入問題,DecimalFormat從JDK6開始,也接受了RoundingMode的設定。

第三方程式庫或JSR354

如果不想從頭自行實作這一切,可以考慮第三方程式庫,像是Joda-Money(https://goo.gl/x55CPs),它是由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、各種捨入模式、金錢模型、格式化到貨幣轉換,算錢可真是不是件容易的事,身為開發者的你,是否曾經認真地搞清楚每個環節了呢?

作者簡介


Advertisement

更多 iThome相關內容