PHP 8.1的never返回类型函数怎么定义和使用
前言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差的不是有没有返回值而是控制流回不回来这两个类型很容易被当成同一件事但语义完全不同用错了会让代码看起来对、行为却不符合预期。对比项voidnever控制流是否回到调用方会函数正常结束不会必须抛异常/退出/死循环函数体末尾能否自然结束可以不可以会抛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_types1); 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_types1); /** 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; charsetutf-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; }// ✅ 参数用具体类型或 mixednever 只放返回位 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 arraystring,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。