前言
never返回类型是PHP 8.1引入的,标题里的版号是准确的。它是一个"永远不会有返回值"的函数签名:函数体要么抛异常,要么调用exit/die结束进程,要么进入永不结束的循环,总之不能正常返回给调用方。如果它真的返回了,PHP 会在运行时抛TypeError。
它存在的意义不在于运行时能做什么新事情——抛异常这件事在 PHP 4 就能做。never的价值在类型系统:它让"这个函数保证不返回"成为一个可以被引擎和静态分析工具(PHPStan、Psalm)验证的契约。类库里写了public static function abort(int $code): never之后,调用方就知道后面那行代码是不可达的;而如果哪天有人在abort()里加了return;,不用等到线上,静态分析和测试就能把问题揪出来。
本文讲清楚三件事:never的定义规则与运行时行为、它和void的本质区别、以及在异常处理、重定向、断言这几类场景里的正确用法。示例基于 PHP 8.1,全部可直接运行。
一、never的定义规则:只有三个"不能"
never是一种类型,但它的可用位置被严格限制:
| 用法 | 是否允许 | 说明 |
|---|---|---|
函数/方法返回值function f(): never | 允许 | 唯一合法位置 |
参数类型function f(never $x) | 不允许 | 编译期报错 |
属性类型public never $x; | 不允许 | 编译期报错 |
可空?never | 不允许 | never不能为空 |
| 联合类型 `never\ | string` | 不允许 |
构造函数__construct(): never | 不允许 | 构造函数不允许声明返回类型 |
箭头函数fn(): never => ... | 语法上可写 | 但很难满足"不返回"的语义,实际很少用 |
一句话总结:never只能孤零零地出现在返回类型的位置上。这是有意为之——把never塞进联合类型或参数位会让类型推导变得不可控,而它作为"底类型"(bottom type)的语义只在返回位置上有意义。
never的核心规则是:只有抛异常、exit/die、或绝对不结束的循环才满足它。三条都必须让函数的控制流不回到调用方。
<?php // 需要 PHP 8.1+ // ✅ 抛异常:最常见的形态 function abort(string $message): never { throw new RuntimeException($message); } // ✅ 结束进程:进程都没了,自然不会返回 function redirect(string $url): never { header('Location: ' . $url, true, 302); exit; } // ✅ 永不结束的循环:控制流不会走出函数 function eventLoop(): never { while (true) { // ... 处理任务 ... usleep(1000); } }二、never与void:差的不是"有没有返回值",而是"控制流回不回来"
这两个类型很容易被当成同一件事,但语义完全不同,用错了会让代码看起来对、行为却不符合预期。
| 对比项 | void | never |
|---|---|---|
| 控制流是否回到调用方 | 会,函数正常结束 | 不会,必须抛异常/退出/死循环 |
| 函数体末尾能否自然结束 | 可以 | 不可以(会抛TypeError) |
能否写return; | 可以(只能空 return) | 编译期致命错误 |
能否写return $value; | 不可以 | 不可以 |
| 调用后代码是否可达 | 可达 | 不可达(静态分析器据此判断) |
| 典型用法 | 副作用的工具函数 | 异常辅助函数、exit包装、守护进程循环 |
用一段可运行的代码直观感受差别:
<?php // 需要 PHP 8.1+ function withVoid(): void { echo "withVoid 正常结束\n"; // 隐式 return; 合法 } function withNever(): never { echo "withNever 准备抛出\n"; throw new LogicException('我不会返回'); } withVoid(); // 输出后继续往下走 echo "withVoid 之后仍然可达\n"; try { withNever(); } catch (LogicException $e) { echo '捕获到: ', $e->getMessage(), PHP_EOL; }输出:
withVoid 正常结束 withVoid 之后仍然可达 withNever 准备抛出 捕获到: 我不会返回再看违反契约时会发生什么——这是排查时最容易懵的一种报错:
<?php // 需要 PHP 8.1+ declare(strict_types=1); function broken(): never { // 只是走到了函数末尾,没有任何 return,也没有抛异常 echo "我本不该走到这里\n"; } try { broken(); } catch (TypeError $e) { // PHP 在函数"隐式返回"的那一刻发现契约被违反 echo get_class($e), ': ', $e->getMessage(), PHP_EOL; // TypeError: broken(): never-returning function must not return }注意报错的时机:函数体全部执行完了,才抛TypeError。所以如果你在函数中间有echo、写日志、发请求等副作用,它们都已经真实发生了,异常才姗姗来迟。这一点在排查"为什么日志打印了但请求失败了"时非常关键。
另有一类问题是编译期就会被拦下的:
<?php function bad(): never { if (rand(0, 1) === 1) { return; // ❌ 致命错误:never 函数不允许 return } exit; }这种写法在加载文件阶段就会报 "A never-returning function must not return" 之类的致命错误,脚本根本跑不到rand()那一步。所以"写了never又写return;"这类错误不会潜伏到线上。
三、实战:把never用在对的地方
never最实用的三个场景:异常辅助函数、统一响应终止、语义化断言。
<?php // never-demo.php —— 需要 PHP 8.1+ // 用法:php never-demo.php declare(strict_types=1); /** 1) 异常辅助:把"抛出并终止"变成一个可复用的函数 */ function fail(string $message, int $code = 0): never { throw new DomainException($message, $code); } /** 2) 断言:不满足条件即终止,调用方之后的代码可以放心继续写 */ function ensure(bool $condition, string $message = '断言失败'): void { if (!$condition) { fail($message); // 由于 fail() 是 never,这里之后无路可走 } } /** 3) 接口里声明 never 契约:实现方必须保证不返回 */ interface Responder { /** 输出错误响应并终止请求 */ public function error(int $status, string $message): never; } final class JsonResponder implements Responder { public function error(int $status, string $message): never { // 先把已经缓冲的输出丢掉,避免污染 JSON 结构 while (ob_get_level() > 0) { ob_end_clean(); } http_response_code($status); header('Content-Type: application/json; charset=utf-8'); echo json_encode( ['error' => $message, 'code' => $status], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ); exit; } } // —— 使用 —— final class OrderService { public function __construct(private Responder $responder) {} public function findOrFail(array $rows, int $id): array { foreach ($rows as $row) { if ($row['id'] === $id) { return $row; } } // 这个分支后没有 return,函数依然合法:因为 error() 是 never $this->responder->error(404, "订单 {$id} 不存在"); } } // CLI 下模拟一下(不会真的发 HTTP 头,但逻辑一致) $service = new OrderService(new class implements Responder { public function error(int $status, string $message): never { fwrite(STDERR, "ERR {$status}: {$message}\n"); exit($status === 404 ? 3 : 1); } }); ensure(true, '这行不会执行'); $order = $service->findOrFail([['id' => 1, 'amount' => 100]], 1); echo '找到订单: ', json_encode($order), PHP_EOL; // 触发 not-found 分支:error() 是 never,所以这里不会继续执行 $service->findOrFail([['id' => 1]], 999); echo "这行永远不会被打印\n";这段代码里有几个值得注意的设计:
fail()让"抛异常"变成一个有类型保证的动作。用它收窄空值分支或错误分支时,静态分析器知道其后的代码不可达,不会误报"变量可能未定义 / 可能为 null"。ensure()里调用fail()后不需要写return,也不需要写else,因为控制流到此为止。这是never带来的最大可读性收益。- 接口声明
: never是强契约。任何实现类如果把它改成: void,会因为返回类型不兼容而报致命错误——never是底类型,它不能被"放宽"。这让"响应出口必须终止请求"这条团队约定由语言来保证,而不是靠 code review。
四、never与静态分析、协变规则
never在类型系统里是"底类型",也就是所有类型的子类型。这带来两条实用推论:
1. 在实现接口/父类方法时,never是最"窄"的返回类型。如果父类声明了具体的返回类型,子类把它收窄成never在规则上是允许的(因为never是它的子类型);反过来,父类声明never时,子类只能声明never。所以:
<?php interface Guard { public function deny(): never; } final class LogGuard implements Guard { // ❌ 致命错误:声明的返回类型与接口不兼容(void 不是 never 的子类型) // public function deny(): void { } // ✅ 必须也是 never public function deny(): never { throw new RuntimeException('denied'); } }2. 静态分析器会把never之后的代码标记为不可达。PHPStan、Psalm 都认这个签名,所以配合它们使用收益最大:能消掉"变量可能未定义""可能为 null"这类误报,也能揪出"以为会终止其实没有"的逻辑漏洞。
但要清醒地认识到:PHP 引擎只在"函数返回的那一刻"检查契约。静态分析则是离线检查。两者都不能证明"函数一定会抛异常"——比如一个never函数里throw语句写在了if里:
<?php function maybeFail(bool $flag): never { if ($flag) { throw new RuntimeException('boom'); } // 没有 else 分支,控制流会走到函数末尾 —— 运行时抛 TypeError }maybeFail(false)会先执行到函数末尾,然后才抛TypeError。这类 bug 不会在加载时报错,只会在特定分支被触发时才出现。
常见坑点
1. 在never函数里写return;
// ❌ 编译期致命错误,整个文件都加载不了 function stop(): never { return; }// ✅ 要么抛异常,要么 exit function stop(): never { exit(0); }2. 以为never函数可以"偶尔不抛",用条件分支兜着
// ❌ $flag 为 false 时,函数会走到末尾,运行时抛 TypeError,而且副作用已经发生了 function bail(bool $flag): never { if ($flag) { throw new RuntimeException('x'); } error_log('这条日志其实已经写进去了'); }// ✅ 保证所有分支都以"不返回"结束 function bail(bool $flag): never { error_log('先记日志,再无条件终止'); throw new RuntimeException($flag ? 'x' : 'y'); }3. 把never当参数类型或属性类型用
// ❌ 编译期报错:never 只能用于返回类型 function handle(never $x): void {} class A { public never $value; }// ✅ 参数用具体类型或 mixed,never 只放返回位 function handle(Throwable $x): void {}4. 写?never或never|string
// ❌ 两者都是非法类型声明 function a(): ?never { exit; } function b(): never|string { exit; }// ✅ never 必须独立出现 function c(): never { exit; }5. 在try里调用never助手,异常被自己的catch吃掉
// ❌ fail() 抛出的异常被自己捕获,随后函数走到末尾,抛 TypeError,报错信息完全跑偏 function process(): never { try { fail('业务异常'); } catch (Throwable $e) { error_log($e->getMessage()); // 忘了重新抛出,控制流继续走 } }// ✅ 捕获后必须重新抛出或直接终止,保持"绝不返回"的契约 function process(): never { try { fail('业务异常'); } catch (Throwable $e) { error_log($e->getMessage()); throw $e; // 或者 exit(1); } }6.never函数里使用exit后还指望后续清理逻辑执行
// ❌ exit 之后的代码不会执行,finally 块之外的收尾逻辑全被跳过 function shutdown(): never { $this->saveState(); // 如果这句在 exit 之后,永远不会执行 exit(0); }// ✅ 把需要执行的收尾动作放在 exit 之前,或注册 shutdown 函数 function shutdown(): never { $this->saveState(); register_shutdown_function(fn() => $this->cleanup()); exit(0); }7. 给构造函数或__toString()加never
// ❌ 构造函数不允许声明返回类型,语法上就是错的 class Bad { public function __construct(): never {} }// ✅ 用静态工厂 + never,把"构造失败"表达在工厂方法上 final class Config { private function __construct(public readonly array $data) {} /** @param array<string,mixed> $data */ public static function loadOrFail(string $path): self { $raw = @file_get_contents($path); if ($raw === false) { fail("配置文件不可读: {$path}"); // never,此处终止 } return new self(json_decode($raw, true, 512, JSON_THROW_ON_ERROR)); } }8. 误以为never能替代void,把普通函数标成never
// ❌ 这个函数明明会正常结束,标成 never 之后每次调用都抛 TypeError function logIt(string $msg): never { error_log($msg); }// ✅ 会正常返回的工具函数用 void function logIt(string $msg): void { error_log($msg); }总结
| 关注点 | 结论 |
|---|---|
| 引入版本 | PHP 8.1,标题版号正确 |
| 唯一合法位置 | 函数/方法的返回类型 |
| 满足条件的三种写法 | 抛异常、exit/die、永不结束的循环 |
| 违反契约的后果 | 运行时抛TypeError(且副作用已经发生) |
return; | 编译期致命错误,文件直接加载失败 |
?never/ `never\ | string` / 参数位 |
与void的区别 | void会正常返回,never不会 |
| 接口中声明 | 实现方必须也是never,不能被放宽 |
| 最大收益点 | 静态分析可判定后续代码不可达,消除误报 |
结论:never是一个给读代码的人和静态分析工具看的契约,它的运行时价值只有一条——在契约被违反时告诉你。用好它的关键是守住"所有分支都不返回"这条底线:不要在never函数里写return;,不要用if兜住唯一的throw,也不要在try里把异常吞掉。满足这三点,never就能把"异常辅助函数"和"响应终止函数"这两类代码的可读性和可靠性同时提高一档;做不到这三点,它只会把一个本来清晰的LogicException变成一句让人摸不着头脑的TypeError。