Skip to content
Powrót do bloga
Poradniki

Precyzja liczb zmiennoprzecinkowych: dlaczego 0.1 + 0.2 ≠ 0.3 (2026)

Dlaczego precyzja liczb zmiennoprzecinkowych psuje 0.1 + 0.2, jak zaokrągla IEEE 754 i jak bezpiecznie porównywać liczby float — darmowy konwerter online.

12 min czytania

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:

  1. Binarny zapis zmiennoprzecinkowy reprezentuje wyłącznie liczby postaci m × 2ⁿ — sumy potęg dwójki.
  2. Ułamki dziesiętne takie jak 0.1, 0.2 czy 0.3 nie mają tej postaci, więc zostają zaokrąglone już przy wczytaniu.
  3. Każda operacja arytmetyczna ponownie zaokrągla swój wynik do najbliższej reprezentowalnej wartości.
Liczba dziesiętnaReprezentowalna dokładnie?Dlaczego
0.5Tak2⁻¹
0.25Tak2⁻²
0.75Tak2⁻¹ + 2⁻²
2.5Tak2 + 2⁻¹
100TakDowolna liczba całkowita poniżej 2⁵³
0.1Nie1/10 — mianownik ma czynnik 5
0.2Nie1/5 — ten sam problem
0.3Nie3/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ęzykDomyślny typ zmiennoprzecinkowy0.1 + 0.2 drukujeOpcja dokładnie dziesiętna
JavaScriptnumber (tylko double)0.30000000000000004Brak wbudowanej — całkowite centy albo biblioteka
Pythonfloat (double)0.30000000000000004decimal.Decimal, fractions.Fraction
Javadouble0.30000000000000004BigDecimal (budowany z tekstu)
C#double0.30000000000000004decimal — 128-bitowy, podstawa 10
Gofloat640.30000000000000004 (przez zmienne)math/big.Rat
Rustf640.30000000000000004crate rust_decimal
SQLFLOAT / DOUBLE PRECISION0.30000000000000004NUMERIC / 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

ZastosowanieZalecany typDlaczego
Pieniądze, rozliczenia, podatkiCałkowite jednostki podrzędne lub typ dziesiętnyDokładna arytmetyka dziesiętna, audytowalne zaokrąglanie
Nauka, fizykadouble (FP64)15–16 cyfr w zupełności wystarcza; najlepsze wsparcie bibliotek
Geometria, grafikafloat lub double + tolerancjaI tak są przybliżone; porównanie z epsilonem
Trenowanie modeli MLbfloat16Zakres FP32 przy połowie pamięci
Inferencja ML, składowanieFP16Więcej bitów mantysy, gdy wartości są znormalizowane
Liczniki, identyfikatory, klucze64-bitowa liczba całkowita lub BigIntNigdy 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ą:

FormatBity łącznieWykładnikMantysaCyfry dziesiętne (ok.)Maks. wartość skończonaTypowe zastosowanie
binary16 (FP16)16510~3.365504Inferencja na GPU, zwarte składowanie
bfloat161687~2.4~3.39 × 10³⁸Trenowanie modeli ML
binary32 (float)32823~7.2~3.40 × 10³⁸Grafika, czujniki, GPU
binary64 (double)641152~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.EPSILON jak 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 albo printf. Nigdy nie wysyłaj użytkownikom domyślnego repr.
  • 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, nie FLOAT, 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.

Tagi: floating-point ieee-754 javascript python numbers

Powiązane artykuły

Zobacz wszystkie artykuły