先看一段很典型的 PHP 代码:
$result = array_slice(
array_values(
array_filter(
array_map(
fn($line) => trim(strtolower($line)),
explode("n", $raw)
),
fn($email) => $email !== ''
)
),
0,
100
);
这段代码干的事情很简单:把一段文本按换行切成数组、逐行去空格转小写、过滤空行、重建索引、截取前 100 条。但在读的时候,你的眼睛得从最内层的 explode 开始,一层一层往外跳。真正的执行顺序是从里往外,而代码的阅读顺序是从外往里。这中间的错位,就是这类代码看起来累的根本原因。
PHP 8.5 引入了管道操作符(Pipe Operator)。它把上面的写法变成这样:
$result = explode("n", $raw)
|> array_map(trim(...), $$)
|> array_map(strtolower(...), $$)
|> array_filter($$, fn($email) => $email !== '')
|> array_values(...)
|> array_slice($$, 0, 100);
从上往下读就是执行顺序,中间经过几步一眼能数清楚。这篇文章把语法和实战都过一遍。
版本这块
管道操作符是 PHP 8.5 的新语法,8.4 及以下写出来是解析错误。
php -v
# PHP 8.5.x (cli)
本地还是老版本的话,用 Docker 起一个:
docker run --rm -v "$PWD":/app -w /app php:8.5-cli php demo.php
不需要扩展,纯语法层面的东西。
最简形式
先看一个不涉及数组的例子:
$result = "hello world" |> strtoupper(...);
echo $result; // HELLO WORLD
右边是 strtoupper(...)。... 是占位符语法,意思是”左边那个值插到这里”。所以这一行等价于:
$result = strtoupper("hello world");
可以连起来:
$result = " Hello World "
|> trim(...)
|> strtolower(...)
|> ucwords(...);
echo $result; // Hello World
从上往下三步走完,比 ucwords(strtolower(trim(" Hello World "))) 好读得多。
占位符的几个细节
不写 … 也能用
如果被调用的函数只有一个参数,... 可以省掉。左边那个值会自动作为第一个参数传入:
"hello" |> strtoupper()
// 等价于 strtoupper("hello")
但一旦右边那个函数需要多个参数,或者值该传的位置不是第一个,就必须写 ... 来指明位置:
$needle = "world";
$result = "hello world" |> str_contains($$, $needle);
// 等价于 str_contains("hello world", "world")
$result2 = "hello world" |> str_contains("hello", $$);
// 等价于 str_contains("hello", "hello world") —— 值插到第二个位置
这个顺序是可以自己定的。这是个挺妙的特性,因为 PHP 的很多内置函数参数顺序并不统一。array_filter 的第一个参数是数组、in_array 的第二个参数才是数组,以前管道这类函数根本用不了。
占位符出现多次?
常见的一个疑问是:如果我想把同一个值用在两个地方怎么办。答案是当前规范只允许 ... 出现一次。
遇到这种需求,得先把它包起来:
// 想比较 $value 和它自己的长度?不写成 $$ 出现两次
// 用闭包先接住
$value = "hello";
$result = $value |> (fn($v) => $v . "/" . strlen($v));
// hello/5
看起来有点绕。好在实际业务里这种场景不多,常见的都是”值当作数组传给 array_* 系列”或者”值当作待处理字符串传进去”,一次占位就够了。
右边必须是个调用
管道右边不能是任意表达式。下面这些都不成立:
$x |> $y + 1; // 语法错误
$x |> $obj->property; // 语法错误
$x |> (fn($v) => $v * 2); // 语法错误(箭头函数本身不是 callable 调用)
$x |> $callable; // 语法错误(不能直接管道到变量)
想要管道到变量里存的 callable,得写成调用形式:
$callable = fn($v) => $v * 2;
$x |> $callable(...); // 这样才成立
也就是说,右边必须是”调用表达式”,函数名、静态方法、实例方法都行,只要带括号:
$text |> trim(...)
$text |> MyHelper::normalize(...)
$text |> $formatter->format(...)
$text |> (fn($v) => $v . "!") // 这个不行,要写成下面的形式
$text |> ((fn($v) => $v . "!")(...)) // 这样才行,但是几乎没人这么写
最后那种写法不实用。真要一步做点复杂变换,就老老实实写一个命名函数或者静态方法,管道到它。
案例:一份邮件列表导入脚本
场景是运营给了一份从各种渠道抓来的联系人文本,格式很不干净:有些行有空格、有些行大小写混着、有些行是空行、有些行重复、有些行是明显不对的邮箱格式。要清洗成规整的邮箱数组,然后存库。
原始输入大概是这样:
$raw = <<<TEXT
alice@example.com
Bob@Example.com
CAROL@example.com
alice@example.com
dave@example.com
not-an-email
bob@example.com
TEXT;
期望输出:去重、去空、全部小写、过滤非邮箱,按字母排序。
先用中间变量写一遍
不急着上管道,先看看中间变量版本长什么样,作为对照。
function importEmails(string $raw, int $limit = 500): array
{
$lines = explode("n", $raw);
$trimmed = array_map(trim(...), $lines);
$noEmpty = array_filter($trimmed, fn($s) => $s !== '');
$lowercased = array_map(strtolower(...), $noEmpty);
$valid = array_filter(
$lowercased,
fn($email) => filter_var($email, FILTER_VALIDATE_EMAIL) !== false
);
$unique = array_unique($valid);
$sorted = array_values($unique);
sort($sorted);
return array_slice($sorted, 0, $limit);
}
很清楚。每一步做了什么一目了然,调试的时候在任意一步 var_dump 都方便。
问题在于变量名。$trimmed、$noEmpty、$lowercased、$valid、$unique——五个临时变量,在函数作用域里活到函数结束。里面每一个都是完整的数组,处理十万行数据的时候内存会翻几倍。而且中间几个名字起得都不太好——$trimmed 其实已经去空行了,$noEmpty 其实已经转小写了,命名和实际内容错位。
用管道重写
function importEmails(string $raw, int $limit = 500): array
{
return explode("n", $raw)
|> array_map(trim(...), $$)
|> array_filter($$, fn($s) => $s !== '')
|> array_map(strtolower(...), $$)
|> array_filter($$, fn($e) =>
filter_var($e, FILTER_VALIDATE_EMAIL) !== false
)
|> array_unique(...)
|> array_values(...)
|> (fn($arr) => sort($arr) ?? $arr)
|> array_slice($$, 0, $limit);
}
逐个说一下每行的用意。
explode("n", $raw) 是入口。它不参与管道,是整条链的起点。
|> array_map(trim(...), $$)——这里是第一处需要注意的写法。管道进去的是数组本身,要传给 array_map 的第二个参数,所以写成 array_map(trim(...), $$)。$$ 的意思是”管道流进来的值插在这个位置”。trim(...) 是个 first-class callable 语法,等价于 'trim' 或者 fn($x) => trim($x)。
同理 array_filter($$, fn($s) => $s !== '') 也是把数组插到第一个位置。
array_unique(...) 后面没有 $$,但是被 ... 标了位置。这里的 ... 就是占位符,表示”把管道进来的值当作 array_unique 的唯一参数”。因为 array_unique 只接收一个参数,省略写法也行,但这样写更显式一点。
那一行箭头函数 |> (fn($arr) => sort($arr) ?? $arr) 是整段里最丑的一行。sort() 是就地排序、返回 bool,不能直接放进管道(管道的右边必须是调用)。所以包了一个箭头函数把 sort 的结果丢掉、把数组返回出去。
如果觉得别扭,可以写成一个辅助函数:
function sorted(array $items): array
{
sort($items);
return $items;
}
// 管道里改成
// |> sorted(...)
这一行看着舒服多了。管道的一个隐含好处,其实是逼着人整理出这类”一步一个变换”的小函数,代码库整体会变得更好组织。
跑一下看看
$raw = <<<TEXT
alice@example.com
Bob@Example.com
CAROL@example.com
alice@example.com
dave@example.com
not-an-email
bob@example.com
TEXT;
var_dump(importEmails($raw));
输出:
array(4) {
[0]=> string(17) "alice@example.com"
[1]=> string(15) "bob@example.com"
[2]=> string(17) "carol@example.com"
[3]=> string(16) "dave@example.com"
}
该做的都做了:去空格、去空行、转小写、过滤非法、去重、排序。原来需要的五个中间变量全没了。
和 array_* 系列组合的几种写法
用管道写数组处理的时候,有几组写法会反复出现,先记下来后面就顺手了。
把一个值送进数组函数
// 想对数组本身做操作,用 $$ 标位置
$numbers |> array_sum(...)
$numbers |> array_reverse(...)
$numbers |> array_unique(...)
// 想映射每一项
$numbers |> array_map(fn($n) => $n * 2, $$)
// 想过滤每一项
$numbers |> array_filter($$, fn($n) => $n > 0)
分清这两种:array_sum(...) 是”对数组整体求和”,array_map(fn, ...) 是”对每一项做处理”。前者直接把数组当作参数,后者把数组插到第二个参数的位置。
和 usort 类函数配合
$records = $records
|> (fn($arr) => usort($arr, fn($a, $b) => $a['age'] <=> $b['age']) ? $arr : $arr);
这行还是丑。老老实实包一个辅助函数:
function sortedBy(array $items, callable $key): array
{
usort($items, fn($a, $b) => $key($a) <=> $key($b));
return $items;
}
// 使用
$records |> sortedBy($$, fn($r) => $r['age'])
和 json_encode 配合
$payload = ['status' => 'ok', 'items' => $items];
$json = $payload
|> (fn($v) => json_encode($v, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR))
|> trim(...);
// 也丑
json_encode 有多个参数,而且第一个参数正好是数据本身,所以直接写就行:
$json = $payload |> json_encode($$, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
清清爽爽。
管道不是万能胶
有一些场景用管道反而更难看,这里说几个。
简单的一次转换
// 没必要
$name = $input |> trim(...);
// 直接写更清楚
$name = trim($input);
管道至少要三步以上才体现价值。一步到两步,用普通函数调用更直白。
每一步都需要检查中间结果
$data = loadRaw($file)
|> parseJson(...)
|> extractItems(...);
如果 parseJson 有可能抛异常或返回 null,而你又想在 extractItems 之前检查一下,管道里是做不到的。这时候要么在每个函数内部处理,要么放弃管道,用中间变量。
我的一般做法是:能被管道串起来的每一步都是”确定成功”的纯函数,任何可能失败的步骤都封装到函数内部、抛出异常。这样管道链看上去干净,出错信息也不会太乱。
和调试器配合
用 xdebug 单步调试的时候,管道链上的每一步不会形成独立的语句,设置断点比中间变量版本要费点劲。开发阶段用中间变量,稳定之后再改成管道——这种切换其实很方便,因为管道链上的每一行就对应一条中间变量赋值。
性能上没什么差别
管道在编译阶段就被展开成普通的函数嵌套调用,没有额外的对象创建、没有反射、没有动态派发。用 opcache 缓存之后和手写嵌套调用的字节码完全一致。
所以不用为性能做取舍,纯粹看哪个写法更好读。
顺手提几个静态分析的注意点
PHPStan 和 Psalm 对管道的支持是逐步到位的。8.5 刚出那阵子,用旧版本的静态分析跑会看到类型丢失的警告——比如管道里的 $$ 被推断成 mixed,链尾返回类型被推成 mixed 或 object。
用之前把工具升到最新,这个坑基本就没了。
还有一个细节:array_filter 这类回调函数,在管道里写的时候参数类型推断往往不如中间变量版本准。因为中间变量会携带它的类型信息,管道要靠工具从上一个函数的返回值去推。如果发现推断出的类型不对,可以在回调参数上显式写类型:
|> array_filter($$, fn(string $e): bool => filter_var($e, FILTER_VALIDATE_EMAIL) !== false)
多写几个字母,换来的是后面少调试半小时。
最后一点想法
管道操作符刚出的时候,我第一反应是:”这是不是把函数式编程里那些年被人骂过的写法带进 PHP 了。”
用了两周之后,我发现它真正解决的问题不是”看起来像函数式”,而是”一次变换一行”的格式清晰度。以前处理一份数据的多个步骤,要么靠中间变量堆叠(变量名起得敷衍就会开始互相干扰),要么靠嵌套调用(从里往外读)。管道提供了第三条路,每一行的处理都独立成行,从上往下就是执行顺序。
真要说限制,是有一些——右边必须是个调用、占位符只能出现一次、不能直接 pipe 到变量里的 callable。但这些都是合理的约束,把”管道”这个概念限制在了可静态分析的范围内。
如果你手上正好有一段三层以上的函数嵌套,特别是 array_* 系列套了好几层的,可以拿它练个手。改完前后对比一下,你会发现很多临时变量名其实一直都是可以省掉的。

