为什么 JavaScript 中 NaN !== NaN(以及其背后的 IEEE 754 标准)

20 阅读6分钟

一个始终返回相同结果的数字

今天我们将了解 NaN 类型,在 JavaScript 中,它被标识为数字 number

> typeof NaN
'number'

我们收到响应中的类型 number

既然某个事物是数字,那么根据逻辑,我们可以对其进行数学运算。

那么我们来尝试添加一些内容,或者检查它的最大值或最小值。

> NaN + 1 
NaN
> NaN - 1
NaN
> Math.max(NaN)
NaN
> Math.min(NaN)
NaN

如你所见,经过加减运算,检查最大值和最小值之后,我们总是得到相同的结果。

如果真是这样,为什么我们需要这样的值呢?

为了解释这一点,让我们研究一下 Firefox 或 V8,看看 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();

查看示例浏览器的代码,会发现标准库中的 std::isnan 方法用于检查 NaN ,这可能已经暗示我们,这是独立于 JavaScript 出现的问题。

事实上,从历史角度来看, NaN 的第一个标准化出现在 1985 年,并给定在 IEEE 754 里。

从 JavaScript 到硬件层面

掌握了这些知识后,让我们用 C 语言编写一个简单的程序,根据我们在浏览器代码中发现的内容,检查 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;
}

结果与 JavaScript 中的相同!

我们已经知道,在其他编程语言中也会遇到 NaN

#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)
}

但我们仍然不知道它是做什么用的。

既然我们不知道,那就让我们为这个简单的程序生成汇编代码吧(让我们跳过序言和栈帧初始化)。

# =====================================
#     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 - code here
.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 - code here
.L3:

对于那些不熟悉汇编语言的人来说——值得注意的是 xmm0 寄存器,它负责对浮点数进行运算。这很合乎逻辑:我们想要对数字进行运算,而 CPU 本身也在处理数字,所以使用专门为此目的设计的寄存器进行运算速度最快!

我们还可以看到 ucomisd 指令,该指令负责在检测到 NaN 时设置一个标志。

我们能从中得出什么结论? NaN 是在硬件层面实现的,而不是在 JavaScript 抽象层面实现的。

因此,为了避免不必要的抽象,我们将程序用汇编语言重写,并检查其执行结果:

#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;
}

结果如何?

NaN is not the same
x is NaN
NaN hex: 0xfff8000000000000

该程序的输出与 C 语言的输出相同。

我们已经知道 NaN 是原生实现的,所以让我们来看看 ucomisd 指令。

"ucomisd xmm0, %1\n\t"          // compare x with x → PF=1 for NaN

ucomisd 或称 Unnordered Compa re Scalar D 双精度浮点数指令。这条出色的指令为 x86 架构的程序员节省了大量时间和精力,因为它在 CPU 层面上就已经检查了数字运算的结果是否正确。

NaN !== NaN

主要原因是,在编程语言中还没有 isnan() 函数的时候,为程序员提供了一种使用 x != x 测试来检测 NaN 方法。

从逻辑角度来看,这是有道理,因为无值不可能等于无值。

这是有意为之的设计,并非漏洞

typeof NaN === “number”

NaN 是数值系统( IEEE 754 )的一部分,而不是一种单独的类型。它是一个特殊的数值,表示数学运算错误。

IEEE 754-1985:二进制浮点运算标准

  • 发布:1985
  • 作者:William Kahan (UC Berkeley) + IEEE committee
  • 定义:NaN, Infinity, denormalized numbers, rounding modes

关键决策:

  • NaN !== NaN 在比较相等时总是为 false
  • Exponent = 0x7FF, mantissa ≠ 0
  • Quiet NaN(qNaN) - 在操作过程中传播,而不会发出异常信号。
  • Signaling NaN(sNaN) - 在操作中首次使用时会生成异常
  • NaN - 任何和 NaN 运算 → NaN

NaN is a Number, 但它是什么类型?

你可能会惊讶于为什么浮点数寄存器用于 0/0 除法。

JavaScript 中对 number 类型值的操作以双精度浮点数 (double) 表示,并按照 IEEE 754 标准对其进行操作。

在整数运算中,除以零是明确的错误。然而,在浮点数运算中,有很多情况会导致未定义的结果:

  • 0.0 / 0.0NaN
  • ∞ - ∞NaN
  • 0 * ∞NaN
  • sqrt(-1)NaN

如果没有 IEEE 754 标准,每个硬件制造商都会以不同的方式处理这些情况,从而导致巨大的代码可移植性问题。

1994:Pentium FDIV Bug

奔腾处理器的浮点除法存在一个缺陷——某些除法运算会给出错误的结果。这并非 NaN 值的问题,但它凸显了精确实现 IEEE 754 标准的重要性。

英特尔更换了数百万个处理器,这花费了公司 4.75 亿美元。

NaN:程序员的救星

我们了解到 NaN 是在硬件级别设置的,但是 NaN 之前是什么呢?

在 IEEE 754 标准(1985 年)之前,每个硬件制造商都按照自己的方式进行操作,这通常意味着像 0/0 这样的操作最终会导致崩溃和程序终止。

这就要求开发人员采用非常严谨的防御性编程。想象一下,你正在驾驶飞机,控制系统中的程序员没有预料到 0/0 情况——这条指令会在 CPU 上执行,并由于除法错误导致整个程序崩溃!

英特尔和其他制造商对程序在不同架构上运行方式不同而造成的混乱感到厌烦。

NaN(非 Number)

我们可以问问自己,为什么选择这个特殊值而不是其他解决方案。

让我们考虑不同的方案:

选项 A:除法错误 → 崩溃(IEEE 754 标准之前已存在)

  • 程序意外终止(参见飞机示例)
  • 每次操作前都需要进行防御性编程。

选项 B:返回,例如,0

  • 数学上不正确
  • 掩盖错误
  • 进一步的计算会得出错误的结果。

选项 C:返回 null 或特殊错误代码

  • 每次操作后都需要检查
  • 中断数学计算链
  • 结果类型变得不一致

选项 D:特殊值 NaN (由 IEEE 754 选择)

  • 数值通过计算传播
  • 程序继续运行
  • 最后可以查看结果
  • 保持类型一致性(数字)

如果没有 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;  // What to return? null? undefined? 0?
    }
}

由于存在 NaN ,我们得到了什么?

function divide(a, b) {
    return a / b;  // Hardware does the rest!
}

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");
}

总结

NaN 是解决浮点运算中错误处理问题的一种优雅方案:

  • 在硬件层面实现( ucomisd 指令)
  • 自 1985 年起成为 IEEE 754 标准的一部分
  • 错误会在整个计算过程中传播,从而可以在计算结束时检测到错误
  • NaN !== NaN 是有意为之的设计,以便于检测
  • typeof NaN === "number" 因为它属于数值系统,而不是一个单独的类型

相关阅读