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:

1
0.1₁₀ = 0.000110011001100110011...₂  (powtarza się bez końca)

Ż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:

1
3602879701896397 / 2^55

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ć:

1
2
3
4
>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)
>>> 0.1 == 0.1000000000000000055511151231257827021181583404541015625
True

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:

1
2
3
4
>>> 0.5 + 0.25 == 0.75
True
>>> 0.1 + 0.2 == 0.3
False

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:

1
2
3
4
5
>>> total = 0.0
>>> for _ in range(10):
...     total += 0.1
>>> total
0.9999999999999999

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ęzykBezpieczne porównanie
Pythonmath.isclose(a, b) — domyślna tolerancja względna 1e-9
JavaScriptMath.abs(a - b) < Number.EPSILON * skala
JavaMath.abs(a - b) < 1e-9, albo BigDecimal dla dokładnego decimala
C#Math.Abs(a - b) < 1e-9, albo natywny typ decimal
Gomath.Abs(a-b) <= tolerancja (brak wbudowanego helpera)

W Pythonie:

1
2
3
4
5
>>> import math
>>> 0.1 + 0.1 + 0.1 == 0.3
False
>>> math.isclose(0.1 + 0.1 + 0.1, 0.3)
True

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:

1
2
3
4
5
function nearlyEqual(x, y, scale = 1) {
  return Math.abs(x - y) < Number.EPSILON * scale;
}
nearlyEqual(0.1 + 0.2, 0.3);              // true
nearlyEqual(1000.1 + 1000.2, 2000.3, 2000); // true, skala odpowiada wielkości

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:

1
2
3
>>> from decimal import Decimal
>>> Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
True

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.

Powiązane artykuły