前言
先说清楚版本:fdiv()确实是PHP 8.0引入的函数,这个标题里的版号没问题。它和 PHP 8.0 的另一项改动是配套出现的——从 PHP 8.0 起,1 / 0不再像 PHP 7 那样"发个 Warning 然后给你 INF",而是直接抛DivisionByZeroError。很多从 7.x 升上来的项目,升级后第一批发现在线的 500,就是除零导致的。
但业务里确实存在"除数是 0 也要算出一个值"的场景:评分、增长率、权重、单价。用fdiv()就能拿到一个 IEEE 754(IEEE 二进制浮点算术标准)意义上的结果——INF、-INF或NAN——而不抛异常。
问题随之而来:INF和NAN不是能直接用的值。它们能参与运算、能比较、能输出,但一旦进入 JSON、数据库、模板或者前端展示,行为就变得非常反直觉。本文讲清楚三件事:fdiv()到底返回什么、INF/NAN在 PHP 各处的真实表现、以及一套能落地的处理范式。
一、fdiv()与除零:四个 API 的差别
分子分母都是 0 或者分母为 0 时,PHP 里四个"除法类"函数的行为完全不同,这是最容易记混的地方。
| 写法 | 版本 | 除数为 0 时 | 返回类型 |
|---|---|---|---|
$a / $b | PHP 8.0 起 | 抛DivisionByZeroError | int或float |
intdiv($a, $b) | PHP 7.0+ | 抛DivisionByZeroError | int |
fmod($a, $b) | PHP 8.0 起对 0 抛错 | 抛DivisionByZeroError | float |
fdiv($a, $b) | PHP 8.0+ | 不抛错,返回INF/-INF/NAN | float |
fdiv()的签名是fdiv(int|float $num1, int|float $num2): float,永远返回float,永远不抛异常。它返回什么,完全遵循 IEEE 754:
- 正数除以正零 →
INF(正无穷) - 负数除以正零(或正数除以负零)→
-INF(负无穷) - 零除以零 →
NAN(Not a Number,非数) - 溢出(比如
fdiv(1e308, 1e-10))→INF,同样不报错
<?php // 需要 PHP 8.0+ var_dump(fdiv(1, 2)); // float(0.5) var_dump(fdiv(1, 0)); // float(INF) var_dump(fdiv(-1, 0)); // float(-INF) var_dump(fdiv(0, 0)); // float(NAN) var_dump(fdiv(1.0, 0.0)); // float(INF) —— 浮点零一样 // 对比:PHP 8.0 起,这些都会抛 DivisionByZeroError try { $x = 1 / 0; } catch (DivisionByZeroError $e) { echo $e->getMessage(), PHP_EOL; // Division by zero }注意最后一个例子:1 / 0抛异常是PHP 8.0的行为。在 PHP 7.x 上它是 "Warning: Division by zero" 加一个INF。所以判断"线上会不会 500"之前,先确认线上跑的是哪个大版本。
二、INF和NAN为什么难缠
INF与NAN都是合法的float值,is_float()对它们返回true。麻烦在于它们不满足普通数值的直觉:
1.NAN不等于自己。这是 IEEE 754 的硬规定,不是 PHP 的 bug:
<?php var_dump(NAN === NAN); // bool(false) var_dump(NAN == NAN); // bool(false) var_dump(in_array(NAN, [NAN])); // bool(false)(默认宽松比较) var_dump(NAN <=> NAN); // int(1) —— 排序时会出现诡异结果所有涉及NAN的比较都是false,除了!=。所以唯一正确的判断方式是is_nan()。
2.INF参与运算是"污染式"的。一旦进入计算链,结果就再也不是有限数:
<?php var_dump(INF + 1); // float(INF) var_dump(INF - INF); // float(NAN) —— 无穷减无穷是 NAN var_dump(INF * 0); // float(NAN) var_dump(1 / INF); // float(0) —— 注意这里不抛错,因为 INF 不是零 var_dump(is_finite(INF)); // bool(false)3. 三个判定函数必须用对:
| 函数 | INF | -INF | NAN | 普通数 |
|---|---|---|---|---|
is_infinite() | true | true | false | false |
is_nan() | false | false | true | false |
is_finite() | false | false | false | true |
注意is_infinite()对-INF也返回true(它只判"是不是无穷",不判方向),要判方向得用$v > 0/$v < 0。另外is_finite()等价于!is_infinite($v) && !is_nan($v),但它一次就够,语义也更清楚。
4.INF/NAN进 JSON 会直接失败。这是线上最痛的一环:
<?php $data = ['ratio' => fdiv(1, 0)]; // INF var_dump(json_encode($data)); // bool(false) var_dump(json_last_error_msg()); // string(28) "Inf and NaN cannot be JSON encoded" // PHP 7.3+ 可以让它抛异常,比返回 false 好排查 try { json_encode($data, JSON_THROW_ON_ERROR); } catch (JsonException $e) { echo $e->getMessage(), PHP_EOL; } // JSON_PARTIAL_OUTPUT_ON_ERROR 会把非法值替换成 0,看起来"成功"了, // 但数据被悄悄改了 —— 这才是最难查的那种 bug var_dump(json_encode($data, JSON_PARTIAL_OUTPUT_ON_ERROR)); // {"ratio":0}json_encode()在遇到非法值(INF/NAN,或非法 UTF-8 字符串)时返回false,而很多框架的响应层并不检查返回值,于是接口返回空 body 或者截断的 JSON,前端JSON.parse报错,排查方向还会跑偏到"网络问题"上去。
三、实战:一处收敛的"安全除法"
正确的做法不是到处写if ($b == 0),而是把"非法值"在边界上一次性处理掉:入口处用fdiv()拿到结果,立刻用一个函数把它规整成可输出的形态。
<?php // safe-divide.php —— 需要 PHP 8.0+ // 用法:php safe-divide.php /** * 安全除法:永不抛异常,永远返回可安全序列化的值。 * * @param int|float $a 被除数 * @param int|float $b 除数 * @param float|null $fallback 结果为 INF/-INF/NAN 时的兜底值,null 表示保留原值 */ function safeDiv(int|float $a, int|float $b, ?float $fallback = 0.0): float { $result = fdiv($a, $b); if (!is_finite($result)) { return $fallback ?? $result; } // 顺手处理 -0.0:它在 JSON 里会变成 -0 return $result === 0.0 ? 0.0 : $result; } /** 把任意浮点值归一化成 JSON 安全的值 */ function jsonSafe(mixed $value): mixed { if (is_float($value) && !is_finite($value)) { return null; // 或者 0,取决于业务语义 } if (is_array($value)) { return array_map('jsonSafe', $value); } return $value; } $cases = [ [1, 2], [1, 0], [-1, 0], [0, 0], [1e308, 1e-10], [7.5, 2.5], ]; foreach ($cases as [$a, $b]) { $raw = fdiv($a, $b); printf( "fdiv(%s, %s) => %-6s is_finite=%s\n", var_export($a, true), var_export($b, true), var_export($raw, true), var_export(is_finite($raw), true) ); } $metrics = [ 'hit_rate' => safeDiv(120, 0), // 除零 -> 兜底 0.0 'growth' => safeDiv(50, 200), // 正常 -> 0.25 'zero_zero' => safeDiv(0, 0), // NAN -> 兜底 0.0 ]; echo PHP_EOL, 'safe: ', json_encode($metrics, JSON_UNESCAPED_UNICODE), PHP_EOL; // 不加防护时的后果:json_encode 直接失败 try { echo json_encode(['growth' => fdiv(1, 0)], JSON_THROW_ON_ERROR), PHP_EOL; } catch (JsonException $e) { echo 'JSON 失败: ', $e->getMessage(), PHP_EOL; }输出大致如下:
fdiv(1, 2) => 0.5 is_finite=true fdiv(1, 0) => INF is_finite=false fdiv(-1, 0) => -INF is_finite=false fdiv(0, 0) => NAN is_finite=false fdiv(1.0E+308, 1.0E-10) => INF is_finite=false fdiv(7.5, 2.5) => 3.0 is_finite=true safe: {"hit_rate":0,"growth":0.25,"zero_zero":0} JSON 失败: Inf and NaN cannot be JSON encoded这段代码的关键点有三个:一是在唯一出口做归一化(jsonSafe挂在响应层),二是兜底值可配置(业务上0和null语义完全不同,前者表示"零",后者表示"无法计算"),三是绝不依赖json_encode()的false返回值,而是显式检查。
对入库场景,同样的思路:INF写进DECIMAL列会报错或被截断,所以入库前也必须过一遍is_finite()。表结构上也要注意,INF需要DOUBLE/FLOAT才能存,且 MySQL 中-INF与INF的比较行为与 PHP 一致(INF > 任何有限值),但排序时NAN的表现依赖具体版本,不要依赖它。
常见坑点
1. 用== 0判断除数,漏掉-0.0和浮点误差
// ❌ 看似安全,其实 -0.0 == 0 为 true 会被拦下,但浮点误差产生的极小值不会 if ($b == 0) { return 0; } $r = $a / $b; // $b = 1e-320 时结果可能是 INF,仍然抛不抛错取决于溢出// ✅ 交给 fdiv 拿值,再用 is_finite 判定"这个结果能不能用" $r = fdiv($a, $b); return is_finite($r) ? $r : $fallback;2. 用$x == NAN或in_array($x, [...])判 NAN
// ❌ 永远为 false,等于这段判断完全没生效 if ($value == NAN) { $value = 0; }// ✅ 只有一个正确写法 if (is_nan($value)) { $value = 0; }3. 直接用is_infinite()判正负无穷,把方向搞丢
// ❌ 只判是不是无穷,实际上可能是个负数 if (is_infinite($rate)) { $rate = 999; }// ✅ 先判有限性,再按方向给兜底 if (!is_finite($rate)) { $rate = is_infinite($rate) && $rate > 0 ? PHP_FLOAT_MAX : 0.0; }4. 把INF存进数据库INT/DECIMAL列
// ❌ 取决于列类型与服务端 SQL 模式,可能报错也可能被截成 0 或最大值 $pdo->prepare('INSERT INTO stats (rate) VALUES (?)')->execute([fdiv(1, 0)]);// ✅ 入库前统一归一到有限值,并明确 NULL 的语义 $rate = fdiv(1, 0); $stmt = $pdo->prepare('INSERT INTO stats (rate) VALUES (?)'); $stmt->execute([is_finite($rate) ? $rate : null]);5. 以为is_float()能过筛,把INF放进 JSON
// ❌ 类型检查通过,序列化时炸掉 if (is_float($v)) { $out['rate'] = $v; }// ✅ 类型对不代表值可用,序列化前必须做值域检查 if (is_float($v) && is_finite($v)) { $out['rate'] = $v; }6. 用round()/number_format()指望把INF修好
// ❌ INF 经格式化后仍不是合法数值语义,且行为依赖版本,别指望它兜底 echo number_format(fdiv(1, 0), 2);// ✅ 先归一化,再格式化 $v = fdiv(1, 0); echo is_finite($v) ? number_format($v, 2) : 'N/A';7. 用JSON_PARTIAL_OUTPUT_ON_ERROR掩盖问题,导致数据静默变成 0
// ❌ 序列化"成功"了,但 INF 被替换成 0,业务上完全说不通,还查不出原因 echo json_encode($data, JSON_PARTIAL_OUTPUT_ON_ERROR);// ✅ 让问题在开发/测试环境直接抛出来,生产环境则先归一化再序列化 try { echo json_encode(jsonSafe($data), JSON_THROW_ON_ERROR); } catch (JsonException $e) { error_log('响应序列化失败: ' . $e->getMessage()); http_response_code(500); }8. 把fdiv()当唯一手段,忘了0 / 0在业务上是"无定义"而不是"零"
// ❌ 0/0 被兜底成 0,前端显示"命中率 0%",掩盖了"根本没有流量"这个事实 $hitRate = safeDiv($hits, $total, 0.0);// ✅ 区分"除数为零"和"确实为零",用 null 表达不可计算 $hitRate = $total === 0 ? null : safeDiv($hits, $total);总结
| 关注点 | 结论 |
|---|---|
fdiv()版本 | PHP 8.0 引入,标题版号正确 |
| 除数为 0 时的返回值 | INF/-INF/NAN,不抛异常 |
1 / 0的行为 | PHP 8.0 起抛DivisionByZeroError(PHP 7 是 Warning +INF) |
| 判定无穷 / 非数 | is_finite()一把梭;判NAN只能is_nan() |
NAN的比较 | 与任何值(包括自己)比较都为false |
| 进 JSON | json_encode()返回false,需先归一化或显式抛错 |
| 进数据库 | 必须先is_finite()过滤,用NULL表达"不可计算" |
结论很简单:fdiv()解决的是"不要因为除零而炸掉"这一个问题,它不解决"无穷大怎么展示、怎么入库、怎么返回给前端"这些问题。把fdiv()的调用点收敛到少数几个函数里,并在这些函数的出口统一做is_finite()归一化,才是能长期维护的做法。反过来,在业务代码各处零散地写fdiv()再各处补判断,用不了多久就会漏掉一个出口,然后以"接口偶发返回空"的形式出现在线上。