Przejdź do treści

Pułapki liczb w JavaScript

JavaScript ma jeden numeryczny typ - number - i mnóstwo rzeczy, które się na nim potykają. Notatka o IEEE 754, NaN, granicach precyzji i BigInt. Dlaczego 0.1 + 0.2 !== 0.3 i dlaczego NaN === NaN to false.

Pewnie nie raz widziałeś coś takiego:

0.1 + 0.2; // 0.30000000000000004
NaN === NaN; // false
Number.MAX_SAFE_INTEGER + 1; // 9007199254740992
Number.MAX_SAFE_INTEGER + 2; // 9007199254740992 (też!)
isNaN("abc"); // true
Number.isNaN("abc"); // false

Wszystkie te wyniki wyglądają jak błędy, ale są deterministyczne. Pod spodem są dwie rzeczy: IEEE 754 - międzynarodowy standard reprezentacji liczb zmiennoprzecinkowych z 1985 roku, którego JavaScript używa do typu number (konkretnie 64-bitowy double) - oraz parę świadomych decyzji w ECMA-262. Reszta tych pułapek wynika właśnie z tych dwóch.

#Dlaczego 0.1 + 0.2 to nie 0.3

Komputer trzyma liczby binarnie. 0.5 da się zapisać dokładnie (to 2^-1), ale 0.1 w systemie dwójkowym to nieskończona reprezentacja - tak samo, jak 1/3 w dziesiętnym to 0.333.... IEEE 754 musi to gdzieś uciąć - konkretnie do 52 bitów (to tzw. mantysa: część, która koduje cyfry znaczące liczby). Zapisuje więc przybliżenie:

(0.1).toPrecision(20); // "0.10000000000000000555"
(0.2).toPrecision(20); // "0.20000000000000001110"

Suma dwóch przybliżeń to przybliżenie sumy, a nie idealne 0.3. Stąd 0.30000000000000004.

To nie jest bug JavaScriptu. Tak samo zachowa się Python, Java, C++ - wszędzie tam, gdzie liczby zmiennoprzecinkowe są IEEE 754 double, czyli w prawie każdym popularnym języku.

W praktyce: nie porównuj floatów (czyli liczb z częścią dziesiętną - od ang. floating point) przez ===. Używaj porównania z tolerancją:

Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON; // true

Number.EPSILON to najmniejsza różnica między 1 a kolejną reprezentowalną liczbą (~2.22e-16). Dobra miara „blisko zera” dla 64-bit double.

#NaN - jedyna wartość, która nie równa się sobie

NaN znaczy „Not a Number”. To specjalna wartość, którą JavaScript zwraca, gdy próbuje policzyć coś, co nie ma sensownego wyniku liczbowego - na przykład pierwiastek z minus jeden albo dzielenie zera przez zero:

0 / 0; // NaN
Math.sqrt(-1); // NaN
parseInt("abc"); // NaN
Number(undefined); // NaN

Gdziekolwiek NaN wpadnie do kodu, propaguje się dalej - każda operacja arytmetyczna z NaN daje NaN. Logika jest prosta: jeśli jeden z operandów nie jest sensowną liczbą (NaN), to wynik też nie może być sensowną liczbą. NaN + 5 nadal jest „coś-bez-sensu”. IEEE 754 wybrał to celowo - możesz sprawdzić NaN raz na końcu obliczenia, zamiast po każdej operacji. Trzeba jednak zapamiętać dwie rzeczy.

Po pierwsze, NaN !== NaN. Specyfikacja IEEE 754 wprost mówi: NaN nie jest równe niczemu, włącznie z sobą samym. To jedyna wartość w JS o tej własności.

NaN === NaN; // false
NaN == NaN; // false

Po drugie, Number.isNaN() ≠ globalne isNaN(). Globalne isNaN(x) najpierw konwertuje argument przez ToNumber (mechanizm opisałem w notatce o coercion), dopiero potem testuje. To powoduje fałszywe trafienia:

