PHP 8.5 #[\NoDiscard] 揪出“忽略返回值“的 Bug

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() : bool
  • tryParse() : Result
  • tryConnect() : 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) 强制转换细节、约束、推荐用法)
相关推荐
BingoGo1 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php
JaguarJack1 天前
当你的 PHP 应用的 API 没有限流时会发生什么?
后端·php·服务端
BingoGo2 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php
JaguarJack2 天前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php·服务端
JaguarJack3 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端
BingoGo3 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
php
JaguarJack4 天前
告别 Laravel 缓慢的 Blade!Livewire Blaze 来了,为你的 Laravel 性能提速
后端·php·laravel
郑州光合科技余经理5 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php
QQ5110082855 天前
python+springboot+django/flask的校园资料分享系统
spring boot·python·django·flask·node.js·php
WeiXin_DZbishe5 天前
基于django在线音乐数据采集的设计与实现-计算机毕设 附源码 22647
javascript·spring boot·mysql·django·node.js·php·html5