BigDecimal w praktyce — jak nie zgubić grosza w Javie
Ceny w Javie: tworzenie BigDecimal, porównywanie kwot, zaokrąglanie i reguły rozdzielania reszty.
BigDecimal w praktyce — jak nie zgubić grosza w Javie
Obliczenie ceny wygląda niewinnie: cena jednostkowa razy liczba sztuk, do tego rabat i podatek. Problemy zaczynają się wtedy, gdy różne fragmenty aplikacji inaczej rozumieją dokładność, równość kwot albo moment zaokrąglenia.
BigDecimal jest dobrym narzędziem do obliczeń dziesiętnych. Nie zastępuje jednak zasad biznesowych. Żeby wynik był przewidywalny, trzeba ustalić je równie starannie jak typ zmiennej.
Zacznij od poprawnej wartości
Liczby takie jak 0.1 nie mają dokładnej reprezentacji w binarnym double. Konstruktor przyjmujący double zachowuje jego rzeczywistą wartość, wraz z tym przybliżeniem. Dla stałej dziesiętnej wybierz tekst:
BigDecimal price = new BigDecimal("19.99");
BigDecimal rate = new BigDecimal("0.10");
BigDecimal.valueOf(19.99) jest rozsądniejsze niż new BigDecimal(19.99), ale konwersja nie naprawi błędów wcześniejszych obliczeń na double. Najlepiej utrzymywać reprezentację dziesiętną od wejścia do wyjścia. Te różnice opisuje API BigDecimal.
Wartość i zapis to różne rzeczy
BigDecimal first = new BigDecimal("10.0");
BigDecimal second = new BigDecimal("10.00");
boolean sameRepresentation = first.equals(second); // false
boolean sameValue = first.compareTo(second) == 0; // true
equals() uwzględnia skalę, a compareTo() porównuje wartość liczbową. Ma to znaczenie także dla kolekcji: klucze w HashMap korzystają z równości i kodu skrótu, nie z porównania liczbowego. Przy kluczach reprezentujących kwoty potrzebna jest uzgodniona normalizacja.
Nie warto mechanicznie zamieniać każdego equals() na compareTo(). Jeśli test sprawdza format wyniku „dokładnie dwa miejsca po przecinku”, skala jest częścią wymagań.
Zasada zaokrąglania należy do domeny
Przyjmijmy dla przykładu, że rabat odejmujemy od wartości całej pozycji, a wynik zaokrąglamy na końcu do dwóch miejsc. To założenie przykładu, nie uniwersalna reguła rozliczeń.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;
public final class PriceCalculator {
private PriceCalculator() {}
public static BigDecimal total(
BigDecimal unitPrice,
int quantity,
BigDecimal discountRate) {
Objects.requireNonNull(unitPrice);
Objects.requireNonNull(discountRate);
if (unitPrice.signum() < 0 || quantity < 0) {
throw new IllegalArgumentException("Negative price or quantity");
}
if (discountRate.signum() < 0
|| discountRate.compareTo(BigDecimal.ONE) > 0) {
throw new IllegalArgumentException("Discount outside [0, 1]");
}
BigDecimal subtotal = unitPrice.multiply(BigDecimal.valueOf(quantity));
BigDecimal multiplier = BigDecimal.ONE.subtract(discountRate);
return subtotal.multiply(multiplier).setScale(2, RoundingMode.HALF_UP);
}
}
Dla 19.99, trzech sztuk i rabatu 0.10 otrzymamy 53.97. Gdyby najpierw zaokrąglić cenę jednej sztuki po rabacie, wynik mógłby się różnić. Dlatego kolejność operacji powinna wynikać z wymagań, a nie z tego, gdzie wygodnie dopisać setScale().
Nie każda waluta i nie każdy etap kalkulacji wymaga dwóch miejsc. Kurs walutowy może potrzebować innej dokładności niż końcowa kwota. Sama kwota nie niesie też informacji o walucie — model płatności powinien przechowywać obie rzeczy.
Dzielenie ujawnia brakującą decyzję
BigDecimal share = new BigDecimal("100.00")
.divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP);
// 33.33
Trzy takie udziały dają 99.99. Pozostały grosz nie jest błędem biblioteki. Trzeba zdecydować, komu go przypisać, np. według stałej kolejności odbiorców. Reguła powinna być deterministyczna, aby ponowne wykonanie rozliczenia dawało ten sam wynik.
Dzielenie bez wskazania zasad może rzucić ArithmeticException, jeśli dokładny wynik ma nieskończone rozwinięcie dziesiętne. MathContext określa precyzję cyfr znaczących; skala wyniku to osobna kwestia.
Testuj decyzje, nie tylko szczęśliwy przypadek
Przykładowy fragment testu JUnit Jupiter, dla klasy powyżej:
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class PriceCalculatorTest {
@Test
void roundsAfterCalculatingTheWholeLine() {
BigDecimal actual = PriceCalculator.total(
new BigDecimal("19.99"), 3, new BigDecimal("0.10"));
assertEquals(new BigDecimal("53.97"), actual);
}
@Test
void rejectsDiscountAboveOneHundredPercent() {
assertThrows(IllegalArgumentException.class, () ->
PriceCalculator.total(BigDecimal.TEN, 1,
new BigDecimal("1.01")));
}
}
Do tego warto dodać zerową ilość, rabat równy jeden, wartości na granicy zaokrąglenia i rozdzielanie reszty. W bazie oraz kontrakcie API trzeba zachować te same zasady dokładności. Klient, który zamieni odebraną kwotę na przybliżony typ liczbowy, może ponownie wprowadzić problem rozwiązany po stronie Javy.