Przejdź do treści

Jak liczyć pieniądze w kodzie – żeby grosze się zgadzały

We wpisie o niedokładności liczb zmiennoprzecinkowych pokazywałem, skąd bierze się słynne 0.1 + 0.2 = 0.30000000000000004. Krótkie przypomnienie: typ float zapisuje liczby w systemie dwójkowym, a ułamek 0,1 w dwójkowym jest nieskończony – tak jak 1/3 w dziesiętnym. Komputer musi go uciąć, i to ucięcie zostawia mikroskopijny błąd.

Ten wpis jest o praktycznej konsekwencji tamtego, która ma znaczenie w niemal każdej aplikacji: jak w takim razie liczyć pieniądze. Bo odpowiedź nie brzmi „zaokrąglaj ostrożniej”. Brzmi: nie używaj do tego float – i jest do tego cały zestaw właściwych narzędzi.

Dlaczego to nie jest kwestia zaokrąglenia

Kuszące jest myślenie: skoro błąd jest mikroskopijny, to wystarczy na końcu zaokrąglić do dwóch miejsc i po sprawie. To pułapka, bo błędy się kumulują. Dodaj do siebie dziesięć tysięcy kwot obarczonych mikrobłędem, a mikrobłąd urośnie do grosza, potem do złotówki. W systemie, który przelicza miliony pozycji, „mikroskopijny” przestaje być mikroskopijny – a różnica jednego grosza w bilansie to dla księgowości nie drobiazg, tylko sygnał, że coś jest zepsute.

Do tego dochodzi rzecz gorsza od samego błędu: nieprzewidywalność. 0,1 + 0,2 nie równa się 0,3, ale 0,1 + 0,4 już równa się dokładnie 0,5. Nie da się na oko przewidzieć, które działanie na floatach wypadnie czysto, a które nie. Kod, który „zwykle działa”, a czasem gubi grosz w miejscu, którego nie sposób z góry wskazać, jest gorszy niż kod, który myli się zawsze.

Rozwiązanie jest jedno: liczyć tak, żeby błąd w ogóle nie powstawał. A to znaczy zejść z systemu dwójkowego.

Wzorzec uniwersalny: licz w groszach, nie w złotówkach

Najprostsza i najbardziej przenośna technika nie wymaga żadnej specjalnej biblioteki. Skoro problem bierze się z ułamków, to pozbądźmy się ułamków: zamiast trzymać 19,99 zł jako liczbę zmiennoprzecinkową, trzymaj 1999 groszy jako liczbę całkowitą.

# Python – kwoty w groszach, arytmetyka na liczbach całkowitych
cena_gr = 1999          # 19,99 zł
ilosc = 3
suma_gr = cena_gr * ilosc      # 5997 groszy, dokładnie
zlote, grosze = divmod(suma_gr, 100)
print(f"{zlote},{grosze:02d} zł")   # "59,97 zł"

Liczby całkowite nie mają problemu z precyzją – 1999 * 3 to zawsze dokładnie 5997, w każdym języku. Ceną jest to, że musisz pamiętać o jednostce (grosze, nie złote) i sam obsłużyć podział na końcu. Ale dla wielu aplikacji to wystarczy i jest niezawodne. Ta technika ma nawet rodowód w 64-bitowych typach na znaczniki czasu – tam też uciekamy od ułamków, licząc wszystko w najmniejszej całkowitej jednostce.

Typ dziesiętny: gdy potrzeba ułamków bez błędu

Grosze jako liczby całkowite mają granicę: co z cenami podawanymi z większą dokładnością (paliwo po 6,299 zł za litr), z kursami walut, z oprocentowaniem? Wtedy potrzebny jest typ, który liczy w systemie dziesiętnym i przechowuje ułamki dokładnie. Każdy poważny język taki ma.

W Pythonie to moduł decimal:

# Python – typ Decimal liczy dziesiętnie, więc 0,1 + 0,2 to dokładnie 0,3
from decimal import Decimal, ROUND_HALF_UP

cena = Decimal("6.299")
litry = Decimal("38.4")
brutto = cena * litry                      # 241.8816, bez błędu binarnego
do_zaplaty = brutto.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(do_zaplaty)                          # 241.88

Uwaga na szczegół, który często umyka: Decimal tworzy się z napisu ("6.299"), nie z floata. Gdybyś napisał Decimal(6.299), wciągnąłbyś do środka błąd binarny, zanim Decimal zdążył cokolwiek pomóc.

Odpowiedniki w innych językach działają tak samo:

// Java – BigDecimal, też tworzony z napisu
BigDecimal cena = new BigDecimal("6.299");
BigDecimal wynik = cena.multiply(new BigDecimal("38.4"))
                       .setScale(2, RoundingMode.HALF_UP);   // 241.88
// PHP – rozszerzenie BCMath operuje na napisach
$brutto = bcmul("6.299", "38.4", 4);   // "241.8816"
$doZaplaty = bcadd($brutto, "0", 2);   // "241.88" (obcięcie do 2 miejsc)

A w bazie danych: DECIMAL, nigdy FLOAT

