PHP 8.5 闭包和一等可调用对象进入常量表达式
PHP 8.5 闭包和一等可调用对象进入常量表达式
当"配置"变成运行时胶水代码
PHP 配置一直有个矛盾:
- 你想要声明式配置:简单的数组、常量值、属性。
- 但你也需要一点逻辑:"验证这个字段"、"选择这个处理器"、"格式化这个值"、"过滤这个列表"。
以前,一旦你需要在"配置类"的地方加逻辑,就会碰壁。PHP 故意把很多结构限制在常量表达式——基本上就是不可变的值。属性参数是最明显的例子:你可以放整数、字符串、标量数组……但不能放闭包。
所以我们用各种变通方案:
- 存字符串如
"App\\Handler::handle",然后用call_user_func调用。 - 在属性里用"迷你语言",比如表达式字符串。
- 用可空回调,在运行时设置默认值。
- 在引导文件里建注册表,而不是直接在该放的地方表达。
PHP 8.5 改变了这个局面:静态闭包和一等可调用对象现在可以出现在常量表达式中,包括:
- 属性参数
- 属性和参数的默认值
- 常量和类常量
这听起来像编译器特性。实际上是个"生活质量"升级:让你把配置放在它配置的代码旁边,不用魔术字符串或运行时初始化 hack。
这篇文章会讲"为什么"、具体规则(有重要限制),然后深入实际模式:路由映射、处理器注册表、策略/格式化器注册表。也会讲哪些场景不适合——因为如果不小心,可调用配置确实能搞出一团乱。
旧痛点:常量太受限,逻辑只能塞进运行时初始化
PHP 8.5 之前,限制不是你不能创建闭包——而是你不能在某些"配置槽"里用它们。
三个常见痛点:
痛点 A:"回调默认值"参数强制运行时初始化
如果你想写一个接受可选回调的函数,并且想要一个合理的默认回调,通常这样做:
function my_filter(array $items, ?Closure $predicate = null): array
{
$predicate ??= static function ($v): bool { return !empty($v); };
$out = [];
foreach ($items as $item) {
if ($predicate($item)) {
$out[] = $item;
}
}
return $out;
}
这能用……但是样板代码,而且不是"声明式"的。
PHP 8.5 的 RFC 明确提到这个用例:允许直接声明默认回调闭包,不用可空参数的变通方案。
痛点 B:属性参数不能包含真正的逻辑
属性是表达规则的自然场所:
- 授权检查
- 验证
- 序列化行为
- 测试用例生成
但属性参数只能是常量表达式,所以人们用字符串或表达式对象。
PHP 8.5 发布公告展示了一个典型的"之前"模式,访问控制属性接受字符串表达式。在 PHP 8.5 中你可以直接传静态闭包。
痛点 C:注册表和路由映射变成运行时引导
任何时候你想要从"键"到"处理器"的映射,你可能在运行时构建它:
$handlers = [
'json' => [JsonFormatter::class, 'format'],
'text' => [TextFormatter::class, 'format'],
];
这能用,但很脆弱:
- IDE 重命名重构不能可靠地跟踪字符串方法名。
- 静态分析更难理解什么是可调用的。
- 你需要运行时代码来组装概念上是静态配置的东西。
PHP 8.5 的常量表达式改进让你可以把这些注册表表达为常量——并且让处理器重构安全。
什么是常量表达式,为什么重要
"常量表达式"是 PHP 内部术语,指在必须不依赖运行时状态就能计算的上下文中允许的表达式——可以理解为"不可变值"。
这些上下文包括:
- 属性参数
- 参数和属性的默认值
- (类)常量
闭包 RFC 总结旧规则为:常量表达式被限制在实际上是"不可变值"的操作,闭包不包括在内——尽管闭包本质上是编译后的代码(操作码),在约束下可以被视为不可变。
为什么这很重要?
因为这些上下文是你想放配置的地方:
- 属性是你的元数据/配置层。
- 默认参数/属性值表达预期行为,不需要样板代码。
- 常量表达"这个映射不会变"。
换句话说:常量表达式是 PHP 引导你走向声明式代码的地方。PHP 8.5 扩展了"声明式"的含义。
常量中的闭包:安全可读的模式(和硬性规则)
PHP 8.5 允许常量表达式中的闭包——但有严格约束:
- 必须是静态的(没有
$this)。 - 不能通过
use(...)捕获外部变量。 - 箭头函数在常量表达式中不支持,因为它们隐式捕获变量。
这些规则是编译时强制的。
这听起来有限制,但实际上这正是这个特性安全的原因:它防止意外把"运行时状态"偷渡进常量。
默认回调参数,不需要可空样板
这是之前过滤器示例的干净 PHP 8.5 版本:
<?php
declare(strict_types=1);
function my_filter(
array $items,
Closure $predicate = static function ($v): bool { return !empty($v); },
): array {
$out = [];
foreach ($items as $item) {
if ($predicate($item)) {
$out[] = $item;
}
}
return $out;
}
这正是闭包 RFC 强调的动机:你可以声明一个真正的默认回调,不需要"可空 + 运行时默认"。
实际上,这也改善了工具支持:
- 参数正确地类型化为
Closure,不是?Closure - 调用者不需要猜测
null是否有特殊含义 - 你去掉了一个分支和一行初始化噪音
包含可调用行为的类常量
你可以在常量或类常量中存储闭包,把它们当作"可调用配置"。
一个简单例子:格式化器注册表。
<?php
declare(strict_types=1);
final class Formatters
{
public const MAP = [
'trim_lower' => static function (string $s): string {
return strtolower(trim($s));
},
'digits_only' => static function (string $s): string {
return preg_replace('/\D+/', '', $s) ?? '';
},
];
}
使用:
$input = " +62 (812) 345-678 ";
$normalized = (Formatters::MAP['digits_only'])($input);
这读起来像配置,但不是"字符串类型"。它是真正的 PHP,编译过的,有类型的,可重构的。
属性默认值:可调用行为作为默认策略
因为常量表达式中的闭包可以用作属性默认值,你可以在属性声明处定义默认策略——同样不需要运行时初始化。
例子:可配置的规范化器。
<?php
declare(strict_types=1);
final class Normalizer
{
public Closure $normalize = static function (string $s): string {
return trim($s);
};
public function run(string $value): string
{
return ($this->normalize)($value);
}
}
重要细节:常量表达式中的闭包必须是静态的,这意味着闭包本身不能用 $this。
这是故意的权衡:常量表达式只求值一次,而 $this 只有在闭包为每个对象实例重新创建时才有意义(这不是常量表达式的行为方式)。
作用域:闭包在正确的上下文中仍然能看到私有成员
尽管闭包必须是静态的(没有 $this),在这些常量上下文中创建的闭包仍然遵循正常的作用域规则。RFC 说明:
- 属性默认值中的闭包可以访问所在类的私有属性/方法/常量
- 属性参数中的闭包可以访问所在类的私有成员
这启用了一个好模式:把复杂逻辑放在私有静态辅助方法中,把闭包作为配置暴露出来。
常量中的一等可调用对象:重构安全的引用,不用字符串
闭包适合"内联逻辑"。但有时候你不想要内联逻辑——你想指向一个现有的函数或静态方法。
这就是一等可调用对象(FCC)的用武之地。
一等可调用对象看起来像:
strrev(...)
MyClass::myMethod(...)
它们产生一个转发到函数/方法的 Closure。
PHP 8.5 现在允许常量表达式中的 FCC 语法,旨在"完善"常量中闭包的特性。
为什么 FCC 比字符串可调用更好
比较这两个:
// 旧方式
public const HANDLERS = [
'reverse' => 'strrev',
'slug' => 'App\\Slugger::slugify',
];
对比:
// PHP 8.5
public const HANDLERS = [
'reverse' => strrev(...),
'slug' => Slugger::slugify(...),
];
第二个版本更好,因为:
- 可重构:重命名和移动更可靠
- 静态分析可以理解它是可调用的
- 你避免了魔术字符串和运行时可调用解析
常量表达式中 FCC 的约束
FCC RFC 添加了一些重要限制(除了正常的 FCC 规则):
- 只支持独立函数和静态方法(
::)。 - 只支持
function_name(...)和ClassName::methodName(...)语法。 - 你不能用表达式构建名称(
($fn)(...))、数组([ClassName::class, 'method'](...)),或依赖__callStatic()魔术方法。
这是好事:它让常量表达式中的 FCC 用法清晰且可分析。
实际用例
用例:路由映射作为常量
传统方式,路由映射是运行时构建的:
$routes = [
'GET /health' => [HealthController::class, 'check'],
'GET /posts' => [PostsController::class, 'index'],
];
这能用,但不是重构安全的。
在 PHP 8.5 中你可以用 FCC 或静态闭包定义路由映射,作为常量:
<?php
declare(strict_types=1);
final class Routes
{
public const MAP = [
'GET /health' => HealthController::check(...),
'GET /posts' => PostsController::index(...),
// 快速端点的内联处理器
'GET /version' => static function (Request $req): Response {
return Response::text('ok');
},
];
}
现在你可以实现一个简单的分发器:
final class Dispatcher
{
public function dispatch(Request $req): Response
{
$key = $req->method . ' ' . $req->path;
$handler = Routes::MAP[$key] ?? null;
if ($handler === null) {
return Response::text('Not found', 404);
}
return $handler($req);
}
}
这个模式有几个好处:
- 路由映射是真正的常量配置。
- 处理器是真正的可调用对象,不是字符串。
- 重构更安全(特别是静态方法处理器)。
实际的路由器需要路径参数;但即使这样,"处理器注册表"部分通常保持静态。
用例:消息总线的处理器注册表
想象一个简单的消息总线:消息类映射到处理器。
旧方式:
$handlers = [
UserRegistered::class => 'App\\Handlers\\SendWelcomeEmail::handle',
];
现在,用 PHP 8.5 FCC:
final class MessageHandlers
{
public const MAP = [
UserRegistered::class => SendWelcomeEmail::handle(...),
OrderPaid::class => CreateInvoice::handle(...),
];
}
分发器:
final class Bus
{
public function __construct(private Container $container) {}
public function handle(object $message): void
{
$handler = MessageHandlers::MAP[$message::class] ?? null;
if ($handler === null) {
throw new RuntimeException('No handler registered for ' . $message::class);
}
// 如果处理器是静态的,它们可以显式接受依赖,
// 或者你可以调整这个模式(见下面的 DI 说明)。
$handler($message, $this->container);
}
}
关键概念:常量表达式让你把映射保持在常量中,但你仍然控制依赖如何注入——通过签名设计。
用例:策略/格式化器注册表(switch 语句的干净替代)
这是我最喜欢的实际用途:替换一个不断增长的 switch。
function format(string $type, mixed $value): string
{
return match ($type) {
'json' => json_encode($value),
'text' => (string) $value,
'upper' => strtoupper((string) $value),
default => throw new InvalidArgumentException('Unknown formatter'),
};
}
现在想象这增长到 15-30 个策略。你最终得到一个大 match 和一个 diff 磁铁。
用可调用常量:
final class FormatterRegistry
{
public const FORMATTERS = [
'json' => static function (mixed $v): string {
return json_encode($v, JSON_THROW_ON_ERROR);
},
'text' => static function (mixed $v): string {
return (string) $v;
},
// FCC 到原生函数
'reverse' => strrev(...),
];
public static function format(string $type, mixed $value): string
{
$fn = self::FORMATTERS[$type] ?? null;
if ($fn === null) {
throw new InvalidArgumentException("Unknown formatter: {$type}");
}
return $fn($value);
}
}
现在添加策略只需要改一行。
测试和依赖注入:什么该放常量,什么该放容器
这个特性引导你走向"代码即配置"。这很好——直到你开始把运行时状态注入到应该是静态的东西里。
一个好的心智模型:
- 常量应该包含稳定的接线:映射、策略、不依赖运行时状态的小逻辑片段。
- DI 容器应该包含运行时组装:需要环境相关接线的对象、IO 资源、凭证、连接等。
好的常量可调用用法:纯粹的转换和策略
这些在常量表达式中是安全的:
- 规范化函数(trim、canonicalize)
- 路由/分发选择逻辑
- 只依赖输入值的验证器
- 格式化器和映射器
DI 的用武之地:当你需要依赖时
你仍然可以通过设计可调用对象显式接受依赖来混合可调用配置和 DI。
例子:注册表返回一个接受 (Message $m, Container $c) 的可调用对象:
final class Handlers
{
public const MAP = [
UserRegistered::class => static function (UserRegistered $m, Container $c): void {
$mailer = $c->get(Mailer::class);
$mailer->sendWelcome($m->email);
},
];
}
这保持在约束内,因为闭包是静态的且不捕获状态。"依赖解析"在调用时发生,容器被传入。
这总是理想的吗?不是。但它是一个干净、显式的桥梁。
测试影响:更好的默认值,更容易覆盖
常量表达式闭包让默认值更干净:
- 函数可以有默认闭包参数(不用可空)。
- 属性可以有默认闭包策略。
对于测试,你仍然可以通过传递不同的闭包参数或给对象属性赋值不同的策略来覆盖行为(如果该属性设计上是可变的)。主要改进是默认行为在它该在的地方表达,你不需要运行时初始化胶水代码来创建默认闭包。
陷阱:捕获状态、副作用和团队可读性
这个特性给你在"配置上下文"中更多能力。能力带来新的搬起石头砸自己脚的方式。
陷阱 A:试图捕获状态(不会编译——这是好事)
你不能这样做:
$prefix = "prod_";
const FN = static function (string $s) use ($prefix): string {
return $prefix . $s;
};
常量表达式中的闭包不能通过 use(...) 捕获变量。
这是硬性约束,它强迫你采用更好的设计:
- 把值作为参数传递
- 使用常量/类常量
- 或者做运行时配置,而不是假装它是常量
类似地,箭头函数在常量表达式中被阻止,因为它们隐式捕获变量。
陷阱 B:在"配置"里隐藏副作用
如果你的"注册表"闭包开始做 IO、访问数据库、读取环境变量等,你就让配置变得不可预测了。
一个好规则:
如果可调用对象做的不只是"计算并返回",考虑把它移到真正的服务中,通过静态方法引用它(或容器接线)。
陷阱 C:在纯数据更清晰的地方用可调用配置
仅仅因为你能在属性里放代码,不意味着你应该这样做。
如果你的规则可以表达为简单数据——用数据。例子:
- 允许的角色
- 数字范围
- 枚举集合
可调用配置应该是你的工具,用于数据本身变得笨拙的情况(或者你否则会发明一个字符串表达式 DSL)。
陷阱 D:团队间的可读性和一致性
可调用配置仍然是代码。如果你的团队经验水平不一,你需要约定:
- 保持常量表达式闭包简短。
- 当闭包超过约 10 行时,倾向于命名逻辑并通过 FCC 引用它(
SomeClass::somePolicy(...))。 - 避免花哨写法(特别是嵌套匿名函数)。
指南:什么时候配置应该保持"数据",什么时候"可调用配置"是合理的
这是一套在实际代码库中通常效果不错的实用指南。
在以下情况倾向于纯数据配置:
- 规则是静态且小的(标志、列表、阈值)
- 你想要容易序列化(比如导出配置)
- 你想让非开发者可以编辑配置(在某些组织中)
例子:
final class Limits
{
public const MAX_TITLE_LENGTH = 120;
public const ALLOWED_SORTS = ['newest', 'popular', 'discussed'];
}
在以下情况使用可调用配置:
- 规则简单但不能很好地映射到数据(比如谓词)
- 使用数据会把你推向自定义 DSL
- 你想通过属性让配置靠近类/方法
- 你想要重构安全的可调用对象而不是字符串
这正是 PHP 8.5 在属性、默认值和常量中启用的。
保持可调用配置安全且可维护:
- 让闭包静态(反正是必须的)。
- 不要试图捕获外部变量(
use(...)不允许)。 - 当逻辑增长时:把它移到命名的静态方法并用 FCC 引用它,PHP 8.5 现在在常量表达式中允许这样做。
- 只在支持的形式中使用 FCC:
function_name(...)和ClassName::methodName(...)(不支持数组可调用语法)。
关于性能和 opcache 的说明
如果你想知道这是否"免费",两个 RFC 都提到 opcache 需要调整才能正确地在共享内存中存储这些闭包/可调用对象。
换句话说:这个特性的实现考虑了真实世界的运行时环境(opcache/JIT)。目标不是微优化——而是表达力和安全性。
小结
PHP 8.5 支持常量表达式中的静态闭包和一等可调用对象,这是那种在更新日志上看起来很小、然后悄悄改善你设计 API 方式的特性:
- 默认回调变得干净且类型正确(不用可空样板)。
- 属性可以携带真正的可执行策略逻辑——不用字符串 DSL。
- 注册表和路由映射可以定义为常量,使用重构安全的可调用对象。
约束就是护栏:不能捕获变量、没有 $this、常量表达式中没有箭头函数。
如果你接受这些护栏,你会得到一个真正更好的"编译时风格"配置层——接线是静态的、可读的、重构更安全。
用它来移除胶水代码,而不是隐藏复杂性。保持可调用配置简短,给重的东西命名,让你的常量描述"发生什么",而不是把它们变成迷你应用程序。

浙公网安备 33010602011771号