Precyzja liczb zmiennoprzecinkowych: dlaczego 0.1 + 0.2 ≠ 0.3 (przewodnik 2026)
0.1 + 0.2 zwraca 0.30000000000000004, ponieważ ani 0.1, ani 0.2 nie istnieje wewnątrz binarnej liczby zmiennoprzecinkowej. Typ double przechowuje wyłącznie wartości postaci m × 2ⁿ, a jedna dziesiąta jest w podstawie 2 nieskończonym ułamkiem okresowym, więc sprzęt zapamiętuje najbliższego reprezentowalnego sąsiada. Liczba double kryjąca się za 0.1 to dokładnie 0.1000000000000000055511151231257827021181583404541015625. Ta stojąca za 0.2 to 0.200000000000000011102230246251565404236316680908203125. Po dodaniu obu zapisanych wartości dokładna suma ląduje pomiędzy dwiema reprezentowalnymi liczbami double. IEEE 754 zaokrągla do bliższej z nich, a ta leży o włos powyżej 0.3.
To cała odpowiedź: precyzja zmiennoprzecinkowa jest skończona, ułamki dziesiętne rzadko trafiają w binarną siatkę, a każda operacja zaokrągla ponownie.
Nie jest to dziwactwo JavaScriptu ani wada procesora. Ten sam wynik pojawia się w Pythonie, Javie, C, Go, Ruście, Swifcie i w kolumnach FLOAT w SQL, bo wszystkie te środowiska opierają się na IEEE 754. Wystarczy wkleić dowolną wartość do darmowego konwertera liczb zmiennoprzecinkowych IEEE 754, aby zobaczyć dokładne bity i dokładną zapisaną wartość, cyfra po cyfrze.
Pozostaje wyjaśnić, skąd bierze się ten błąd, dlaczego język czasem go ukrywa, jak porównywać liczby float bez == i po jaki typ liczbowy sięgnąć, gdy precyzja kosztuje pieniądze.
Precyzja zmiennoprzecinkowa w 60 sekund
Trzy fakty tłumaczą niemal każde zaskoczenie:
- Binarny zapis zmiennoprzecinkowy reprezentuje wyłącznie liczby postaci m × 2ⁿ — sumy potęg dwójki.
- Ułamki dziesiętne takie jak
0.1,0.2czy0.3nie mają tej postaci, więc zostają zaokrąglone już przy wczytaniu. - Każda operacja arytmetyczna ponownie zaokrągla swój wynik do najbliższej reprezentowalnej wartości.
| Liczba dziesiętna | Reprezentowalna dokładnie? | Dlaczego |
|---|---|---|
| 0.5 | Tak | 2⁻¹ |
| 0.25 | Tak | 2⁻² |
| 0.75 | Tak | 2⁻¹ + 2⁻² |
| 2.5 | Tak | 2 + 2⁻¹ |
| 100 | Tak | Dowolna liczba całkowita poniżej 2⁵³ |
| 0.1 | Nie | 1/10 — mianownik ma czynnik 5 |
| 0.2 | Nie | 1/5 — ten sam problem |
| 0.3 | Nie | 3/10 — ten sam problem |
Praktyczna zasada: wystarczy skrócić ułamek. Jeśli mianownik jest czystą potęgą dwójki, wartość jest dokładna; każdy pozostały czynnik 5 oznacza, że zapisano ją w przybliżeniu.
Dlaczego 0.1 nie da się zapisać dokładnie
Bity za przecinkiem binarnym niosą wagi 1/2, 1/4, 1/8, 1/16, 1/32 i tak dalej. Z żadnej ich kombinacji nie da się złożyć 0.1. Wychodzi 1/16 = 0.0625, potem plus 1/32 = 0.09375, potem plus 1/256 = 0.09765625. Suma podchodzi coraz bliżej, ale nigdy nie trafia w cel. W zapisie binarnym jedna dziesiąta to 0.0001100110011…, gdzie 0011 powtarza się bez końca.
System dziesiętny ma ten sam problem, tylko z innymi ułamkami. Zapis 1/3 w podstawie 10 daje 0.333… i żaden skończony ciąg cyfr nigdy w to nie trafi. Nikt nie nazywa tego błędem systemu dziesiętnego. Podstawa 2 po prostu stawia granicę w innym miejscu, a 1/10 przypadkiem znalazła się po jej niewłaściwej stronie. Dokumentacja Pythona, Floating Point Arithmetic: Issues and Limitations, przeprowadza przez to samo rozwinięcie, jeśli ktoś woli wersję rozbudowaną.
System dwójkowy to zwyczajnie podstawa 2, a ta sama arytmetyka pozycyjna napędza hex i system ósemkowy. Konwerter systemów liczbowych pokazuje, jak wartość przechodzi między podstawami, a nasz przewodnik po konwersji systemów liczbowych omawia szczegółowo stronę całkowitoliczbową. Dopiero przy ułamkach podstawa 2 zaczyna boleć.
Co naprawdę przechowuje double
64-bitowa liczba double dzieli się na trzy pola: 1 bit znaku, 11 bitów wykładnika i 52 bity mantysy. Mantysa niesie domyślną wiodącą jedynkę, której nigdy się nie zapisuje, więc do dyspozycji zostaje 53 bity znaczące, czyli mniej więcej 15.95 cyfry dziesiętnej precyzji zmiennoprzecinkowej. Wykładnik zapisywany jest z przesunięciem 1023.
Wystarczy poprosić dowolny język o więcej cyfr, niż zwykle drukuje, a przybliżenie wychodzi na jaw:
>>> 0.1
0.1
>>> f"{0.1:.20f}"
'0.10000000000000000555'
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
(0.1).toFixed(20); // '0.10000000000000000555'
(0.1).toPrecision(20); // '0.10000000000000000555'
Decimal(0.1) niczego nie upiększa: konwertuje liczbę double, która już istnieje, bez ponownego zaokrąglania, i drukuje wszystkie 55 cyfr. Panel dokładności w konwerterze IEEE 754 wypisuje to samo rozwinięcie dla dowolnej wpisanej liczby, obok błędu zaokrąglenia ze znakiem względem tego, co zostało wprowadzone.
Dokładna arytmetyka stojąca za 0.30000000000000004
Zostaje pytanie, dlaczego wynikiem jest ta konkretna liczba, a nie jakieś inne chybienie o włos.
Dodanie obu zapisanych wartości dokładnie, bez żadnego zaokrąglania, daje:
0.1 → 0.1000000000000000055511151231257827021181583404541015625
0.2 → 0.200000000000000011102230246251565404236316680908203125
sum → 0.3000000000000000166533453693773481063544750213623046875
Ta suma sama w sobie nie jest reprezentowalną liczbą double. Leży pomiędzy dwoma sąsiadami na binarnej siatce:
below: 0.299999999999999988897769753748434595763683319091796875 (prints as 0.3)
above: 0.3000000000000000444089209850062616169452667236328125 (prints as 0.30000000000000004)
Dokładna suma leży 2⁻⁵⁵ ≈ 2.776e-17 powyżej dolnego sąsiada i 2⁻⁵⁵ poniżej górnego, czyli dokładnie w połowie między nimi. Żaden z sąsiadów nie jest bliższy, więc zaokrąglanie ma do rozstrzygnięcia idealny remis.
Zaokrąglanie do najbliższej wartości nie ma tu żadnego dystansu, na którym mogłoby pracować, więc IEEE 754 sięga po swojego rozjemcę: zaokrąglanie połówek do parzystej, czyli wybór kandydata, którego ostatni bit mantysy wynosi 0. Znacząca dolnego sąsiada to 5404319552844595, liczba nieparzysta. Górnego: 5404319552844596, parzysta. Wygrywa wartość parzysta, a jej układ bitów to 0x3FD3333333333334, czyli jeden ULP powyżej liczby double, na którą odwzorowuje się literał 0.3; drukuje się ona jako 0.30000000000000004.
Dlaczego akurat parzysta? Stałe zaokrąglanie połówek w górę obciążałoby długie sumy w jedną stronę. Naprzemienne skłanianie się ku parzystemu sąsiadowi utrzymuje ten dryf blisko zera przez wiele operacji. To to samo bankierskie zaokrąglanie, którego używają księgowi, tyle że znormalizowane w krzemie. Stąd bierze się końcówka ...04 w najczęściej cytowanym błędzie zaokrąglenia zmiennoprzecinkowego.
Dla kontrastu suma, która nie wymaga żadnego zaokrąglania:
0.5 + 0.25 === 0.75; // true
0.1 + 0.2 === 0.3; // false
0.5, 0.25 i 0.75 to 2⁻¹, 2⁻² oraz 2⁻¹ + 2⁻². Wszystkie trzy są dokładne, ich suma jest dokładna, a równość zachowuje się tak, jak sugeruje szkolna arytmetyka. Panel sąsiadów w konwerterze pokazuje poprzednią i następną reprezentowalną wartość wokół dowolnej wpisanej liczby wraz z odstępem ULP, więc można samodzielnie sprawdzić siatkę wokół 0.3.
Dlaczego język czasem ukrywa ten błąd
Największe zamieszanie bierze się stąd, że 0.1 drukuje się jako 0.1, a 0.1 + 0.2 już jako siedemnaście cyfr. Format zapisu jest ten sam, a wyjście skrajnie różne.
Nowoczesne środowiska uruchomieniowe stosują formatowanie shortest round-trip: wypisują najkrótszy ciąg dziesiętny, który po ponownym wczytaniu daje identyczną liczbę double. "0.1" już jednoznacznie wraca do wartości zapisanej dla 0.1, więc dokładnie to widać. Suma 0.1 + 0.2 to inna liczba double niż ta, na którą odwzorowuje się "0.3", więc formatujący musi dokładać cyfry, aż ciąg stanie się jednoznaczny, a to zabiera wszystkie siedemnaście. Zaczęło się od pracy Davida Gaya o poprawnym zaokrąglaniu z 1990 roku; Grisu, a potem Ryu, przyspieszyły ją na tyle, by trafiła do każdej biblioteki standardowej. Język nie kłamie. Pokazuje najkrótszą etykietę, która jednoznacznie identyfikuje wartość.
Zachowanie w poszczególnych językach
| Język | Domyślny typ zmiennoprzecinkowy | 0.1 + 0.2 drukuje | Opcja dokładnie dziesiętna |
|---|---|---|---|
| JavaScript | number (tylko double) | 0.30000000000000004 | Brak wbudowanej — całkowite centy albo biblioteka |
| Python | float (double) | 0.30000000000000004 | decimal.Decimal, fractions.Fraction |
| Java | double | 0.30000000000000004 | BigDecimal (budowany z tekstu) |
| C# | double | 0.30000000000000004 | decimal — 128-bitowy, podstawa 10 |
| Go | float64 | 0.30000000000000004 (przez zmienne) | math/big.Rat |
| Rust | f64 | 0.30000000000000004 | crate rust_decimal |
| SQL | FLOAT / DOUBLE PRECISION | 0.30000000000000004 | NUMERIC / DECIMAL |
Dwa z tych wierszy kryją pułapkę.
Go zwija stałe z dowolną precyzją. Wyrażenie zbudowane z literałów jest obliczane dokładnie w czasie kompilacji i dopiero potem konwertowane, więc stała 0.1 + 0.2 staje się dokładnie 0.3, zanim w ogóle stanie się wartością float64:
package main
import "fmt"
func main() {
fmt.Println(0.1 + 0.2) // 0.3 — constant folded exactly, then converted
a, b := 0.1, 0.2
fmt.Println(a + b) // 0.30000000000000004
fmt.Println(a+b == 0.3) // false
}
BigDecimal w Javie dziedziczy błąd, jeśli poda mu się liczbę double. Konstruktor wiernie konwertuje bity, które dostał:
System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
// 0.3
SQL dzieli się według typu kolumny, a nie według dialektu:
-- PostgreSQL: a bare decimal literal is NUMERIC, which is exact
SELECT 0.1 + 0.2 = 0.3; -- t
-- Cast to binary floating point and equality fails
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8; -- f
-- SQLite: REAL is a double, and the printed value hides it
SELECT 0.1 + 0.2; -- 0.3
SELECT 0.1 + 0.2 = 0.3; -- 0 (false)
Ostatnia para zapytań SQLite jest tu najciekawsza. Wypisany wynik mówi 0.3, porównanie mówi coś przeciwnego, a oba są poprawne.
Porównywanie liczb float: dlaczego == zawodzi, a Number.EPSILON nie jest tolerancją
0.1 + 0.2 === 0.3 jest fałszem, bo lewa strona to inna liczba double. Zwykle radzi się porównywać z tolerancją, a najczęściej powtarzana wersja tej rady jest błędna:
Math.abs(a - b) < Number.EPSILON; // ⚠️ not a general-purpose comparison
Number.EPSILON wynosi 2.220446049250313e-16, czyli 2⁻⁵². MDN definiuje ją jako różnicę między 1 a najmniejszą liczbą double większą od 1. To odstęp siatki w okolicy 1.0, a nie uniwersalny budżet błędu. Odstępy między liczbami float podwajają się przy każdej potędze dwójki, więc stały próg jest błędny wszędzie poza otoczeniem, w którym go zmierzono.
W pobliżu 1.0 akurat działa:
Math.abs((0.1 + 0.2) - 0.3); // 5.551115123125783e-17
Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true
Wystarczy przeskalować to samo obliczenie o miliard, a próg się rozsypuje:
const a = (0.1 + 0.2) * 1e9;
const b = 0.3 * 1e9;
a === b; // false
Math.abs(a - b); // 5.960464477539063e-8
Math.abs(a - b) < Number.EPSILON; // false — yet a and b are adjacent doubles
W okolicy 1e9 odstęp między sąsiadującymi liczbami double wynosi mniej więcej 1.19e-7. Skoro 1e9 mieści się w [2²⁹, 2³⁰), ULP wynosi tam 2²⁹⁻⁵² = 2⁻²³ ≈ 1.19e-7, czyli około 500 milionów razy więcej niż Number.EPSILON. Każdy rzeczywisty błąd zaokrąglenia tego rzędu przewyższa próg, więc sprawdzenie już zawsze zwraca fałsz. W okolicy 1e-20 ta sama stała jest z kolei o wiele za hojna i uznaje za równe wartości ewidentnie różne.
Tolerancja bezwzględna, względna i w ULP
Trzy narzędzia, każde do innego zadania:
- Tolerancja bezwzględna —
|a − b| <= atol. Właściwa, gdy rząd wielkości jest znany z góry, i jedyna, która działa wobec zera, bo tolerancja względna wokół 0 zawsze wynosi 0. - Tolerancja względna —
|a − b| <= rtol × max(|a|, |b|). Skaluje się razem z danymi w różnych rzędach wielkości, ale degeneruje się w pobliżu zera. - Odległość w ULP — ile reprezentowalnych wartości mieści się między dwoma układami bitów. Najprecyzyjniejsza, ale i najmniej czytelna.
Połączenie dwóch pierwszych daje wzór, który stosują biblioteki numeryczne:
function nearlyEqual(a, b, rtol = 1e-9, atol = 1e-12) {
if (a === b) return true; // handles Infinity === Infinity
const diff = Math.abs(a - b);
return diff <= Math.max(rtol * Math.max(Math.abs(a), Math.abs(b)), atol);
}
nearlyEqual(0.1 + 0.2, 0.3); // true
nearlyEqual((0.1 + 0.2) * 1e9, 0.3 * 1e9); // true
nearlyEqual(0, 1e-15); // true
nearlyEqual(1, 1.0001); // false
Python ma to w bibliotece standardowej, a allclose z NumPy korzysta z tego samego wzoru:
import math
math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-9, abs_tol=1e-12) # True
math.isclose(1e9, 1e9 + 1e-7, rel_tol=1e-9) # True
Dla wersji ścisłej wystarczy odwzorować bity na monotoniczny porządek całkowitoliczbowy i odjąć:
const view = new DataView(new ArrayBuffer(8));
function toOrdinal(x) {
view.setFloat64(0, x);
const i = view.getBigInt64(0);
return i >= 0n ? i : -(1n << 63n) - i; // make negatives order correctly
}
function ulpDistance(a, b) {
const d = toOrdinal(a) - toOrdinal(b);
return d < 0n ? -d : d;
}
ulpDistance(0.1 + 0.2, 0.3); // 1n
ulpDistance((0.1 + 0.2) * 1e9, 0.3 * 1e9); // 1n
ulpDistance(-0, 0); // 0n
Dokładne == nie zawsze jest zresztą błędem. Nadaje się do liczb double o wartościach całkowitych poniżej 2⁵³, do stałych, które zostały przypisane i na których nic nie liczono, oraz do sprawdzania, czy wartość jest dokładnie zerem.
Kiedy precyzja kosztuje pieniądze: wybór właściwego typu liczbowego
Waluta i binarne liczby zmiennoprzecinkowe to zły duet, choć nie dlatego, że pojedynczy błąd jest duży. Błędy są systematyczne, powtarzalne i niewidoczne, dopóki nie znajdzie ich audytor:
19.99 * 100; // 1998.9999999999998
Math.round(19.99 * 100); // 1999
(1.005).toFixed(2); // '1.00' — 1.005 is stored as 1.00499999999999989...
Ten drugi przypadek co roku łapie kolejne zespoły. toFixed nie zaokrąglił źle. Wartość, którą dostał, była już poniżej 1.005.
Całkowite jednostki podrzędne
Lepiej przechowywać centy niż dolary. 1999 oznacza $19.99, arytmetyka działa na liczbach całkowitych, a dzielenie następuje dopiero w momencie wyświetlenia.
const priceCents = 1999; // $19.99
const subtotal = priceCents * 3; // 5997 — exact
const totalCents = 1000; // $10.00 split three ways
const share = Math.floor(totalCents / 3); // 333
const remainder = totalCents - share * 3; // 1 cent to allocate
Dwa zastrzeżenia. Powyżej Number.MAX_SAFE_INTEGER (9007199254740991, czyli 2⁵³ − 1) liczby całkowite w JavaScripcie przestają być dokładne, więc trzeba sięgnąć po BigInt. Poza tym liczby całkowite nie rozstrzygają polityki zaokrąglania: dzielenie, procenty i podziały podatku wciąż wymagają jawnej reguły mówiącej, gdzie trafia nadwyżkowy cent.
Typy dziesiętne
Typ dziesiętny przechowuje cyfry w podstawie 10, więc ułamki dziesiętne trafiają dokładnie. Trzeba go konstruować z tekstu, nigdy z liczby float, bo inaczej błąd binarny zostanie odziedziczony jeszcze przed startem:
from decimal import Decimal
Decimal("0.1") + Decimal("0.2") == Decimal("0.3") # True
Decimal(0.1) # 0.1000000000000000055511151231257827021181583404541015625
Console.WriteLine(0.1m + 0.2m); // 0.3
Console.WriteLine(0.1m + 0.2m == 0.3m); // True
Java ma BigDecimal, C# natywny 128-bitowy decimal, a SQL NUMERIC(12,2). Każdy z nich naprawia ułamki dziesiętne. Żaden nie naprawia 1/3, bo typ dziesiętny wciąż jest formatem zmiennoprzecinkowym; przesunął jedynie podstawę z 2 na 10.
Macierz decyzyjna
| Zastosowanie | Zalecany typ | Dlaczego |
|---|---|---|
| Pieniądze, rozliczenia, podatki | Całkowite jednostki podrzędne lub typ dziesiętny | Dokładna arytmetyka dziesiętna, audytowalne zaokrąglanie |
| Nauka, fizyka | double (FP64) | 15–16 cyfr w zupełności wystarcza; najlepsze wsparcie bibliotek |
| Geometria, grafika | float lub double + tolerancja | I tak są przybliżone; porównanie z epsilonem |
| Trenowanie modeli ML | bfloat16 | Zakres FP32 przy połowie pamięci |
| Inferencja ML, składowanie | FP16 | Więcej bitów mantysy, gdy wartości są znormalizowane |
| Liczniki, identyfikatory, klucze | 64-bitowa liczba całkowita lub BigInt | Nigdy nie należały do liczb float |
Kumulacja błędów i katastrofalna utrata cyfr znaczących
Jeden błąd zaokrąglenia to 1e-17 i nic ponadto. Dziesięć tysięcy takich błędów to zgłoszenie do supportu:
let total = 0;
for (let i = 0; i < 10000; i++) total += 0.1;
total; // 1000.0000000001588
total - 1000; // 1.588205122970976e-10
Dryf rośnie mniej więcej wraz z liczbą operacji. W pętli tej wielkości jest niewidoczny; w nocnej agregacji po milionach wierszy już nie.
Katastrofalna utrata cyfr znaczących
Groźniejsza awaria to odejmowanie. Odjęcie dwóch niemal równych liczb kasuje ich zgodne cyfry wiodące i zostawia wynik zdominowany przez błąd, który zdążył się już nazbierać. Błąd bezwzględny nie rośnie. Eksploduje błąd względny, bo wynik jest teraz maleńki, a błąd pozostał tej samej wielkości.
Podręcznikowy jednoprzebiegowy wzór na wariancję, E[x²] − (E[x])², wchodzi w to prosto z marszu:
const data = [1e9, 1e9 + 1, 1e9 + 2];
const mean = data.reduce((s, x) => s + x, 0) / data.length;
// One-pass: E[x²] − (E[x])²
data.reduce((s, x) => s + x * x, 0) / data.length - mean * mean; // 0
// Two-pass: centre the data first
data.reduce((s, x) => s + (x - mean) ** 2, 0) / data.length; // 0.6666666666666666
Prawdziwa wariancja populacji wynosi 2/3. Wzór jednoprzebiegowy zwraca dokładnie zero — nie „trochę obok”, tylko zupełnie inną odpowiedź, bo oba argumenty krążyły wokół 1e18, a różnica między nimi kryje się poniżej ostatniego zapisanego bitu. Algorytm online Welforda omija ten problem, aktualizując przyrostowo średnią i sumę kwadratów odchyleń.
Wzór na pierwiastki równania kwadratowego ma tę samą słabość, gdy b² ≫ 4ac, i naprawia się go w tym samym stylu:
const a = 1, b = 1e8, c = 1;
const disc = Math.sqrt(b * b - 4 * a * c);
(-b + disc) / (2 * a); // -7.450580596923828e-9 — about 25% wrong
(2 * c) / (-b - disc); // -1e-8 — correct
Obie linie liczą ten sam pierwiastek. Pierwsza odejmuje dwie niemal równe liczby; druga przekształca algebrę tak, by nigdy nie musiała tego robić. Tekst Goldberga What Every Computer Scientist Should Know About Floating-Point Arithmetic wciąż pozostaje referencyjnym omówieniem utraty cyfr znaczących i oszacowań błędu.
Sumowanie Kahana
Kompensowane sumowanie Kahana prowadzi bieżący zapis najmniej znaczących bitów odrzucanych przy każdym dodawaniu, a potem zwraca je do gry w kolejnej iteracji:
function kahanSum(values) {
let sum = 0;
let compensation = 0;
for (const value of values) {
const y = value - compensation;
const t = sum + y;
compensation = (t - sum) - y; // the bits that fell off
sum = t;
}
return sum;
}
kahanSum(new Array(10000).fill(0.1)); // 1000 — exactly
Python daje to za darmo. math.fsum([0.1] * 10_000) zwraca 1000.0, a od CPythona 3.12 wbudowane sum() również korzysta z kompensacji Neumaiera, więc sum([0.1] * 10_000) jest dokładne, podczas gdy jawna pętla z += wciąż dryfuje do 1000.0000000001588.
Kompensacja opłaca się przy długich sumach, agregatach finansowych i całkowaniu numerycznym. Można ją pominąć przy garstce wartości albo wtedy, gdy praca odbywa się już na typie dokładnym.
Wartości specjalne, które łamią założenia
IEEE 754 rezerwuje układy bitów dla wartości, które nie przestrzegają oczekiwanych reguł:
NaN === NaN; // false — mandated by the standard
Number.isNaN(NaN); // true
[NaN].indexOf(NaN); // -1 (uses ===)
[NaN].includes(NaN); // true (uses SameValueZero)
-0 === 0; // true
Object.is(-0, 0); // false
1 / -0; // -Infinity
1e308 * 10; // Infinity — overflow never throws
Infinity - Infinity; // NaN
Number.MIN_VALUE; // 5e-324 — the smallest subnormal double
NaN z założenia jest różne od wszystkiego, łącznie z samym sobą, więc nieudane obliczenie nigdy nie podszyje się pod poprawny wynik. Testuje się je przez Number.isNaN() albo math.isnan() w Pythonie.
Przepełnienie jest cichsze i groźniejsze: zwraca ±Infinity i leci dalej w dół potoku, aż ktoś zauważy wykres pełen dziur. Zero ujemne to odrębny układ bitów, który mimo wszystko równa się +0 pod ===; pojawia się z niedomiaru wartości ujemnej albo z -1 * 0, a ujawnia je dopiero dzielenie lub Object.is.
Liczby subnormalne wypełniają lukę między zerem a najmniejszą liczbą znormalizowaną. Przy polu wykładnika złożonym z samych zer domyślna wiodąca jedynka znika, a mantysa kurczy się stopniowo w stronę zera, co gwarantuje, że różnica dwóch nierównych liczb float nigdy nie zaokrągli się dokładnie do zera. Ceną jest spadająca precyzja oraz, na części sprzętu, ostry spadek wydajności. Skróty do wartości specjalnych w konwerterze IEEE 754 wczytują ±0, ±Infinity, NaN i najmniejszą liczbę subnormalną, więc każdy układ bitów da się obejrzeć bezpośrednio.
float vs double vs FP16 vs bfloat16
Ten sam standard, inne budżety na zakres i precyzję zmiennoprzecinkową:
| Format | Bity łącznie | Wykładnik | Mantysa | Cyfry dziesiętne (ok.) | Maks. wartość skończona | Typowe zastosowanie |
|---|---|---|---|---|---|---|
| binary16 (FP16) | 16 | 5 | 10 | ~3.3 | 65504 | Inferencja na GPU, zwarte składowanie |
| bfloat16 | 16 | 8 | 7 | ~2.4 | ~3.39 × 10³⁸ | Trenowanie modeli ML |
| binary32 (float) | 32 | 8 | 23 | ~7.2 | ~3.40 × 10³⁸ | Grafika, czujniki, GPU |
| binary64 (double) | 64 | 11 | 52 | ~15.9 | ~1.80 × 10³⁰⁸ | Domyślny wszędzie indziej |
Bity wykładnika kupują zakres, bity mantysy — precyzję. FP16 wydaje swój budżet na precyzję i kończy się na 65504, czyli na tyle blisko realnych wartości gradientów, że przepełnienie do Infinity bywa rutynowym zagrożeniem. bfloat16 wybiera odwrotny kompromis: zachowuje osiem bitów wykładnika z FP32 i godzi się na siedem bitów mantysy, więc wartości, które przepełniłyby FP16, przechodzą gładko, a trenowanie rzadko potrzebuje skalowania straty.
Domyślnie lepiej zostać przy double. Zejście do węższego formatu ma sens dopiero po zmierzeniu wąskiego gardła pamięci lub przepustowości, a koszt zawężenia można sprawdzić, wpisując tę samą wartość w każdym z formatów w konwerterze.
Praktyczne zasady pracy z precyzją zmiennoprzecinkową
- Nigdy nie używaj
==na wyliczonych liczbach float. Stosuj tolerancję łączącą składnik bezwzględny i względny, dobraną do danych. - Nie traktuj
Number.EPSILONjak progu. Opisuje siatkę w okolicy 1.0 i nic poza tym. - Trzymaj pieniądze w całkowitych jednostkach podrzędnych albo w typie dziesiętnym i zawsze buduj wartości dziesiętne z tekstu.
- Kompensuj długie sumy algorytmem Kahana, Neumaiera lub funkcją
math.fsum. - Przekształcaj wzory odejmujące niemal równe wielkości, zamiast zacieśniać wokół nich tolerancje.
- Formatuj jawnie dla ludzi — przez
toFixed, f-string alboprintf. Nigdy nie wysyłaj użytkownikom domyślnegorepr. - Przesyłaj liczby przez granice usług jako tekst lub liczby całkowite. Podróż w obie strony przez JSON i typ double jest cicha i stratna.
- Wybieraj
NUMERIC, nieFLOAT, dla kolumn walutowych. Poprawianie tego po starcie oznacza migrację i uzgadnianie danych. - Myśl w bitach, gdy wartość wygląda na niemożliwą. Ten sam nawyk napędza nasz przewodnik po operacjach bitowych i zamienia zmiennoprzecinkowe zagadki w arytmetykę, którą da się sprawdzić.
Najczęściej zadawane pytania
Czy arytmetyka zmiennoprzecinkowa jest zepsuta?
Arytmetyka zmiennoprzecinkowa nie jest zepsuta: działa dokładnie zgodnie z IEEE 754. Standard reprezentuje liczby rzeczywiste na skończonej liczbie cyfr binarnych, a ułamki dziesiętne takie jak 0.1 nie mają dokładnej postaci binarnej, więc zapisywana jest najbliższa reprezentowalna wartość. Zepsute jest oczekiwanie, że każdy ułamek dziesiętny się zmieści.
Dlaczego Python wypisuje 0.1 jako 0.1, a 0.1 + 0.2 jako 0.30000000000000004?
repr w Pythonie korzysta z formatowania shortest round-trip: drukuje najkrótszy ciąg dziesiętny, który po ponownym wczytaniu daje identyczną liczbę double. "0.1" już jednoznacznie identyfikuje swoją liczbę double. Suma 0.1 + 0.2 to inna liczba double niż ta, na którą odwzorowuje się "0.3", więc formatujący musi wypisać wszystkie siedemnaście cyfr, by pozostać jednoznacznym.
Dlaczego nie powinno się używać Number.EPSILON do porównywania dwóch liczb float?
Number.EPSILON (około 2.22e-16) to odstęp między 1 a następną liczbą double, a nie uniwersalny budżet błędu. Odstępy między liczbami float podwajają się przy każdej potędze dwójki, więc w okolicy 1e9 sąsiadujące liczby double dzieli około 1.19e-7. Każdy prawdziwy błąd zaokrąglenia w tym miejscu przekracza EPSILON, przez co porównanie zawsze wypada fałszywie. Lepiej użyć tolerancji względnej albo łączonej.
Czy można używać liczb zmiennoprzecinkowych do pieniędzy, jeśli zaokrągli się na końcu?
Zaokrąglenie na końcu nie ratuje pieniędzy trzymanych w liczbach zmiennoprzecinkowych. Błędy kumulują się przez dodawania, mnożenia i wieloetapowe podziały, a moment zaokrąglenia zmienia sumę końcową. 19.99 * 100 daje już 1998.9999999999998. Audyt wymaga powtarzalnej, dokładnej arytmetyki: całkowitych centów, typu decimal, BigDecimal albo NUMERIC w SQL.
Które liczby dziesiętne da się zapisać dokładnie w typie double?
Tylko wartości dające się wyrazić jako m / 2ⁿ dla liczb całkowitych m i n mieszczących się w precyzji formatu. Obejmuje to 0.5, 0.25, 0.125, 0.75 i 2.5 oraz każdą liczbę całkowitą do 2⁵³. Najpierw trzeba skrócić ułamek: każdy pozostały czynnik 5 w mianowniku — jak w 0.1, 0.2, 0.3 czy 0.7 — oznacza, że wartość jest przybliżona.
Dlaczego 0.1 + 0.2 === 0.3 jest fałszem, a 0.5 + 0.25 === 0.75 prawdą?
Ponieważ 0.5, 0.25 i 0.75 to 2⁻¹, 2⁻² oraz 2⁻¹ + 2⁻² — wszystkie reprezentowalne dokładnie, a ich suma nie wymaga zaokrąglania. 0.1, 0.2 i 0.3 są zapisywane w przybliżeniu, a zaokrąglenie sumy dwóch pierwszych ląduje jeden ULP powyżej liczby double, na którą odwzorowuje się 0.3.
Czy dzieje się tak w każdym języku programowania?
Każdy język zbudowany na binarnej arytmetyce IEEE 754 zachowuje się tak samo: JavaScript, Python, Java, C, C++, Go, Rust, Swift oraz FLOAT w SQL. Różni je domyślna precyzja wypisywania i to, czy dokładny typ dziesiętny jest dostępny od razu — jak decimal w C# albo moduł decimal w Pythonie.