Zasada obowiązuje po drodze na każdym etapie – także w bazie. Kolumna z kwotą to DECIMAL (albo równoważny NUMERIC), nigdy FLOAT czy DOUBLE:

CREATE TABLE zamowienia (
    id       INT PRIMARY KEY,
    kwota    DECIMAL(10, 2) NOT NULL   -- 10 cyfr łącznie, 2 po przecinku
);

DECIMAL(10, 2) przechowuje liczbę dokładnie, dziesiętnie – baza nie zamieni 19,99 na 19,989999…. Jest tu jednak pułapka, o którą łatwo się potknąć w PHP: sterowniki baz danych zwracają wartości DECIMAL jako napisy, nie jako liczby. To celowe – gdyby zwróciły float, zniszczyłyby precyzję, którą baza starannie utrzymała. Dlatego wynik z bazy podawaj dalej do BCMath albo Decimal jako napis, a nie rzutuj pochopnie na float.

Dwa smaczki, które psują bilans mimo dobrego typu

Właściwy typ danych to warunek konieczny, ale nie wystarczający. Są jeszcze dwie decyzje, które potrafią rozjechać wynik, choć każda liczba z osobna jest policzona dokładnie.

Zaokrąglenie bankierskie kontra szkolne

W szkole uczono nas: piątka na końcu zaokrągla w górę. 2,5 → 3. To zaokrąglenie „half up”. Ale istnieje drugie, bankierskie („half even”), które piątkę zaokrągla do najbliższej parzystej cyfry: 2,5 → 2, ale 3,5 → 4. Po co? Bo przy tysiącach zaokrągleń szkolne „zawsze w górę” wprowadza systematyczne, kierunkowe odchylenie – suma po zaokrągleniu jest odrobinę zawyżona. Bankierskie rozkłada piątki po równo w górę i w dół, więc odchylenie się znosi. Wiele standardów finansowych i domyślne zachowanie niektórych typów zmiennoprzecinkowych używa właśnie half-even – i jeśli twój kod zaokrągla po szkolnemu, a system, z którym się rozliczasz, po bankiersku, będziecie się różnić o grosze bez oczywistej przyczyny.

VAT: od pozycji czy od sumy?

Klasyczny spór, który nie ma jednej „poprawnej matematycznie” odpowiedzi, bo to kwestia reguły, nie rachunku. Masz fakturę z wieloma pozycjami. Liczysz VAT osobno dla każdej pozycji i sumujesz – czy sumujesz kwoty netto i liczysz VAT raz, od całości? Oba sposoby dają wyniki różniące się o grosz albo dwa, bo w każdym zaokrąglasz w innym momencie. To nie jest błąd w kodzie – to różnica reguł, i musisz wiedzieć, której wymaga prawo oraz system, z którym się integrujesz. Kod, który liczy „poprawnie”, ale inną metodą niż urząd skarbowy, i tak wygeneruje fakturę do poprawki.

Ślad historii: dlaczego COBOL miał to od 1959 roku

Na koniec ciekawostka, która spina to z wątkiem o starym kodzie. Problem liczenia pieniędzy bez błędu binarnego nie jest nowy – jest tak stary jak komercyjne programowanie. I rozwiązano go od razu: COBOL, język z 1959 roku, miał arytmetykę dziesiętną stałoprzecinkową wbudowaną w typy danych. Deklaracja PIC 9(9)V99 znaczy „dziewięć cyfr, przecinek, dwie po” – i wszystkie działania na takim polu są dziesiętne, dokładne, z zaokrągleniem tam, gdzie każe księgowość.

To jest jeden z powodów, dla których banki do dziś nie chcą porzucać COBOL-u: on od urodzenia robi dobrze to, co w nowoczesnych językach trzeba świadomie dołożyć osobnym typem. Kiedy piszesz Decimal("6.299") w Pythonie albo DECIMAL(10,2) w SQL-u, robisz dokładnie to, co COBOL robił domyślnie, zanim powstał internet.

Podsumowanie

  • Nigdy float/double do pieniędzy. To nie kwestia ostrożności, tylko niewłaściwego narzędzia – błąd jest wbudowany w typ i się kumuluje.
  • Najprostszy niezawodny wzorzec: trzymaj i licz kwoty w groszach jako liczby całkowite.
  • Gdy potrzeba ułamków: typ dziesiętny – Decimal (Python), BigDecimal (Java), BCMath (PHP). Twórz go z napisu, nie z floata.
  • W bazie: kolumna DECIMAL/NUMERIC. Pamiętaj, że sterownik zwróci ją jako napis – i dobrze.
  • Uważaj na reguły, nie tylko liczby: ustal tryb zaokrąglenia (bankierskie czy szkolne) i metodę liczenia VAT (od pozycji czy od sumy), zanim zdziwisz się różnicą grosza.

A jeśli chcesz zobaczyć, skąd w ogóle bierze się ten cały kłopot – czyli dlaczego 0,1 w dwójkowym jest nieskończone – wróć do wpisu o niedokładności liczb zmiennoprzecinkowych. Ten tekst jest jego praktycznym ciągiem dalszym.