iT邦幫忙

2023 iThome 鐵人賽

DAY 2
1
Software Development

救救我啊我救我!CRUD 工程師的惡補日記系列 第 2

【Java】使用 BigDecimal 進行精確運算

  • 分享至 

  • xImage
  •  

有句知名的話是「算錢用浮點,遲早被人扁」。在進行財務相關的計算時,在匯率、利率或外幣等項目,經常會遇到小數點。然而在電腦的世界,浮點數的資料型態本身是會有誤差的(與程式語言無關)。因此在精確度要求很高的條件下,我們應避免用 doublefloat 型態來計算。

本文會先簡述為什麼浮點數不精確,再介紹 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中浮點型數據的存儲方式

二、宣告與輸出 BigDecimal

為了在數值運算上能達到精確,在 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 可執行如 intValuedoubleValue 之類的方法,轉換回基本型態。另外還有一些像是 intValueExact 的方法,其作用是在 BigDecimal 含有小數,或是超出基本型態的範圍時,會拋出 ArithmeticException

三、小數進位與捨去

為了方便閱讀後面的除法範例,首先讓我們先認識 BigDecimal 的小數進位和捨去方式。只要呼叫 setScale 方法,再定義要留到哪一位,並傳入 RoundMode 型態的參數,指定進位或捨去的方式即可。

下面列舉一部份 RoundMode 可用的選項,本文只示範「無條件進位」、「無條件捨去」與「四捨五入」。

(一)CEILING

使用 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)。因此每次操作後,都會生成新物件,我們要將回傳值給接起來。

(二)FLOOR

使用 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

使用 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

使用 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

Ref:RoundingMode 幾個參數詳解

四、使用 BigDecimal 運算

(一)加法與減法

使用 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

五、效率比較

最後稍微比較一下 BigDecimaldouble 各自的效率。以下的程式,是測量從 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 為了達到精確運算,且每次操作都會生成一個新物件,所以需花費較多的效能。

六、例題

以下是計算定期存款本利和的範例程式,假設情境如下:

  • 存入金額 50 萬
  • 年利率 1.59%
  • 存期 1 年
  • 每個月複利一次
  • 計算利息時,四捨五入到個位數
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。


今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教/images/emoticon/emoticon41.gif


上一篇
第一次參加鐵人賽的動機
下一篇
Java 8 推出的日期時間套件(上)
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言