PHP 8.5 #[\NoDiscard] 上线前揪出"忽略返回值"的 Bug

没人愿意承认的 bug

有些 bug 会导致异常、致命错误、监控面板一片红。

还有一类 bug 长这样:"一切都跑了,但什么都没发生"。方法调了,副作用也有了,但关键返回值(成功标志、错误列表、新的不可变实例)被扔掉了。粗看代码没毛病,测试没覆盖到边界情况也能过。bug 就这么混进生产环境。

PHP 一直允许这种风格的失误:

php
doSomethingImportant(); // 返回了一个值……但没人用

PHP 8.5 新增了一种原生方式来标记这类情况:#[\NoDiscard]

给函数或方法加上 #[\NoDiscard],调用方要是不用返回值,PHP 就会发警告。可以把它理解为"编译器级别的提示"(实际由引擎在运行时/编译时执行),不抛异常、不改行为,只是让 API 更安全。

本文讲的是怎么用好 #[\NoDiscard]

  • 它能防哪些 bug
  • 具体语义(什么算"用了"返回值)
  • 高价值模式:Result / Either、不可变构建器
  • 怎么推广才不会让团队反感
  • 什么时候别用(误报确实存在)

不涉及 PHP 8.5 其他特性。

常见"惯犯":返回值被忽略的几种场景

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) 强制转换细节、约束、推荐用法)
本作品采用《CC 协议》,转载必须注明作者和本文链接