PHP 8.5 #[\NoDiscard] 揪出"忽略返回值"的 Bug
有些 bug 会导致异常、致命错误、监控面板一片红。
还有一类 bug 长这样:"一切都跑了,但什么都没发生"。方法调了,副作用也有了,但关键返回值(成功标志、错误列表、新的不可变实例)被扔掉了。粗看代码没毛病,测试没覆盖到边界情况也能过。bug 就这么混进生产环境。
PHP 一直允许这种风格的失误:
php
doSomethingImportant(); // 返回了一个值......但没人用
PHP 8.5 新增了一种原生方式来标记这类情况:#[\NoDiscard]。
给函数或方法加上 #[\NoDiscard],调用方要是不用返回值,PHP 就会发警告。可以把它理解为"编译器级别的提示"(实际由引擎在运行时/编译时执行),不抛异常、不改行为,只是让 API 更安全。
本文讲的是怎么用好 #[\NoDiscard]:
- 它能防哪些 bug
- 具体语义(什么算"用了"返回值)
- 高价值模式:Result / Either、不可变构建器
- 怎么推广才不会让团队反感
- 什么时候别用(误报确实存在)
原文 PHP 8.5 #[\NoDiscard] 揪出"忽略返回值"的 Bug
常见"惯犯":返回值被忽略的几种场景
PHP 代码库里有几个常见的"惯犯"。
返回布尔值,默认它总是成功
典型案例:
php
$ok = rename($tmpFile, $finalFile);
有人重构,赋值没了:
php
rename($tmpFile, $finalFile);
// 继续跑,当作移动成功了
开发环境没事。生产环境碰上权限边界情况,你读的文件压根没移动过。
不是每个返回布尔值的函数都该标 #[\NoDiscard]。但你自己的 API 里,如果返回值有意义,忽略它至少该引起警觉。
返回错误信息,但正常路径太常见,失败没人注意
批处理是重灾区:99.9% 成功,忽略返回值不会破坏大多数运行。
典型场景:
- 函数处理多个条目
- 返回每个条目的错误详情
- 副作用照常发生
- 忽略返回值 = 部分失败被藏起来了
官方 RFC 就是用这个逻辑来解释 #[\NoDiscard] 的设计动机。
不可变 API 返回新实例,调用却像在原地改
这种情况很微妙,从可变对象迁移到不可变对象时特别常见。
你写了个不可变的"更新"方法:
php
$user = $user->withEmail($newEmail);
后来有人写成:
php
$user->withEmail($newEmail);
// 以为 $user 变了......其实没变
没报错,没异常,状态就是静悄悄地没变。
RFC 明确提到 DateTimeImmutable::set*() 就是典型:"听起来像原地修改,实际返回新实例"。
返回 Result 对象,但忘了解包检查
如果你用 Result 类型(或 Either)来避免异常,忽略返回值基本就是忽略了错误。
不一定马上出问题------但错误处理被推到了"以后再说",而"以后"往往等于"永远不"。
#[\NoDiscard] 是什么
简单说,#[\NoDiscard] 是加在函数和方法上的属性,意思是:
"调用了却不用返回值?多半是 bug。"
最简用法:
php
#[\NoDiscard]
function createSession(): string {
return bin2hex(random_bytes(16));
}
createSession(); // PHP 8.5 中会产生警告
RFC 和 PHP 手册里定义了具体行为:
- 调用
#[\NoDiscard]函数但没用返回值,PHP 发警告 - 内置函数发
E_WARNING;用户定义函数发E_USER_WARNING - 可以带消息:
#[\NoDiscard("...")],消息会出现在警告里(跟#[Deprecated]类似)
什么算"用了"?
最关键的细节:"用了"是语法层面的判断,不是语义层面的。
RFC 对"用了返回值"的定义很宽松:返回值只要成为任意表达式的一部分就行。赋值给变量------哪怕是个哑变量------算。类型转换也算。
所以下面这些都算"用了":
php
$unusedButAssigned = createSession(); // 无警告
(bool) createSession(); // 无警告(但见下面关于 OPcache 的说明)
也就是说 #[\NoDiscard] 不保证行为正确,只保证你没把结果直接扔地上。
(void) 强制转换:显式丢弃
PHP 8.5 还引入了 (void) 强制转换:
php
(void) createSession(); // 无警告
没有运行时效果,纯粹表明意图:"是的,我故意不用它。"可以用来抑制 #[\NoDiscard] 警告,IDE 和静态分析工具也能识别。
RFC 里有个细节:(void) 是语句不是表达式,不能嵌到其他表达式里,否则语法错误。
使用约束
RFC 规定这些情况会编译报错:
- 返回类型是
: void或: never的函数 - 必须是 void / 无返回的魔术方法(
__construct、__clone等) - 属性钩子
所以这样写会报错:
php
#[\NoDiscard]
function logSomething(string $msg): void {
error_log($msg);
}
// Fatal: void 函数不返回值,但 #[\NoDiscard] 要求有返回值
这是故意的:没东西可丢弃,这个属性就没意义。
锐边:警告可能变致命错误
大多数团队把警告当噪音。有些团队把警告转成异常(严格环境里常见)。
RFC 指出,引擎在调用函数之前(参数求值之后)就验证"返回值有没有被用"。如果你配了个会抛异常的错误处理器,警告一触发就抛异常,函数压根不会被调用------RFC 把这叫"fail-closed"行为。
对 #[\NoDiscard] 函数来说,这通常是好事(忽略返回值本来就不安全),但如果函数有重要副作用,你得心里有数。
实用示例
看看实际怎么用。下面这些模式是 #[\NoDiscard] 真正能发挥价值的地方。
Result 类型
一个最小化的 Result 实现:
php
<?php
declare(strict_types=1);
final class Result
{
private function __construct(
private bool $ok,
private mixed $value,
private ?string $error,
) {}
public static function ok(mixed $value = null): self
{
return new self(true, $value, null);
}
public static function err(string $error): self
{
return new self(false, null, $error);
}
public function isOk(): bool { return $this->ok; }
public function isErr(): bool { return !$this->ok; }
public function unwrap(): mixed
{
if (!$this->ok) {
throw new RuntimeException($this->error ?? 'Unknown error');
}
return $this->value;
}
public function error(): ?string { return $this->error; }
}
假设有个验证函数返回 Result:
php
#[\NoDiscard("Validation results must be handled (ok/err)")]
function validateUsername(string $name): Result
{
$name = trim($name);
if ($name === '') {
return Result::err("Username cannot be empty.");
}
if (strlen($name) < 3) {
return Result::err("Username is too short.");
}
return Result::ok($name);
}
这样调用会触发警告:
php
validateUsername($_POST['username'] ?? '');
这就是你想要的效果:用了 Result 模式,忽略它几乎肯定是写错了。
正确的写法变成显式的:
php
$res = validateUsername($_POST['username'] ?? '');
if ($res->isErr()) {
http_response_code(422);
echo $res->error();
exit;
}
$username = $res->unwrap();
开发者还能写 $_ = validateUsername(...) 然后不管它吗?能,PHP 会认为"用了"。但主要的失败模式------不小心写了裸调用------被拦住了。
Either 风格
有些团队喜欢更结构化的错误:
php
final class ValidationError
{
public function __construct(public string $code, public string $message) {}
}
final class Either
{
private function __construct(
public bool $isRight,
public mixed $right,
public ?ValidationError $left,
) {}
public static function right(mixed $value): self
{
return new self(true, $value, null);
}
public static function left(ValidationError $err): self
{
return new self(false, null, $err);
}
}
返回 Either 的函数标 #[\NoDiscard] 通常没问题,因为本来就是要强制调用方决定走哪个分支。
不可变构建器
假设有个不可变构建器,每个方法返回新的构建器:
php
<?php
declare(strict_types=1);
final readonly class InvoiceBuilder
{
public function __construct(
public array $lines = [],
public int $totalCents = 0,
) {}
#[\NoDiscard("InvoiceBuilder is immutable; you must capture the returned builder.")]
public function withLine(string $label, int $amountCents): self
{
if ($amountCents < 0) {
throw new InvalidArgumentException('amountCents must be >= 0');
}
$newLines = $this->lines;
$newLines[] = ['label' => $label, 'amountCents' => $amountCents];
return new self(
lines: $newLines,
totalCents: $this->totalCents + $amountCents
);
}
#[\NoDiscard("Calling build() without using the invoice is almost certainly a bug.")]
public function build(): array
{
return [
'lines' => $this->lines,
'totalCents' => $this->totalCents,
];
}
}
看看典型错误:
php
$builder = new InvoiceBuilder();
$builder->withLine('Subscription', 1500);
$builder->withLine('Support', 500);
$invoice = $builder->build();
没有 #[\NoDiscard],这会产生一张没有行项目的发票,因为返回的构建器被扔掉了。
加上 #[\NoDiscard],每个被忽略的 withLine() 返回都会触发警告,逼你写对:
php
$builder = (new InvoiceBuilder())
->withLine('Subscription', 1500)
->withLine('Support', 500);
$invoice = $builder->build();
这就是 #[\NoDiscard] 要揪出来的 bug:容易犯、测试常能过、生产环境让人头疼。
PHP 自己也在用
RFC 给一小部分原生 API 加了 #[\NoDiscard],这些 API 忽略结果容易出隐蔽问题:
flock()(忽略锁定失败,竞争条件下可能数据损坏)DateTimeImmutable::set*()(从可变 DateTime 迁移过来时的常见坑)
就算你从不直接用这些函数,这也说明一件事:这个特性针对的是真实场景里的错误,不是为了理论上的"纯粹"。
采用策略
到处加 #[\NoDiscard] 只会制造噪音,团队迟早习惯性忽略。RFC 的建议是:只在忽略返回值可能是无意的、且会导致测试期间难以发现的 bug 的地方用。
务实的推广思路:
从危险的地方开始
高价值场景:
- 可能部分失败但继续执行的领域操作(批处理)
- 返回 Result / 错误列表的持久化调用
- 不可变更新方法(不可变对象上的
with*、set*) - 用返回值表示失败的 "try" 风格 API
低价值场景:
- 纯函数(如
str_contains()这类检查):调用后什么都不做本来就奇怪,忽略返回值很少造成隐蔽问题。RFC 拿str_contains()当反面例子 - 主要靠副作用、返回值只是顺便给的方法
让警告可见,但别炸生产
引擎发的是警告(不是异常),所以可以逐步收紧:
- 开发环境:把警告喊出来
- CI:把
E_USER_WARNING当失败(可选,过渡一段时间后) - 生产环境:保持默认,除非你确定警告策略够严格
记住:如果你们把警告转成异常,#[\NoDiscard] 会直接阻止函数运行(fail-closed)。有时候这是好事,但这种行为变化得有意识地引入。
代码审查集成
#[\NoDiscard] 用来强化团队规则效果最好,不是用来代替思考的。
下面是一套跟代码审查配合良好的简单规则:
把警告当设计信号
看到 #[\NoDiscard] 警告,别急着"消掉它"。先问:
- 是不小心忽略的吗?(最常见)
- 如果确实想忽略,用
(void)合适吗? - 还是说 API 返回的东西本身就有问题?
用 (void) 表明意图
比如:调用一个返回缓存键的方法,但你只要副作用:
php
(void) $cache->warmUp($userId);
这是干净、可读的约定:我故意丢弃这个值。
比下面这种写法强多了:
php
$unused = $cache->warmUp($userId);
因为 $unused 重构后很容易留下来,让后面的人摸不着头脑。
跟静态分析配合
静态分析器和 IDE 本来就会对纯函数的未使用返回值报警。RFC 提到,PHPStorm、PHPStan、Psalm 这些工具已经能抓"纯返回值被忽略"的问题(比如 DateTimeImmutable 的坑),但它们一般没法覆盖非纯函数的重要返回值------#[\NoDiscard] 填的就是这个空白。
所以组合起来是这样:
- 静态分析器:"纯返回值没用"
#[\NoDiscard]:"重要返回值没用"(哪怕是非纯的)
重构示例
做个贴近真实场景的重构:一个"保存"并返回状态的方法。
之前
php
final class UserRepository
{
public function save(User $user): bool
{
// ... 写入数据库 ...
// 冲突/失败时返回 false
return true;
}
}
调用点往往变成:
php
$repo->save($user);
// 当作保存成功了
如果返回值有意义,这就是埋着的雷。
之后
php
final class UserRepository
{
#[\NoDiscard("Save may fail; handle the return value or explicitly discard it with (void).")]
public function save(User $user): bool
{
// ... 写入数据库 ...
return true;
}
}
现在,忽略它的调用点都会报警告。
更好的做法
布尔值描述性不够。能改就返回 Result:
php
final class UserRepository
{
#[\NoDiscard("Save may fail; callers must handle the Result.")]
public function save(User $user): Result
{
// 示例逻辑
$ok = true;
if (!$ok) {
return Result::err("Write failed due to conflict.");
}
return Result::ok($user);
}
}
现在更难不小心跳过错误处理,API 也更自文档化。
确实想忽略时
有些场景确实要忽略:
- best-effort 的缓存写入
- 遥测发送
- 顺手清理
这时候 (void) 正合适:
php
(void) $repo->save($user); // "我就是不想检查这个。"
代码审查看着清楚,也能防止无意中"静默忽略"又混回来。
限制和误报
#[\NoDiscard] 很强大,但不是通用的"质量徽章"。用多了就是噪音,噪音会淹没信号。
别用在无害的函数上
纯查询比如:
str_contains()strlen()- 字符串转换辅助函数
调用了却什么都不做,bug 本来就很明显:算了个东西没用,还没副作用。RFC 明确说不要在 str_contains() 这类函数上用 #[\NoDiscard],因为忽略结果本来就不太可能,而且除了浪费点计算没啥坏处。
别用来强制编码风格
你会想给很多方法标 #[\NoDiscard],理由是"调用者总是接返回值更干净"。这是风格偏好,不是安全问题。
只在忽略返回值可能是无意的、而且有害的地方用。
注意"发射后不管"的 API
有些方法返回值只是顺便给的,主要靠副作用。给它们加 #[\NoDiscard] 会逼调用者到处写 (void),换一种杂乱而已。
如果你发现某个函数有一堆 (void),说明属性可能加错地方了。
"用了"不等于"处理了"
因为"用了"的定义很宽松,你可以满足 #[\NoDiscard] 但实际上什么都没处理:
php
$tmp = $repo->save($user); // 无警告,但语义上还是忽略了
这不是特性的缺陷------它提醒我们 #[\NoDiscard] 是护栏,不是完整的正确性证明。
团队命名约定
好团队不会只靠属性。他们用命名约定,在警告出现之前就引导正确用法。
下面是跟 #[\NoDiscard] 配合良好的命名约定:
with* 不可变更新
如果你的类是不可变的:
withEmail()withStatus()withTimeout()
这些方法基本都该标 #[\NoDiscard],因为忽略返回值通常意味着"啥也没变"。
try* 失败编码在返回值里
比如:
tryLock() : booltryParse() : ResulttryConnect() : Result
方法名以 try 开头,调用者一般会期望检查结果。标 #[\NoDiscard] 强化这个预期。
build() / finalize() 模式
build() 产生你要的东西,调用了却不用,基本就是写错了。
这里很适合加 #[\NoDiscard]。
消息要简短有行动指向
好的消息是你希望队友在 CI 日志里看到的:
- "This Result must be handled."
- "Immutable update: capture the returned instance."
- "Operation can partially fail; consume the error list."
RFC 支持可选消息,会出现在警告文本里。
结论
支持 #[\NoDiscard] 的最强理由不是理论,是可维护性。
忽略返回值是真实 PHP 代码里反复出现的失败模式------尤其是:
- 函数大多数时候都成功
- API 是不可变的(返回新实例)
- 失败靠返回值而不是异常来报告
PHP 8.5 给了一种原生手段来尽早抓住这些错误,用警告加显式 (void) 来保持故意丢弃时的可读性。
精准地用它:
- 从忽略返回值有害的领域/服务 API 入手
- 避开纯函数和"主要靠副作用"的方法
- 配合命名约定(
with*、try*),让代码在引擎报警之前就能读出正确用法
做到这些,#[\NoDiscard] 就会成为那种安静地减少生产事故的小特性------不用逼整个团队换编程模型。
参考资料
- PHP 8.5 发布公告(#[\NoDiscard] 概述、警告行为)
- PHP 手册:PHP 8.5 新特性(#[\NoDiscard] 和 (void) 强制转换)
- PHP RFC:"Marking return values as important (#[\NoDiscard])"(警告级别、"使用"的含义、(void) 强制转换细节、约束、推荐用法)