有句知名的話是「算錢用浮點,遲早被人扁」。在進行財務相關的計算時,在匯率、利率或外幣等項目,經常會遇到小數點。然而在電腦的世界,浮點數的資料型態本身是會有誤差的(與程式語言無關)。因此在精確度要求很高的條件下,我們應避免用 double
、float
型態來計算。
本文會先簡述為什麼浮點數不精確,再介紹 Java 的 BigDecimal
及其操作方式。它的運算結果會與我們對數學的認知一致。
此篇亦轉載到個人部落格。
由於電腦是以二進制的科學記號法來儲存浮點數,所以會造成誤差,以下分別說明。
國中數學課有教到「科學記號」,它的好處是能夠將很大或很小的數值,用簡單的方式表達出來。
打個比方,光速大約是每秒 2.9979 億公尺,那麼「2.9979 億」的科學記號寫成「2.9979 × 10^8」,或者表示成「2.9979E+8」。若某細菌的長度是 1.2 微米,則寫成「1.2 × 10 ^(-6)」或「1.2E-6」。
以 double
為例,它在記憶體會佔用 64 位元。包含正負符號 1 位元、指數 11 位元與小數 52 位元。
若要儲存十進制 20.5,則將其轉為二進制 10100.1,以科學記號表示成 1.01001 × 2^100。
若要儲存十進制 20.3,則將其轉為二進制 10100.0 1001 1001 …,以科學記號表示成 1.0 1001 1001… × 2^100。
由以上兩個例子能發現,將十進制轉為二進制時,有的數字是可以完整轉換小數(很像整除的感覺)。而像 20.3 是無法完整轉換小數,它出現了「1001」的循環。
討論誤差時,我們關注小數的地方就好。在把小數儲存到記憶體時,若在轉換過程中,52 位元不夠用(如 20.3 發生循環),未轉換完的部份就被捨棄。這就是為什麼浮點數會有誤差的原因。
Ref:JAVA中浮點型數據的存儲方式
為了在數值運算上能達到精確,在 Java 語言可採用 BigDecimal
類別。該類別的建構子可傳入字串、整數或浮點數。
var amount = new BigDecimal("-2000");
var rate = new BigDecimal("30.785");
int amountInt = amount.intValue();
System.out.println(amountInt); // -2000
int rateInt = rate.intValue();
System.out.println(rateInt); // 30
double rateDouble = rate.doubleValue();
System.out.println(rateDouble); // 30.785
BigDecimal
可執行如 intValue
、doubleValue
之類的方法,轉換回基本型態。另外還有一些像是 intValueExact
的方法,其作用是在 BigDecimal
含有小數,或是超出基本型態的範圍時,會拋出 ArithmeticException
。
為了方便閱讀後面的除法範例,首先讓我們先認識 BigDecimal
的小數進位和捨去方式。只要呼叫 setScale
方法,再定義要留到哪一位,並傳入 RoundMode
型態的參數,指定進位或捨去的方式即可。
下面列舉一部份 RoundMode
可用的選項,本文只示範「無條件進位」、「無條件捨去」與「四捨五入」。
使用 RoundMode.CEILING
,可讓數值往正方向進位。
var d1 = new BigDecimal("1.73");
d1 = d1.setScale(1, RoundingMode.CEILING);
System.out.println(d1); // 1.8
var d2 = new BigDecimal("-1.73");
d2 = d2.setScale(1, RoundingMode.CEILING);
System.out.println(d2); // -1.7
這邊請注意到,BigDecimal
是不可變的(immutable)。因此每次操作後,都會生成新物件,我們要將回傳值給接起來。
使用 RoundMode.FLOOR
,可讓數值往負方向捨去。
var d1 = new BigDecimal("3.1415");
d1 = d1.setScale(3, RoundingMode.FLOOR);
System.out.println(d1); // 3.141
var d2 = new BigDecimal("-3.1415");
d2 = d2.setScale(3, RoundingMode.FLOOR);
System.out.println(d2); // -3.142
使用 DOWN
,可讓數值往 0 的方向捨去。
var d1 = new BigDecimal("1.234");
d1 = d1.setScale(2, RoundingMode.DOWN);
System.out.println(d1); // 1.23
var d2 = new BigDecimal("-1.234");
d2 = d2.setScale(1, RoundingMode.DOWN);
System.out.println(d2); // -1.2
使用 HALF_UP
,會讓正數與負數,分別往正方向與負方向做四捨五入。也就是絕對值的四捨五入。
var d1 = new BigDecimal("1.234");
d1 = d1.setScale(2, RoundingMode.HALF_UP);
System.out.println(d1); // 1.23
var d2 = new BigDecimal("2.345");
d2 = d2.setScale(2, RoundingMode.HALF_UP);
System.out.println(d2); // 2.35
var d3 = new BigDecimal("-1.234");
d3 = d3.setScale(2, RoundingMode.HALF_UP);
System.out.println(d3); // -1.23
var d4 = new BigDecimal("-2.345");
d4 = d4.setScale(2, RoundingMode.HALF_UP);
System.out.println(d4); // -2.35
使用 add
方法,可進行加法。別忘了要將計算結果給接起來。
var d1 = new BigDecimal("1.23");
var d2 = new BigDecimal("4.56");
var d3 = new BigDecimal("-1");
var result1 = d1.add(d2);
System.out.println(result1); // 5.79
var result2 = d1.add(d3);
System.out.println(result2); // 0.23
使用 subtract
方法,可進行減法。
var d1 = new BigDecimal("4");
var d2 = new BigDecimal("1");
var d3 = new BigDecimal("10");
var result1 = d1.subtract(d2);
System.out.println(result1); // 3
var result2 = d1.subtract(d3);
System.out.println(result2); // -6
var result3 = result1.subtract(result2);
System.out.println(result3); // 9
使用 multiply
方法,可進行乘法。
var d1 = new BigDecimal("3.14");
var d2 = new BigDecimal("-100");
var result = d1.multiply(d2);
System.out.println(result); // -314.00
System.out.println(result.stripTrailingZeros()); // -314
從範例中可看到,若參與運算的數值含有小數,即便算完後的小數為 0,BigDecimal
仍會保留小數。呼叫 stripTrailingZeros
方法後便能清除。
使用 divide
方法,可進行除法。由於可能會產生小數,所以必須提供小數的進位或捨去方式,以及到第幾位。
var d1 = new BigDecimal("16");
var d2 = new BigDecimal("3");
var result = d1.divide(d2, 3, RoundingMode.HALF_UP);
System.out.println(result); // 5.333
使用 divideAndRemainder
方法,可同時取得商和餘數。
var d1 = new BigDecimal("16");
var d2 = new BigDecimal("3");
var d3 = new BigDecimal("3.5");
BigDecimal[] result1 = d1.divideAndRemainder(d2);
System.out.println(result1[0]); // 5
System.out.println(result1[1]); // 1
BigDecimal[] result2 = d1.divideAndRemainder(d3);
System.out.println(result2[0]); // 4
System.out.println(result2[1]); // 2.0
使用 pow
方法,可以計算數值的冪數,也就是 n 次方。
var d = new BigDecimal("1.1");
var result = d.pow(4);
System.out.println(result); // 1.4641
BigDecimal
有實作 Comparable
介面,所以可呼叫 compareTo
方法來比較大小。
var d1 = new BigDecimal(5);
var d2 = new BigDecimal("8.2");
var d3 = new BigDecimal(10);
var d4 = new BigDecimal("10.00");
System.out.println(d1.compareTo(d2)); // -1
System.out.println(d3.compareTo(d2)); // 1
System.out.println(d3.compareTo(d4)); // 0
Ref:BigDecimal
最後稍微比較一下 BigDecimal
與 double
各自的效率。以下的程式,是測量從 0 加到 1 千萬的時間。
var repeat = 10000000;
// double
var value1 = 0.0d;
var startTime1 = System.currentTimeMillis();
for (var i = 0; i < repeat; i++) {
value1 = value1 + 1.0;
}
System.out.println(System.currentTimeMillis() - startTime1);
// BigDecimal
var value2 = new BigDecimal("0.0");
var startTime2 = System.currentTimeMillis();
for (var i = 0; i < repeat; i++) {
value2 = value2.add(BigDecimal.ONE);
}
System.out.println(System.currentTimeMillis() - startTime2);
在筆者的電腦上跑過後,double
約花費 13 ~ 15 毫秒;而 BigDecimal
約花費 180 ~ 270 毫秒。推測是 BigDecimal
為了達到精確運算,且每次操作都會生成一個新物件,所以需花費較多的效能。
以下是計算定期存款本利和的範例程式,假設情境如下:
var deposit = new BigDecimal("500000");
var rateOfYear = new BigDecimal("0.0159");
var twelve = new BigDecimal("12");
for (var i = 0; i < 12; i++) {
var monthlyInterest = deposit
.multiply(rateOfYear)
.divide(twelve, 0, RoundingMode.HALF_UP);
deposit = deposit.add(monthlyInterest);
}
System.out.println(deposit); // 508008
計算結果為 508008。
若將小數點的處理方式改為「無條件進位」(RoundMode.CEILING
)和「無條件捨去」(RoundMode.FLOOR
),計算結果分別為 508003 與 508015。
今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教