PHP 8.5のパイプ演算子(|>)は何が嬉しい? 実務でハマる4つのポイント
パイプ演算子(|>)とは、左辺の値を右辺のcallableに第1引数として渡し、その戻り値を返すPHP 8.5の演算子です。関数の入れ子を左から右への流れに書き直せます。
strtoupper(trim(str_replace(...)))のような、内側から読まないといけないコードに疲れたことがある人は多いと思います。一時変数を並べて逃げるのも、それはそれで読みにくいですね。
パイプ演算子はそこを素直に解決してくれる機能ですが、書き始めるとわりとすぐに壁に当たります。今日は、実際に手を動かしてつまずいたところを中心に書きます。
基本の書き方はどうなる?
右辺には「引数を1つ受け取れるcallable」なら何でも置けます。いちばん素直なのは、PHP 8.1からある第一級callable記法(関数名(...))と組み合わせる書き方です。
<?php
declare(strict_types=1);
$slug = " Hello PHP World "
|> trim(...)
|> strtolower(...)
|> (fn(string $s) => str_replace(' ', '-', $s));
var_dump($slug); // string(15) "hello-php-world"
上から順に読めば処理の順番になっている、というのがいちばんの嬉しさです。差分レビューでも、1行足す・1行消すだけで工程を増減できるので、変更の意図が見えやすくなります。
ちなみにtrim(...)のような「関数名(…)」の形は、コンパイル時にtrim($値)という普通の関数呼び出しに置き換えられます。php-srcのコンパイラを読むとそうなっていて、クロージャを毎回作っているわけではありません。一方でアロー関数を置いた段は、当然クロージャが生成されます。
アロー関数は括弧で囲まないとコンパイルエラー
最初に踏むのがこれです。上の例の最後の段、アロー関数を丸括弧で囲っていますが、これは好みではなく必須です。
<?php
// NG: コンパイルエラー
$r = "abc" |> fn($s) => strtoupper($s) |> strlen(...);
// Fatal error: Arrow functions on the right hand side of |> must be parenthesized
// OK
$r = "abc" |> (fn($s) => strtoupper($s)) |> strlen(...);
var_dump($r); // int(3)
括弧がないと、アロー関数の本体が後ろの|> strlen(...)まで飲み込んでしまい、意図と違う解釈になります。RFCが可決されたあとにこの問題が見つかり、曖昧さを残すよりはエラーにする、という判断になったようです。
エラーメッセージがはっきりしているので、一度見れば覚えます。ただ、フォーマッタやエディタのスニペットが括弧を外してしまう設定になっていないかは、一度確認しておくと安心です。
引数が2つ以上の関数はそのまま渡せない
パイプが渡すのは第1引数だけです。必須引数が2つある関数を置くと、実行時にArgumentCountErrorになります。任意引数(デフォルト値あり)ならデフォルトが使われるので通ります。
実務で困るのは配列関数です。array_mapはコールバックが第1引数、array_filterは配列が第1引数でもコールバックが要る、と、どれもそのままではパイプに乗りません。結局アロー関数で包むことになります。
<?php
declare(strict_types=1);
$users = [
['name' => 'Sato', 'active' => true],
['name' => 'Suzuki', 'active' => false],
['name' => 'Tanaka', 'active' => true],
];
$names = $users
|> (fn(array $xs) => array_filter($xs, fn(array $u) => $u['active']))
|> (fn(array $xs) => array_column($xs, 'name'))
|> (fn(array $xs) => implode(', ', $xs));
var_dump($names); // string(12) "Sato, Tanaka"
正直、ここまで包むと「これ一時変数で良くない?」という気持ちにもなります。個人的には、アロー関数だらけになる箇所は無理にパイプにせず、1引数で済む変換が続く箇所に絞って使うのがちょうどいいと思っています。
参照渡しの関数は使えない
sort()のように引数を参照で受け取って書き換える関数は、パイプに置くとErrorが投げられます。パイプは値を渡すだけで、左辺の変数を書き換える経路を持っていないからですね。
<?php
try {
$sorted = [3, 1, 2] |> sort(...);
} catch (\Error $e) {
echo $e->getMessage();
// sort(): Argument #1 ($array) could not be passed by reference
}
// 新しい配列を返す関数で包めば使える
function sorted(array $xs): array
{
sort($xs);
return $xs;
}
var_dump([3, 1, 2] |> sorted(...)); // [1, 2, 3]
sort系、array_walk、shuffleあたりは全部これに当たります。チーム内で「値を返す版」の小さなヘルパーを用意しておくと、パイプとの相性が一気に良くなります。
優先順位は「算術・連結より低く、比較より高い」
地味に事故りやすいのが演算子の優先順位です。|>は+や.より弱く、==や??、三項演算子より強い位置にいます。
<?php
$a = 'foo';
$b = 'bar';
// 連結が先に評価され、結果全体がstrtoupperに渡る
var_dump($a . $b |> strtoupper(...)); // string(6) "FOOBAR"
// 足し算が先
var_dump(5 + 2 |> (fn(int $x) => $x * 10)); // int(70)
// パイプが先に評価され、その結果が比較される
var_dump('abc' |> strlen(...) === 3); // bool(true)
1つ目は「$bだけ大文字にしたかった」つもりで書くと見事に外れます。結果として動いてしまうぶん、気づきにくいのがいやらしいところです。迷ったら括弧を付ける、で十分だと思います。
途中の値を覗きたいときはどうする?
パイプで書くと一時変数がないので、途中経過をvar_dumpしにくくなります。そういうときは、値をそのまま返す「tap」的な関数を1段はさむと便利です。
<?php
function tap(callable $fn): \Closure
{
return function (mixed $v) use ($fn): mixed {
$fn($v);
return $v;
};
}
$r = " Hi "
|> trim(...)
|> tap(var_dump(...)) // string(2) "Hi"
|> strtoupper(...);
var_dump($r); // string(2) "HI"
ログ出力に差し替えれば、そのまま本番のトレースにも使えます。逆に、戻り値がvoidの関数をうっかり途中に置くと、次の段にはnullが渡ります。strict_typesなら型エラーで止まりますが、型を付けていない関数だと静かにnullが流れていくので注意が必要です。
まとめ
パイプ演算子は、1引数の変換を順番に重ねていく処理を、読む順番どおりに書けるようにしてくれる機能です。第一級callable記法と組み合わせるぶんには、コンパイル時に普通の関数呼び出しになるので、コストもほぼ気にしなくていいはずです。
一方で、多引数関数・参照渡し・アロー関数の括弧・優先順位と、ハマりどころははっきりしています。全部をパイプにしようとせず、気持ちよく書ける箇所だけで使うくらいがちょうどいい、という気がしています。
よくある質問
Q. パイプ演算子はどのバージョンから使えますか?
A. PHP 8.5からです。8.4以前では構文エラーになるので、ライブラリで使う場合はcomposer.jsonのPHP要件も合わせて上げておく必要があります。
Q. 右辺にはどんなものを書けますか?
A. 呼び出し可能なものなら何でも書けます。strlen(...)のような第一級callable、'strlen'のような文字列、[$obj, 'method']の配列、クロージャ、__invokeを持つオブジェクトなどです。
Q. メソッドチェーンと比べてどちらを使うべきですか?
A. 自分で設計するクラスならメソッドチェーンで十分なことが多いです。パイプは、組み込み関数や既存の関数をつなぎたいときに向いています。