有些 bug 会导致异常、致命错误、监控面板一片红。
还有一类 bug 长这样:"一切都跑了,但什么都没发生"。方法调了,副作用也有了,但关键返回值(成功标志、错误列表、新的不可变实例)被扔掉了。粗看代码没毛病,测试没覆盖到边界情况也能过。bug 就这么混进生产环境。
PHP 一直允许这种风格的失误:
doSomethingImportant(); // 返回了一个值……但没人用PHP 8.5 新增了一种原生方式来标记这类情况:#[\NoDiscard]。
给函数或方法加上 #[\NoDiscard],调用方要是不用返回值,PHP 就会发警告。可以把它理解为"编译器级别的提示"(实际由引擎在运行时/编译时执行),不抛异常、不改行为,只是让 API 更安全。
本文讲的是怎么用好 #[\NoDiscard]:
不涉及 PHP 8.5 其他特性。
PHP 代码库里有几个常见的"惯犯"。
典型案例:
$ok = rename($tmpFile, $finalFile);有人重构,赋值没了:
rename($tmpFile, $finalFile);
// 继续跑,当作移动成功了开发环境没事。生产环境碰上权限边界情况,你读的文件压根没移动过。
不是每个返回布尔值的函数都该标 #[\NoDiscard]。但你自己的 API 里,如果返回值有意义,忽略它至少该引起警觉。
批处理是重灾区:99.9% 成功,忽略返回值不会破坏大多数运行。
典型场景:
官方 RFC 就是用这个逻辑来解释 #[\NoDiscard] 的设计动机。
这种情况很微妙,从可变对象迁移到不可变对象时特别常见。
你写了个不可变的"更新"方法:
$user = $user->withEmail($newEmail);后来有人写成:
$user->withEmail($newEmail);
// 以为 $user 变了……其实没变没报错,没异常,状态就是静悄悄地没变。
RFC 明确提到 DateTimeImmutable::set*() 就是典型:"听起来像原地修改,实际返回新实例"。
如果你用 Result 类型(或 Either)来避免异常,忽略返回值基本就是忽略了错误。
不一定马上出问题——但错误处理被推到了"以后再说",而"以后"往往等于"永远不"。
简单说,#[\NoDiscard] 是加在函数和方法上的属性,意思是:
"调用了却不用返回值?多半是 bug。"
最简用法:
#[\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 对"用了返回值"的定义很宽松:返回值只要成为任意表达式的一部分就行。赋值给变量——哪怕是个哑变量——算。类型转换也算。
所以下面这些都算"用了":
$unusedButAssigned = createSession(); // 无警告
(bool) createSession(); // 无警告(但见下面关于 OPcache 的说明)也就是说 #[\NoDiscard] 不保证行为正确,只保证你没把结果直接扔地上。
PHP 8.5 还引入了 (void) 强制转换:
(void) createSession(); // 无警告没有运行时效果,纯粹表明意图:"是的,我故意不用它。"可以用来抑制 #[\NoDiscard] 警告,IDE 和静态分析工具也能识别。
RFC 里有个细节:(void) 是语句不是表达式,不能嵌到其他表达式里,否则语法错误。
RFC 规定这些情况会编译报错:
: void 或 : never 的函数__construct、__clone 等)所以这样写会报错:
#[\NoDiscard]
function logSomething(string $msg): void {
error_log($msg);
}
// Fatal: void 函数不返回值,但 #[\NoDiscard] 要求有返回值这是故意的:没东西可丢弃,这个属性就没意义。
大多数团队把警告当噪音。有些团队把警告转成异常(严格环境里常见)。
RFC 指出,引擎在调用函数之前(参数求值之后)就验证"返回值有没有被用"。如果你配了个会抛异常的错误处理器,警告一触发就抛异常,函数压根不会被调用——RFC 把这叫"fail-closed"行为。
对 #[\NoDiscard] 函数来说,这通常是好事(忽略返回值本来就不安全),但如果函数有重要副作用,你得心里有数。
看看实际怎么用。下面这些模式是 #[\NoDiscard] 真正能发挥价值的地方。
一个最小化的 Result 实现:
<?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:
#[\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);
}这样调用会触发警告:
validateUsername($_POST['username'] ?? '');这就是你想要的效果:用了 Result 模式,忽略它几乎肯定是写错了。
正确的写法变成显式的:
$res = validateUsername($_POST['username'] ?? '');
if ($res->isErr()) {
http_response_code(422);
echo $res->error();
exit;
}
$username = $res->unwrap();开发者还能写 $_ = validateUsername(...) 然后不管它吗?能,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
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,
];
}
}看看典型错误:
$builder = new InvoiceBuilder();
$builder->withLine('Subscription', 1500);
$builder->withLine('Support', 500);
$invoice = $builder->build();没有 #[\NoDiscard],这会产生一张没有行项目的发票,因为返回的构建器被扔掉了。
加上 #[\NoDiscard],每个被忽略的 withLine() 返回都会触发警告,逼你写对:
$builder = (new InvoiceBuilder())
->withLine('Subscription', 1500)
->withLine('Support', 500);
$invoice = $builder->build();这就是 #[\NoDiscard] 要揪出来的 bug:容易犯、测试常能过、生产环境让人头疼。
RFC 给一小部分原生 API 加了 #[\NoDiscard],这些 API 忽略结果容易出隐蔽问题:
flock()(忽略锁定失败,竞争条件下可能数据损坏)DateTimeImmutable::set*()(从可变 DateTime 迁移过来时的常见坑)就算你从不直接用这些函数,这也说明一件事:这个特性针对的是真实场景里的错误,不是为了理论上的"纯粹"。
到处加 #[\NoDiscard] 只会制造噪音,团队迟早习惯性忽略。RFC 的建议是:只在忽略返回值可能是无意的、且会导致测试期间难以发现的 bug 的地方用。
务实的推广思路:
高价值场景:
with*、set*)低价值场景:
str_contains() 这类检查):调用后什么都不做本来就奇怪,忽略返回值很少造成隐蔽问题。RFC 拿 str_contains() 当反面例子引擎发的是警告(不是异常),所以可以逐步收紧:
E_USER_WARNING 当失败(可选,过渡一段时间后)记住:如果你们把警告转成异常,#[\NoDiscard] 会直接阻止函数运行(fail-closed)。有时候这是好事,但这种行为变化得有意识地引入。
#[\NoDiscard] 用来强化团队规则效果最好,不是用来代替思考的。
下面是一套跟代码审查配合良好的简单规则:
看到 #[\NoDiscard] 警告,别急着"消掉它"。先问:
(void) 合适吗?比如:调用一个返回缓存键的方法,但你只要副作用:
(void) $cache->warmUp($userId);这是干净、可读的约定:我故意丢弃这个值。
比下面这种写法强多了:
$unused = $cache->warmUp($userId);因为 $unused 重构后很容易留下来,让后面的人摸不着头脑。
静态分析器和 IDE 本来就会对纯函数的未使用返回值报警。RFC 提到,PHPStorm、PHPStan、Psalm 这些工具已经能抓"纯返回值被忽略"的问题(比如 DateTimeImmutable 的坑),但它们一般没法覆盖非纯函数的重要返回值——#[\NoDiscard] 填的就是这个空白。
所以组合起来是这样:
#[\NoDiscard]:"重要返回值没用"(哪怕是非纯的)做个贴近真实场景的重构:一个"保存"并返回状态的方法。
final class UserRepository
{
public function save(User $user): bool
{
// ... 写入数据库 ...
// 冲突/失败时返回 false
return true;
}
}调用点往往变成:
$repo->save($user);
// 当作保存成功了如果返回值有意义,这就是埋着的雷。
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:
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 也更自文档化。
有些场景确实要忽略:
这时候 (void) 正合适:
(void) $repo->save($user); // "我就是不想检查这个。"代码审查看着清楚,也能防止无意中"静默忽略"又混回来。
#[\NoDiscard] 很强大,但不是通用的"质量徽章"。用多了就是噪音,噪音会淹没信号。
纯查询比如:
str_contains()strlen()调用了却什么都不做,bug 本来就很明显:算了个东西没用,还没副作用。RFC 明确说不要在 str_contains() 这类函数上用 #[\NoDiscard],因为忽略结果本来就不太可能,而且除了浪费点计算没啥坏处。
你会想给很多方法标 #[\NoDiscard],理由是"调用者总是接返回值更干净"。这是风格偏好,不是安全问题。
只在忽略返回值可能是无意的、而且有害的地方用。
有些方法返回值只是顺便给的,主要靠副作用。给它们加 #[\NoDiscard] 会逼调用者到处写 (void),换一种杂乱而已。
如果你发现某个函数有一堆 (void),说明属性可能加错地方了。
因为"用了"的定义很宽松,你可以满足 #[\NoDiscard] 但实际上什么都没处理:
$tmp = $repo->save($user); // 无警告,但语义上还是忽略了这不是特性的缺陷——它提醒我们 #[\NoDiscard] 是护栏,不是完整的正确性证明。
好团队不会只靠属性。他们用命名约定,在警告出现之前就引导正确用法。
下面是跟 #[\NoDiscard] 配合良好的命名约定:
如果你的类是不可变的:
withEmail()withStatus()withTimeout()这些方法基本都该标 #[\NoDiscard],因为忽略返回值通常意味着"啥也没变"。
比如:
tryLock() : booltryParse() : ResulttryConnect() : Result方法名以 try 开头,调用者一般会期望检查结果。标 #[\NoDiscard] 强化这个预期。
build() 产生你要的东西,调用了却不用,基本就是写错了。
这里很适合加 #[\NoDiscard]。
好的消息是你希望队友在 CI 日志里看到的:
RFC 支持可选消息,会出现在警告文本里。
支持 #[\NoDiscard] 的最强理由不是理论,是可维护性。
忽略返回值是真实 PHP 代码里反复出现的失败模式——尤其是:
PHP 8.5 给了一种原生手段来尽早抓住这些错误,用警告加显式 (void) 来保持故意丢弃时的可读性。
精准地用它:
with*、try*),让代码在引擎报警之前就能读出正确用法做到这些,#[\NoDiscard] 就会成为那种安静地减少生产事故的小特性——不用逼整个团队换编程模型。