isNaN("abc"); // true  - "abc" konwertuje do NaN
isNaN({}); // true  - {} konwertuje do NaN
Number.isNaN("abc"); // false - "abc" to string, nie NaN
Number.isNaN(NaN); // true  - jedyna sytuacja, kiedy zwraca true

Do sprawdzania NaN używaj Number.isNaN(), nigdy globalnego isNaN(). Globalne istnieje tylko dla kompatybilności wstecznej.

#Bezpieczne liczby całkowite i BigInt

64-bit IEEE 754 double reprezentuje liczby całkowite dokładnie tylko do 2^53 - 1 (Number.MAX_SAFE_INTEGER). Powyżej tej granicy precyzja zaczyna się sypać:

Number.MAX_SAFE_INTEGER; // 9007199254740991 (2^53 - 1)
Number.MAX_SAFE_INTEGER + 1; // 9007199254740992 (jeszcze ok)
Number.MAX_SAFE_INTEGER + 2; // 9007199254740992 (!) - nie 9007199254740993
Number.MAX_SAFE_INTEGER + 3; // 9007199254740994

Skąd to się bierze? Powyżej 2^53 liczb całkowitych jest więcej niż możliwych reprezentacji binarnych - format musi zaokrąglać do najbliższej, którą umie zapisać. Tuż nad granicą reprezentowalne liczby idą co 2 (2^53, 2^53 + 2, 2^53 + 4…) - dlatego +1 może w ogóle nie zmienić wartości. Nieistniejące 2^53 + 1 zaokrągla się do 2^53. Powyżej 2^54 skoki rosną do 4, dalej do 8.

Dla większości kodu nie ma znaczenia. Ale dla ID dłuższych niż ~16 cyfr (snowflake ID jak w Twitterze/Discordzie, wartości z kolumn BIGINT w bazie danych), znaczników czasu w nanosekundach albo bardzo dużych kwot finansowych - to realny bug, który po cichu zafałszowuje dane.

Rozwiązanie: BigInt (ES2020). Osobny typ prymitywny dla liczb całkowitych dowolnej precyzji.

const big = 9007199254740993n; // przyrostek `n` = literał BigInt
big + 1n; // 9007199254740994n - dokładnie
typeof big; // "bigint" - osobny typ
BigInt("9007199254740993"); // też BigInt

Główna pułapka BigInt: nie miesza się z Number bez explicit konwersji. 1n + 1 rzuca TypeError. Musisz albo Number(big) + 1, albo big + 1n. Drobniejsze: JSON.stringify(1n) rzuca błąd (musisz konwertować do stringa), a >>> (unsigned right shift) nie działa na BigInt.

#Object.is - gdy === i isNaN nie wystarczają

Object.is(a, b) to wbudowana metoda do porównywania dwóch wartości - alternatywa dla ===, ale z dwoma poprawkami:

NaN === NaN; // false  -> Object.is(NaN, NaN) === true
0 === -0; // true   -> Object.is(0, -0) === false

Czyli: Object.is traktuje NaN jako równe samemu sobie i rozróżnia +0 od -0. Te dwa przypadki to całe „odejście” od ===. We wszystkim innym daje ten sam wynik co ===. Pełne porównanie operatorów równości opisałem w notatce o equality. W codziennym kodzie rzadko potrzebny, ale dobry do zapamiętania jako narzędzie.

#Co z tego zapamiętać

Trzy zasady, które pokrywają większość codziennego kodu:

  1. Floaty porównuj z tolerancją (Math.abs(a - b) < Number.EPSILON), nie przez ===.
  2. NaN testuj przez Number.isNaN(), nigdy globalnego isNaN().
  3. BigInt dla liczb większych niż 2^53 lub gdy precyzja całkowita ma znaczenie (finanse, ID, timestampy nanosekundowe).

Reszta - Object.is dla zera ze znakiem, Number.MAX_SAFE_INTEGER jako granica - to narzędzia na konkretne sytuacje. Kiedy przyjdzie ich potrzeba, wiesz gdzie sięgnąć.

← Wróć do notatek