← 返回信息流
AI 资讯Hacker News·4 天前

Two Case Studies of NaN

AI 深度解读

背景

IEEE-754 定义的 NaN(Not a Number)是一种特殊的浮点值,其行为与普通数值截然不同。例如,NaN 不与任何值相等,包括自身。这种反直觉的属性常常在编程语言设计中被意外忽略,导致语言实现中包含隐含的假设,而这些假设在遇到 NaN 时会被打破。本文通过两个案例(Python 和 Lua)揭示了这种设计疏漏。

核心内容

案例 1:Python 列表相等比较

Python 中,NaN 与自身比较的结果是 False

>>> from math import nan
>>> nan == nan
False

这是 IEEE-754 规范所定义的合理行为。然而,当比较两个包含 NaN 的列表时,结果却出人意料:

>>> [nan] == [nan]
True

原因在于 Python 对列表相等性比较做了优化:首先使用身份标识(identity)比较元素,只有当两个元素的身份不同时,才会转而使用 == 运算符进行值比较。由于 [nan] 中的两个 nan 是不同的对象(身份不同),优化逻辑会跳过身份比较直接进入值比较;而当列表中的元素是同一个 NaN 对象时,身份比较会认为它们相等(因为 x is y 成立),从而直接返回 True,不再进行 == 比较。

Python 官方文档中明确建议自定义比较操作应遵循自反性规则:

Equality comparison should be reflexive. In other words, identical objects should compare equal: x is y implies x == y.

NaN 打破了这一规则,但大多数其他对象都遵循它,因此 Python 的设计者基于此假设进行了优化,导致 NaN 获得了看似矛盾的行为。需要注意的是,本文并非指责这种优化是错误的,而是指出这种隐含假设的存在值得关注。

案例 2:Lua 数值 for 循环

Lua 的数值 for 循环语法如下:

for i = 1, 10 do
    stuff()
end

其语义是:变量 i 从初始值(这里的 1)开始,每次迭代按步长(可选的第三个操作数,默认 1)递增,直到新的值超过上限(10)时循环终止。上述循环会执行 10 次,从 1 到 10(包含)。

当 NaN(例如 0/0)参与时,参考实现(PUC-Rio Lua)的行为令人费解:

  • 循环 for i = 0/0, 10 do 执行一次(输出 nan
  • 循环 for i = 0/0, 0/0 do 执行一次(输出 nan
  • 循环 for i = 1, 10, 0/0 do 永不执行
  • 循环 for i = 10, 1, 0/0 do 执行一次(输出 10.0

其背后的机制是:Lua 的 for 循环在首次进入时使用 limit < init 来判断是否执行,而后续迭代则使用 idx <= limit 的条件。由于 NaN 与任何数字比较都返回 false,所以第一次判断 10 < nan 为假,循环得以执行;但第二次迭代时 nan <= 10 同样为假,循环终止。这就是前两个循环只执行一次的原因。

对于步长为 NaN 的情况,Lua 通过 0 < step 来判断步长是否为正。由于 0 < NaN 始终为 false,NaN 被视为负步长。因此第三个循环(步长 NaN,初始值小于上限)被视为反向循环,但首次判断 limit < init(即 10 < 1)为假,所以根本不会执行;而第四个循环(初始值大于上限)首次判断 1 < 10 为真,执行一次后因 idx <= limit 为假而停止。

这些行为在 Lua 的文档中完全没有记录,很可能是一个疏忽。一个简单的修复方案是在数值 for 循环中检测并禁用 NaN——Lua 已经对步长为 0 的情况抛出了错误,却忽略了 NaN。

额外案例

Chris Siebenmann 曾撰文讨论 Go 语言中 NaN 作为 map 键时的诡异行为,这可以视为第三个案例,进一步佐证 NaN 的奇怪性质。

关键要点

  • NaN 的 IEEE-754 定义导致 NaN == NaNFalse,破坏了自反性假设。
  • Python 列表比较优化利用身份比较(identity check)提升性能,但该优化假设了自反性,导致 [nan] == [nan] 可能返回 TrueFalse 取决于元素是否为同一对象。
  • Lua 数值 for 循环的实现使用了不同的比较运算符(首次判断 limit < init,后续判断 idx <= limit),NaN 参与时产生了反直觉的单次执行或不执行行为。
  • Lua 中 NaN 作为步长时,因 0 < NaN 恒为假而被视为负步长,但行为仍与正常负步长不同(循环只执行一次)。
  • 上述行为在 Lua 文档中未提及,表明这是设计时的疏忽;而 Python 的行为虽合理但反映了隐含假设的脆弱性。
  • 简单解决方案是在循环逻辑中显式检查 NaN 并报错,类似 Lua 对零步长的处理。

意义与影响

NaN 的怪异行为并非理论上的特例——它在实际编程中可能因数值运算而自然出现。语言设计者在实现优化或控制流时,若未充分考虑 NaN 的非自反性和不可比性,就会产生难以预测的漏洞。这些案例提醒我们:

  • 通用假设往往不适用于特殊值,自反性(x is y ⇒ x == y)和比较的可传递性在 NaN 面前均失效。语言实现应明确处理这类边界情况。
  • 文档与规范的重要性:Lua 的行为完全没有文档记录,用户无法预期其行为,这增加了调试难度。显式抛错或至少注明行为是最小成本的安全措施。
  • 对语言设计者的启示:在优化(如 Python 的身份比较)或控制流(如 Lua 的循环条件)中,应避免依赖“所有值都遵循自反性”的前提,或者为 NaN 等特殊值预留单独的检查路径。

NaN 的存在本身就是对“数值总可比”这一直觉的挑战。编程语言在追求效率和简洁的同时,需要为这种数学上的“异常”留出容错空间,否则看似优雅的设计会在边缘情况下暴露意外的行为。

查看原文 →sebsite.pw