Każdy początkujący programista prędzej czy później napotyka na pozornie magiczny błąd: w wielu językach programowania wynik 0,1 + 0,2 nie jest równy 0,3. Z matematycznego punktu widzenia to niedorzeczne – przecież od dziecka uczono nas, że 0,1 + 0,2 = 0,3. A jednak w świecie programowania często zobaczymy rezultat w postaci czegoś w stylu 0,30000000000000004. Dlaczego tak się dzieje? Spokojnie – to nie bug, lecz cecha sposobu, w jaki komputery reprezentują liczby. W tym artykule wyjaśnię sekret liczb zmiennoprzecinkowych z użyciem prostego przykładu, trochę humoru i analogii z dzieleniem tortu.
Dziwny wynik w praktyce
Zacznijmy od krótkiej demonstracji. Poniżej znajduje się prosty kod w C# pokazujący omawiany fenomen:
double a = 0.1;
double b = 0.2;
Console.WriteLine(a + b); /* Wynik: 0,30000000000000004 */
Console.WriteLine(a + b == 0.3); /* Wynik: False (0,1 + 0,2 nie równa się 0.3) */Większość popularnych języków (C#, Java, JavaScript, Python i inne) przechowuje liczby zmiennoprzecinkowe w standardzie IEEE 754 jako tzw. podwójna precyzja (64 bity). Oznacza to, że 0,1 oraz 0,2 i 0,3 są reprezentowane wewnętrznie w postaci binarnej (dwójkowej) z ograniczoną dokładnością. W rezultacie np. w C# (i analogicznie w Javie) sumowanie 0,1 + 0,2 daje w przybliżeniu 0,30000000000000004 zamiast idealnego 0,3. Ta niewielka różnica wynika bezpośrednio z ograniczeń formatu zmiennoprzecinkowego – ani 0,1, ani 0,2, ani nawet 0,3 nie mają dokładnej reprezentacji binarnej. Dla komputera 0,3 również jest nieskończonym ułamkiem w systemie dwójkowym, co prowadzi do podobnych problemów z precyzją. Innymi słowy, komputer zaokrągla te wartości do najbliższej możliwej liczby, jaką może zapisać w 64 bitach, a drobne „śmieci” widoczne w wyniku to właśnie efekt takiego zaokrąglenia.
Gdzie ginie dokładność? (czyli tort podzielony na pół)
Skąd dokładnie bierze się ten błąd? Winowajcą jest sposób reprezentacji ułamków w systemie binarnym. Komputer operuje na bitach (0 i 1), więc każdą liczbę zmiennoprzecinkową zapisuje jako sumę potęg liczby 2 (np. 1/2, 1/4, 1/8, 1/16 itd.). To tak, jakbyśmy próbowali podzielić tort zawsze na połowy. Połówka tortu (0.5) da się idealnie zapisać w tym systemie (to 1/2). Ćwiartka tortu (0,25) też (1/4). Ale spróbujmy uzyskać dokładnie 0,1 tortu korzystając wyłącznie z dzielenia na pół: dostajemy kolejne kawałki 1/2, 1/4, 1/8, 1/16… Jeśli zsumujemy niektóre z nich, zbliżymy się do 0,1, lecz nigdy nie otrzymamy dokładnie jednej dziesiątej. To trochę jak z ułamkiem 1/3 w systemie dziesiętnym – w zapisie dziesiętnym 1/3 to 0,3333… (nieskończona liczba trójek). Podobnie 1/10 w systemie dwójkowym to nieskończony ułamek 0,0001100110011… i komputer dysponujący skończoną ilością pamięci musi go uciąć w pewnym momencie. Informacja, której brakuje, objawia się właśnie jako ta malutka różnica: 0.30000000000000004 zamiast 0.3.
Można powiedzieć, że komputer nie „myli się” w obliczeniach – on po prostu liczy w innym języku (binarnym) i dlatego wynik wydaje się nam dziwny. W praktyce ta różnica jest ekstremalnie mała (rzędu 10^-17) i w większości zadań można ją zignorować. Trzeba jednak uważać przy bezpośrednim porównywaniu zmiennoprzecinkowych wyników w kodzie – porównanie a + b == 0,3 może dać wynik false nawet wtedy, gdy dla nas matematycznie wartości są równe. Dlatego zaleca się porównywanie z pewną tolerancją błędu (np. sprawdzając, czy |(a+b) - 0,3| < 1e-9 zamiast używania ==).
Podsumowanie
Słynny problem 0,1 + 0,2 ≠ 0,3 nie jest kaprysem jednego języka, lecz wynika z fundamentalnych zasad reprezentacji liczb zmiennoprzecinkowych w komputerach. Warto o tym pamiętać, zwłaszcza przy programowaniu krytycznych obliczeń. Jeśli Twoja aplikacja wymaga dokładnych kalkulacji (np. operacje finansowe), nie polegaj ślepo na typach zmiennoprzecinkowych. Istnieją sposoby, by uniknąć błędów precyzji – na przykład użycie typów dedykowanych do arytmetyki dziesiętnej (w C# jest to typ decimal, w Javie BigDecimal, w Pythonie moduł decimal itp.) lub przechowywanie wartości w najmniejszych jednostkach (np. grosze zamiast złotych) jako liczby całkowite. Do obliczeń na pieniądzach programiści często wybierają inne rozwiązania – warto dwa razy się zastanowić, zanim użyjemy float czy double do reprezentowania kwot.
Na koniec ciekawostka: to, że Twój program pokazuje wynik 0,30000000000000004, wcale nie oznacza, że komputer zapomniał jak dodawać. Taki rezultat uświadamia nam, jak różni się świat matematyki od świata binarnych obliczeń w procesorze. Następnym razem, gdy zobaczysz podobny „magiczny” błąd, będziesz już wiedzieć, że to normalne zachowanie wynikające z ograniczonej precyzji reprezentacji ułamków.
PS: Jeśli interesują Cię takie ciekawostki i chcesz solidnie opanować C#/.NET (oraz dowiedzieć się więcej o praktycznych aspektach programowania), sprawdź moje kursy online – znajdziesz je na stronie Modest Programmer - Kursy. To świetna okazja, by rozwinąć umiejętności programistyczne i poznać podobne sekrety od podszewki.
To wszystkie na dzisiaj. Jeżeli taki artykuł Ci się spodobał, to koniecznie dołącz do mojej społeczności – darmowe zapisy, gdzie będziesz również miał dostęp do dodatkowych materiałów i przede wszystkim bonusów. Do zobaczenia w kolejnym artykule.