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"); // falseWszystkie 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; // trueNumber.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); // NaNGdziekolwiek 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; // falsePo 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 trueDo 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; // 9007199254740994Ską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ż BigIntGłó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) === falseCzyli: 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:
- Floaty porównuj z tolerancją (
Math.abs(a - b) < Number.EPSILON), nie przez===. - NaN testuj przez
Number.isNaN(), nigdy globalnegoisNaN(). BigIntdla liczb większych niż2^53lub 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ąć.