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 (zawsze false przy 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 z NaNNaN)

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.0NaN
  • ∞ - ∞NaN
  • 0 * ∞NaN
  • sqrt(-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 !== NaN to celowy design umożliwiający detekcję
  • typeof NaN === "number" bo jest częścią systemu liczbowego, nie osobnym typem

Źródła