编程 PHP 8.6 的 `?` 占位符:partial 应用写法、`(...)` 的元数差异与 array_map 编进 foreach

2026-10-31 20:03:24

PHP 8.6 的 ? 占位符:partial 应用写法、(...) 的元数差异与 array_map 编进 foreach

PHP 8.6 引入的部分函数应用(partial function application),用 ? 替掉了 8.5 里到处手写的 fn (...) => ...。同一段数组处理,两版写法放一起看:

// PHP 8.5
$clean = array_map(fn (string $t): string => strtolower(trim($t)), $titles);
$failures = array_filter($lines, fn (string $l): bool => str_contains($l, 'ERROR'));
usort($names, fn (string $a, string $b): int => strcmp($a, $b));

// PHP 8.6
$clean = array_map(strtolower(?), array_map(trim(?), $titles));
$failures = array_filter($lines, str_contains(?, 'ERROR'));
usort($names, strcmp(?, ?));

? 就是一个稍后再绑的实参。箭头和参数上的类型标注都没了——类型信息只剩在被调函数自己的签名里。

管道里怎么混用 ? 和 (...)

src/Text/Slugger.php 把处理步骤存成闭包数组:

$this->steps = [
    trim(...),
    strtolower(...),
    str_replace('_', '-', ?),
    self::normalize(...),
    trim(?, '-'),
];

第四步特意包了一层,而不是直接写 preg_replace('/[^a-z0-9-]+/', '-', ?)。原因是 preg_replace() 在 PCRE 出错时返回 null,而这条管道的元素类型是 Closure(string): string,null 会在下一步才炸成 TypeError。管道的类型强度只等于最弱的一环。

f(?) 与 f(...) 的元数区别

first-class callable 语法从 8.1 就有,f(...) 的含义是「这个函数,原封不动当成闭包」。8.6 新增的 ? 表示「这个位置的参数留到以后再绑」,末尾的 ... 则表示「凡是没点名的参数全都接住」。差别在元数:

$one = trim(?);
$all = trim(...);

$one('--x--', '-'); // '--x--' —— 第二个参数永远到不了 trim()
$all('--x--', '-'); // 'x' —— 它到了

array_map(trim(?), [' a '], ['-']); // ['a'] —— 每个元素只取一个参数,来自第一个数组

? 的元数比原函数少(多给的参数被丢掉),... 的元数与原函数一致(全部透传)。这条规则直接决定多数组场景怎么选:array_map() 传多个数组时,回调按位置收到每个数组的对应元素,trim(?) 只能拿到第一个数组的值;要让 array_map() 的多数组回调、array_filter() 配 ARRAY_FILTER_USE_BOTH、需要同时拿到键和值的回调、以及接收两个参数的比较器正常工作,得用 f(...) 而不是 f(?)。

占位符的填充规则

占位符按位置填充;一个表达式里有多个时按书写顺序依次填;占位符可以出现在具名实参之后;实例方法与静态方法的写法一致:

$replace = str_replace(?, ?, 'aaa'); // $replace('a', 'b') === 'bbb'
$padded = str_pad(string: ?, length: 6);
$formats = array_map($clock->format(?), ['H:i', 'Y-m-d']);
$parse = DateTimeImmutable::createFromFormat(?, '2026-01-02');
$mapped = array_map(?, ...);

最容易踩的坑:嵌套不是组合

占位符只在它出现的位置绑定,所以把两个 partial 叠起来并不构成函数复合:

$f = strtolower(trim(?));
// TypeError: strtolower(): Argument #1 ($string) must be of type string, Closure given

内层 trim(?) 先求值成一个 Closure,这个 Closure 接着被当成 strtolower() 的第一个参数,类型检查当场失败。

两个编译期错误

new ArrayObject(?, 0); // Cannot create Closure for new expression
array_map(..., ?);     // Variadic placeholder must be last

new 表达式不能做成 partial;变参占位符必须放在最后。

partial 是 Closure,不是函数引用

partial 求值结果是一个闭包,不是对被包装函数的引用。凡是需要可调用名字的地方——字符串回调、对原函数做 ReflectionFunction——仍然必须传原函数本身。

性能

没有捕获变量的 partial 表达式可以直接命中闭包缓存,不必每次重建。循环里创建闭包的开销实测:无 OPcache 为 54.3 对 26.2 ns/op,有 OPcache 为 50.4 对 24.5 ns/op;捕获了变量的闭包则没有变化。也就是说构建一个 partial 仍大约是普通闭包的两倍——省的是代码噪音,不是运行时间。

发布说明里另有一条性能项:array_map() 的回调如果是 first-class callable 或 partial,会被编译成等价的 foreach 循环,省掉中间 Closure 和用户态回调的开销。

这条快速路径在 RC3 修过一次回归。8.6 会把部分 array_map() 调用编译成另一套代码,新路径对回调的实参做了类型检查,而 array_map() 以前从不做这件事:

declare(strict_types=1);

array_map(trim(...), [1]);
// 8.5: 正常跑完
// 8.6 RC2: TypeError: trim(): Argument #1 ($string) must be of type string, int given

Tim Düsterhus 把 strict_types=1 文件的快速路径关掉了;Arnaud Le Blanc 另外修了函数或参数不是字面量的调用。

信源

  • 原文:https://wolinski.com/blog/php-8-6-new-features.html
  • 配套仓库:https://github.com/michalwolinski/php-8-6-new-features
  • RC3 说明:https://phpnews.net/php-8-6-rc3
复制全文 生成海报 PHP 8.6 函数式编程 性能

推荐文章

程序员茄子在线接单