Wpisz 0.1 + 0.2 w shellu Pythona i nie dostaniesz 0.3. Dostaniesz 0.30000000000000004. Spróbuj w JavaScript, Java, Go, C albo Rust i wyjdzie ten sam siedemnastocyfrowy ogon. Każdy z tych języków robi dokładnie to, co nakazuje standard sprzętowy. Liczba, która wymagałaby nieskończenie wielu cyfr binarnych, jest ściskana do 64 bitów i coś musi ustąpić.
Czemu 0.1 nie da się zapisać dokładnie w binarnym systemie
Komputery zapisują liczby zmiennoprzecinkowe w systemie dwójkowym. W systemie dwójkowym ułamek 1/10 ma rozwinięcie okresowe, tak jak 1/3 powtarza się bez końca w systemie dziesiętnym (0.3333...). Zapisane w całości, 0.1 w binarnym zaczyna się tak:
| |
Żadna skończona liczba bitów nie urywa tej sekwencji tak, by wyszło dokładnie 0.1. 64-bitowy “double” IEEE 754 (1 bit znaku, 11 bitów wykładnika, 52 zapisane bity mantysy, 53 bity precyzji licząc domyślną wiodącą 1) zaokrągla to nieskończone rozwinięcie do najbliższej wartości, jaką może zapisać, a ta zawsze jest ułamkiem postaci J / 2^N. Dla 0.1 ta wartość to:
| |
Jako liczba dziesiętna to 0.1000000000000000055511151231257827021181583404541015625. Python ci to pokaże. Każdy fragment kodu w tym artykule korzysta tylko z biblioteki standardowej, więc nie musisz stawiać wirtualnego środowiska, żeby to śledzić:
| |
0.2 niesie ze sobą własny, nieco inny błąd zaokrąglenia. Zsumuj dwie już niedokładne wartości i błędy się nie skompensują: złożą się w wynik o jeden krok wyższy od double najbliższego 0.3. Stąd 0.30000000000000004.
Jakie liczby zmiennoprzecinkowe rzeczywiście zapisują dokładnie
Nie każda liczba dziesiętna jest przegrana. Ułamek binarny się kończy, gdy jego mianownik po skróceniu jest potęgą dwójki. 0.5 to 1/2. 0.25 to 1/4. 0.125 to 1/8. Te są dokładne:
| |
Liczby dziesiętne, które ludzie faktycznie wpisują, jak ceny i procenty, prawie nigdy nie mają mianownika będącego potęgą dwójki. To sprawia, że błąd przy 0.1 + 0.2 jest normalnym skutkiem arytmetyki dziesiętnej na binarnych floatach i się kumuluje. Dodaj 0.1 dziesięć razy w jakimkolwiek języku używającym double’ów i nie wylądujesz na 1.0:
| |
Dziesięć dodawań. Tyle wystarczy. Zwróć uwagę, że notacja Big O mówi, jak szybko ta pętla rośnie wraz z rozmiarem danych wejściowych, a nie czy wynik jest poprawny — poprawność to tu osobna kontrola, którą musisz zrobić sam.
Jak bezpiecznie porównywać liczby zmiennoprzecinkowe
Skoro == na floatach nie jest wiarygodne, porównuj z tolerancją: sprawdzaj, czy dwie liczby są wystarczająco bliskie, nie identyczne. Każdy popularny język ma na to swój idiom.
| Język | Bezpieczne porównanie |
|---|---|
| Python | math.isclose(a, b) — domyślna tolerancja względna 1e-9 |
| JavaScript | Math.abs(a - b) < Number.EPSILON * skala |
| Java | Math.abs(a - b) < 1e-9, albo BigDecimal dla dokładnego decimala |
| C# | Math.Abs(a - b) < 1e-9, albo natywny typ decimal |
| Go | math.Abs(a-b) <= tolerancja (brak wbudowanego helpera) |
W Pythonie:
| |
math.isclose skaluje tolerancję do wielkości liczb (rel_tol * max(abs(a), abs(b))), więc to samo wywołanie działa i przy małych, i przy dużych wartościach. Jego domyślny abs_tol to jednak 0.0. Jeśli którakolwiek z wartości może legalnie być zerem albo bardzo bliska zeru, przekaż jawne abs_tol — inaczej porównanie zawiedzie na wartościach, które uznałbyś za równe.
Number.EPSILON w JavaScript (2⁻⁵², około 2.22e-16) to odstęp między 1 a kolejnym możliwym do zapisania double, więc sam w sobie jest sensownym progiem tylko dla liczb bliskich 1. Dla innych wielkości przeskaluj go:
| |
Asercja typu result == 0.3 w teście to bug, który czeka tylko na inne dane wejściowe, platformę albo kompilator, żeby się ujawnić. Niezależnie, czy to test jednostkowy, czy coś wyżej w piramidzie testów, zamień dokładne porównanie na sprawdzenie z tolerancją.
Kiedy liczby zmiennoprzecinkowe to złe narzędzie: pieniądze i liczenie
Tolerancja naprawia porównanie. Nie naprawia sposobu przechowywania. Jeśli sumujesz ceny albo prowadzisz narastające saldo, sam błąd zaokrąglenia jest problemem, a rozwiązaniem jest przestać używać float/double do tej wartości.
Najprostsza wersja: przechowuj pieniądze jako liczbę całkowitą najmniejszej jednostki. 19,99 zł to liczba całkowita 1999 groszy, nie float 19.99. Dodawanie i odejmowanie liczb całkowitych nie ma błędu zaokrąglenia. Konwertuj na ciąg dziesiętny tylko wtedy, gdy wyświetlasz wartość.
Kiedy potrzebujesz prawdziwych ułamków dziesiętnych z dokładnymi zasadami zaokrąglania (księgowość, obliczanie podatku, cokolwiek, co musi się zgodzić do grosza), użyj typu decimal:
| |
Java ma do tego BigDecimal; C# ma natywny 128-bitowy typ decimal z około 28-29 istotnymi cyframi dziesiętnymi. Wszystkie trzy zapisują cyfry w systemie dziesiętnym, więc ułamki dziesiętne, które ludzie piszą, są przechowywane bez zaokrąglenia. Płacisz za to szybkością i rozwlekłością kodu. Typy decimal są wolniejsze niż natywne floaty i wymagają jawnego trybu zaokrąglania przy dzieleniu. Warto dla salda bankowego. Zła zamiana dla intensywnej pętli numerycznej.
Kiedy niedokładność liczb zmiennoprzecinkowych nie ma znaczenia
Transformacje graficzne, symulacje fizyczne, wagi modeli uczenia maszynowego i przetwarzanie audio działają na floatach z rozmysłem. Błąd względny na poziomie 1e-16 jest daleko poniżej tego, co te dziedziny mogłyby zauważyć, a natywny sprzęt do floatów jest dużo szybszy niż arytmetyka dziesiętna.
Pytanie o liczbę nie dotyczy więc tego, czy ma przecinek. Zapytaj, czy liczysz dyskretne, dokładne rzeczy, jak pieniądze albo oczekiwany wynik testu, czy mierzysz wielkość ciągłą, która od początku była przybliżona, jak pozycja pikseli albo odczyt czujnika. Liczone wartości potrzebują liczb całkowitych albo typu decimal. Mierzone wartości to właśnie to, do czego zbudowano liczby zmiennoprzecinkowe, a przenoszenie ich do Decimal czyni je tylko wolniejszymi, bez realnego zwiększenia poprawności.
Gdy następnym razem zobaczysz pole salda zadeklarowane jako float albo test, który sprawdza result == 0.3, zdecyduj, z jakim typem liczby masz do czynienia, i popraw typ przechowywania albo porównanie już w tym code review — zanim brakujący grosz pojawi się na produkcji.