PHP 8.5 的 #[\NoDiscard] 和 (void):让被丢掉的返回值留下一条日志
PHP 8.5 新增了 #[\NoDiscard] 属性和与之配套的 (void) 转换。前者标记“返回值必须被消费”,后者表示“这次丢弃是故意的”。在支付、加锁、队列这类代码里,返回值一旦没人接就会静默失败,而这两个语法点正好把这种失败变成可检索的告警。
文档:
- 新特性:https://www.php.net/manual/en/migration85.new-features.php
- 不兼容变更:https://www.php.net/manual/zh/migration85.incompatible.php
语义:一个标记,一个豁免
<?php
#[\NoDiscard]
function concat(string $a, string $b): string
{
return $a . $b;
}
concat('a', 'b'); // Warning: The return value of function concat()
// should either be used or intentionally ignored
// by casting it as (void)
$result = concat('a', 'b'); // 无告警,赋值消费了返回值
(void) concat('a', 'b'); // 无告警,(void) 显式忽略
(void) 本身不改变程序执行结果,它的作用是抑制 #[\NoDiscard] 发出的告警,同时让人和 IDE、静态分析器都能看出“这里丢弃返回值是写代码的人知道的”。注意它是一个 cast 表达式,不要写进需要取值的上下文,比如 $x = (void) concat(...) 这种写法没有意义。
判断要不要给函数加 #[\NoDiscard],只看一件事:返回值里是否带着调用方后续需要的信息(结果对象、ID、成败),并且“没接住”属于 bug 而不是正常用法。纯副作用函数、日志写入之类的不要加,否则只会制造噪音。
三类值得加的调用点
支付:返回值里带 transaction_id,丢了它,后续对账和对账失败的重试都接不上。
#[\NoDiscard]
function charge(Order $order, Money $amount): ChargeResult
{
// 失败抛异常,成功返回带 transaction_id 的结果
}
charge($order, $amount); // 危险:transaction_id 被丢弃
$result = charge($order, $amount); // 正常路径
(void) charge($order, $amount); // 明确表示这次不需要回执
加锁:锁没拿到却继续往下跑,是最典型的静默事故。
#[\NoDiscard]
function acquireLock(string $key, int $ttl): bool
{
// ...
}
if (!acquireLock('order:' . $orderId, 30)) {
throw new RuntimeException('lock busy');
}
acquireLock('order:' . $orderId, 30); // 告警:锁的成败没人判断
队列:投递返回消息 ID 时,丢弃它等于放弃幂等追踪。
#[\NoDiscard]
function enqueue(string $queue, array $payload): string
{
// ...
}
enqueue('payment.settle', $payload); // 告警
$messageId = enqueue('payment.settle', $payload);
让告警进入日志
#[\NoDiscard] 触发的是 Warning 这一档的错误。生产环境常见的坑是把 error_reporting 收得太窄,或者入口里残留 error_reporting(0),导致告警既不进日志也不进监控。最小配置:
; php.ini
error_reporting = E_ALL
log_errors = On
error_log = /var/log/php/app-error.log
display_errors = Off
再在入口挂一个错误处理器,把这类告警变成异常,走已有的异常上报链路:
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
if (!(error_reporting() & $errno)) {
return false;
}
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
把全部 E_WARNING 转成异常对老项目冲击较大,可以先只改新代码路径,或在 CI 里用 php -d error_reporting=E_ALL 跑测试、把输出重定向到文件后再统计。验证语法本身是否生效,一行命令就够:
php -d error_reporting=E_ALL -d display_errors=1 -r '
#[\NoDiscard] function f(): int { return 1; }
f();
(void) f();
'
输出里应当只看到 f() 那一条 Warning,(void) f() 不产生输出。
与静态分析的分工
(void) 对静态分析器的影响是“可能”的:PHPStan、Psalm、IDE 各有自己的未使用返回值规则,它们未必读这个属性,也未必因为 (void) 就消掉诊断。因此不要把 (void) 当成压制静态分析告警的通用手段——它的第一职责是运行时告警豁免,第二职责是给读代码的人留下“故意丢弃”的标记。
反过来,#[\NoDiscard] 是把约定写进代码,而不是写进 docblock。已有基线里靠注解表达的“返回值必须使用”,可以逐步迁移到属性上。升级后静态分析报告数量可能变化,先跑 --dry-run 再落地:
vendor/bin/rector process src --dry-run # 配置 withPhpSets(php85: true)
vendor/bin/phpstan analyse
顺带清掉的 8.5 弃用
同一轮升级里,几项可以用 grep 机械定位的清理,和 #[\NoDiscard] 一起做:
# 非规范 cast 名
grep -rnE '\((integer|boolean|double|binary)\)' --include='*.php' src/
# 反引号运算符
grep -rn '`' --include='*.php' src/
# 已弃用的资源释放函数
grep -rnE '\b(curl_close|imagedestroy|xml_parser_free)\s*\(' --include='*.php' src/
替换关系:(integer) → (int)、(boolean) → (bool)、(double) → (float)、(binary) → (string);反引号命令执行改用 shell_exec();curl_close()、imagedestroy()、xml_parser_free() 的调用可以直接删掉。grep 反引号会命中字符串和注释里的同字符,结果需要人工过一遍。
除此之外,升级检查里几处容易被忽略的顺序:先用 error_reporting(E_ALL) 跑全量测试并把 deprecation 日志保存下来,再处理序列化(__sleep/__wakeup 已软弃用),然后单独审查 PDO::FETCH_CLASS 的构造参数(改为 call_user_func_array 语义,字符串键当命名参数)和 enum 与布尔松散比较(统一按 (bool)$object,$enum == $otherEnum 的结果可能翻转)。Docker 镜像和 INI 也要 diff 一遍,确认 ICU 版本满足 57.1+,以及 zend_extension=opcache.so 这类旧配置不会因为 opcache 总是内置而报警。