Liczba, która zawsze zwraca to samo
Dzisiaj przyjrzymy się typowi NaN, który w JavaScripcie jest określany typem number.
> typeof NaN
'number'
W odpowiedzi dostajemy typ number.
Skoro coś jest liczbą, to logika podpowiada nam, że możemy przeprowadzić na niej operacje matematyczne.
A więc spróbujmy w takim razie coś do niej dodać lub zbadać jej wartość maksymalną lub minimalną.
> NaN + 1
NaN
> NaN - 1
NaN
> Math.max(NaN)
NaN
> Math.min(NaN)
NaN
Jak widać, po dodaniu, odjęciu, zbadaniu wartości maksymalnej i minimalnej - zawsze otrzymujemy ten sam rezultat.
Skoro tak jest, to po co nam taka wartość jest potrzebna?
Aby spróbować to wyjaśnić, zajrzyjmy do Firefox lub V8 w poszukiwaniu wykorzystania i implementacji NaN.
// Firefox
bool isNaN() const { return isDouble() && std::isnan(toDouble()); }
// V8
if (IsMinusZero(value)) return has_minus_zero();
if (std::isnan(value)) return has_nan();
Patrząc na kod przykładowych przeglądarek, do sprawdzania NaN jest wykorzystywana metoda z biblioteki standardowej std::isnan, co może już nam sugerować, że jest to coś, co pojawiło się niezależnie od JavaScriptu.
I rzeczywiście, patrząc historycznie, pierwsza standardyzacja NaN pojawiła się w 1985 roku i został jej nadany numer IEEE 754.
Od JavaScriptu do poziomu sprzętowego
Uzbrojeni w tą wiedzę, napiszmy zatem prosty program w C, gdzie bazując na tym, co odnaleźliśmy w kodzie przeglądarek, sprawdzimy jak zachowuje się NaN.
> NaN !== NaN
true
> 0 / 0
NaN
#include <math.h>
#include <stdint.h>
#include <stdio.h>
int main() {
double x = 0.0 / 0.0;
if (x != x) {
printf("NaN is not the same\n");
}
if (isnan(x)) {
printf("x is NaN\n");
}
uint64_t bits = *(uint64_t*)&x;
printf("NaN hex: 0x%016lx\n", bits);
return 0;
}
Rezultat taki sam jak w przypadku JavaScriptu!
NaN is not the same
x is NaN
NaN hex: 0xfff8000000000000
Już wiemy, że NaN spotykamy także w innych językach programowania
#Python
import math
nan = float('nan')
print(nan != nan) # True
print(nan == nan) # False
print(math.isnan(nan)) # True
//C++
#include <iostream>
#include <cmath>
int main() {
double nan = NAN;
std::cout << (nan != nan) << std::endl; // 1 (true)
std::cout << (nan == nan) << std::endl; // 0 (false)
std::cout << std::isnan(nan) << std::endl; // 1 (true, proper way)
return 0;
}
//Rust
fn main() {
let nan = f64::NAN;
println!("{}", nan != nan); // true
println!("{}", nan == nan); // false
println!("{}", nan.is_nan()); // true (proper way)
}
… ale dalej nie wiemy po co on jest.
A skoro nie wiemy, to wygenerujmy kod assembly dla naszego skromnego programu (pomińmy sobie prolog i inicjowanie ramki stosu).
# =====================================
# double x = 0.0 / 0.0;
# =====================================
pxor xmm0, xmm0 # xmm0 = 0.0
divsd xmm0, xmm0 # xmm0 = 0.0 / 0.0 = NaN
movsd QWORD PTR -8[rbp], xmm0 # x = NaN
# =====================================
# if (x != x) {
# =====================================
movsd xmm0, QWORD PTR -8[rbp] # xmm0 = x
ucomisd xmm0, QWORD PTR -8[rbp] # compare x with x (sets PF=1 for NaN)
jnp .L2 # skip if NOT NaN (PF=0)
# NaN detected - kod tutaj
.L2:
# =====================================
# if (isnan(x)) {
# =====================================
movsd xmm0, QWORD PTR -8[rbp] # xmm0 = x
ucomisd xmm0, QWORD PTR -8[rbp] # compare x with x (sets PF=1 for NaN)
jnp .L3 # skip if NOT NaN (PF=0)
# NaN detected - kod tutaj
.L3:
Dla tych, co nie mieli styczności z assembly - dla nas warty uwagi jest rejestr xmm0, który wykonuje operacje na liczbach zmiennoprzecinkowych.
Co jest logiczne: chcemy przeprowadzić operację na liczbach, CPU operuje na liczbach, także najszybciej będzie to przeprowadzić w rejestrach specjalnie do tego przeznaczonych!
Do tego możemy zobaczyć instrukcję ucomisd, która jest odpowiedzialna za ustawianie flagi w przypadku, gdy wykryje NaN.
Jaki z tego płynie wniosek? NaN jest zaimplementowany na poziomie sprzętowym, a nie abstrakcji JavaScriptu.
Zatem - przepisując program na assembly, aby uniknąć niepotrzebnych abstrakcji, zbadajmy jego wynik działania:
#include <stdio.h>
#include <stdint.h>
int main() {
double x;
uint64_t bits;
__asm__ (
// double x = 0.0 / 0.0;
"pxor xmm0, xmm0\n\t" // xmm0 = 0.0
"divsd xmm0, xmm0\n\t" // xmm0 = 0.0 / 0.0 = NaN
// Save results
"movsd %0, xmm0\n\t" // x = NaN
"movq %1, xmm0\n\t" // bits = *(uint64_t*)&x
: "=m" (x), "=r" (bits)
:
: "xmm0"
);
int is_not_equal;
__asm__ (
// if (x != x)
"movsd xmm0, %1\n\t" // xmm0 = x
"ucomisd xmm0, %1\n\t" // compare x with x → PF=1 for NaN
"setp al\n\t" // al = (x != x)
"movzx %0, al\n\t" // is_not_equal = al
: "=r" (is_not_equal)
: "m" (x)
: "xmm0", "al"
);
if (is_not_equal) { // if (x != x)
printf("NaN is not the same\n");
}
int is_nan_result;
__asm__ (
// if (isnan(x))
"movsd xmm0, %1\n\t" // xmm0 = x
"ucomisd xmm0, %1\n\t" // compare x with x → PF=1 for NaN
"setp al\n\t" // al = isnan(x)
"movzx %0, al\n\t" // is_nan_result = al
: "=r" (is_nan_result)
: "m" (x)
: "xmm0", "al"
);
if (is_nan_result) { // if (isnan(x))
printf("x is NaN\n");
}
printf("NaN hex: 0x%016lx\n", bits);
return 0;
}
Rezultat?
NaN is not the same
x is NaN
NaN hex: 0xfff8000000000000
Wynik programu jest taki sam jak w przypadku wysokopoziomowego C.
Wiemy już, że NaN jest natywnie zaimplementowany, także przyjrzyjmy się instrukcji ucomisd.
"ucomisd xmm0, %1\n\t" // compare x with x → PF=1 for NaN
ucomisd - czyli Unordered Compare Scalar Double-precision floating-point. Ta oto wspaniała instrukcja zaoszczędziła czas i nerwy programistom na architekturze x86, ponieważ już na etapie CPU sprawdza, czy wynik operacji na liczbach jest poprawny czy nie.
NaN !== NaN
Głównym powodem było zapewnienie programistom sposobu na wykrywanie NaN za pomocą testu x != x w czasach, gdy nie istniała jeszcze funkcja isnan() w językach programowania.
Z punktu logicznego ma to dużo sensu, ponieważ nie-wartość nie może być równa nie-wartości.
To jest celowy design, a nie bug.
typeof NaN === “number”
NaN jest częścią systemu liczbowego (IEEE 754), a nie osobnym typem. To specjalna wartość liczbowa sygnalizująca błąd operacji matematycznej.
IEEE 754-1985: Standard for Binary Floating-Point Arithmetic
- Opublikowany: 1985
- Autor: William Kahan (UC Berkeley) + komitet IEEE
- Definiuje: NaN, Infinity, denormalized numbers, rounding modes
Kluczowe decyzje:
NaN !== NaN(zawszefalseprzy porównaniu równości)- Wykładnik =
0x7FF, mantysa ≠ 0 - Quiet NaN (qNaN) - propaguje się przez operacje bez sygnalizowania wyjątku
- Signaling NaN (sNaN) - generuje wyjątek przy pierwszym użyciu w operacji
- Propagacja
NaN(każda operacja zNaN→NaN)
NaN jest liczbą, ale jaką?
Może Cię zdziwiło, dlaczego rejestry dla liczb zmiennoprzecinkowych są używane w przypadku dzielenia 0/0.
Operacje na liczbach typu number w JavaScripcie są reprezentowane jako liczby zmiennoprzecinkowe podwójnej precyzji (double), aby przeprowadzić na nich operacje zgodnie ze standardem IEEE 754.
W operacjach na liczbach całkowitych dzielenie przez zero jest jednoznacznym błędem. W liczbach zmiennoprzecinkowych mamy jednak wiele przypadków, które mogą prowadzić do nieokreślonych wyników:
0.0 / 0.0→NaN∞ - ∞→NaN0 * ∞→NaNsqrt(-1)→NaN
Bez standardu IEEE 754 każdy producent sprzętu radził sobie z tymi sytuacjami inaczej, co skutkowało ogromnymi problemami z przenośnością kodu.
1994: Pentium FDIV bug
Bug w dzieleniu zmiennoprzecinkowym Pentium - niektóre dzielenia dawały złe wyniki. Nie był to problem z NaN, ale pokazał wagę precyzyjnej implementacji IEEE 754.
Intel wymienił miliony procesorów, co kosztowało firmę 475 milionów dolarów.
NaN jako zbawca programistów
Dowiedzieliśmy się, że NaN jest ustawiane na poziomie sprzętowym, ale co było zatem przed NaN?
Przed standardem IEEE 754 (1985) każdy producent sprzętu robił to po swojemu, co zazwyczaj oznaczało, że operacje takie jak 0/0 kończyły się crashem i zakończeniem programu.
Wymagało to od programistów bardzo defensywnego programowania. Wyobraź sobie, że lecisz samolotem, a w systemie sterowania programista nie przewidział 0/0 - instrukcja wykonuje się na CPU i crashuje cały program z powodu Division Error!
Intel i pozostali producenci mieli już dosyć chaosu wynikającego z różnego działania programów na różnych architekturach.
NaN (Not a Number)
Możemy sobie zadawać pytania, czemu akurat specjalna wartość zamiast innego rozwiązania.
Rozważmy różne opcje:
Opcja A: Division Error → CRASH (istniejąca przed IEEE 754)
- Nieoczekiwane zakończenie programu (patrz przykład z samolotem)
- Wymaga defensywnego programowania przed każdą operacją
Opcja B: Zwróć np. 0
- Matematycznie niepoprawne
- Maskuje błąd
- Dalsze obliczenia dają fałszywe wyniki
Opcja C: Zwróć null lub specjalny kod błędu
- Wymaga sprawdzania po każdej operacji
- Przerywa ciąg obliczeń matematycznych
- Typ wyniku staje się niespójny
Opcja D: Specjalna wartość NaN (wybrane przez IEEE 754)
- Wartość propaguje się przez obliczenia
- Program działa dalej
- Można sprawdzić wynik na końcu
- Zachowuje spójność typu (number)
Co by było, gdyby nie było NaN?
function divide(a, b) {
// Check types
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Arguments must be numbers');
}
// Check if numbers are valid
if (!isFinite(a) || !isFinite(b)) {
throw new Error('Arguments must be finite');
}
// Check divisor
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
function calculate(expression) {
try {
const result = divide(10, 0);
return result;
} catch (e) {
console.error(e.message);
return null; // Co zwrócić? null? undefined? 0?
}
}
Co jest dzięki NaN?
function divide(a, b) {
return a / b; // Hardware robi resztę!
}
function calculate(expression) {
return divide(10, 0);
}
const result = calculate("10 / 0");
console.log("Result:", result); // Infinity
const badResult = 0 / 0;
if (Number.isNaN(badResult)) {
console.log("Invalid calculation");
}
Podsumowanie
NaN to eleganckie rozwiązanie problemu obsługi błędów w obliczeniach zmiennoprzecinkowych:
- Zaimplementowane na poziomie sprzętowym (instrukcja
ucomisd) - Część standardu IEEE 754 od 1985 roku
- Propaguje się przez operacje, pozwalając wykryć błąd na końcu obliczeń
NaN !== NaNto celowy design umożliwiający detekcjętypeof NaN === "number"bo jest częścią systemu liczbowego, nie osobnym typem
