PHP8.5でパイプライン演算子が導入されました。
さらにPHP8.6では部分適用が導入される予定です。
となれば次はもちろん関数合成です。
関数合成ってなに?
Closureを足し算できます。
// 普通の関数
$cb = function($x){
$x = strtolower($x);
$x = str_rot13($x);
return fn($x) => "<$x>";
};
$ret = $cb('Hello World'); // <uryyb jbeyq>
// パイプライン
$cb = fn($x) => $x |> strtolower(...) |> str_rot13(...) |> fn($x) => "<$x>";
$ret = $cb('Hello world'); // <uryyb jbeyq>
// 関数合成
$cb = strtolower(...) + str_rot13(...) + fn($x) => "<$x>"
$ret = $cb('Hello world'); // <uryyb jbeyq>
パイプラインとおんなじじゃね?
もちろんわざわざ導入が検討されるということは、パイプラインとは異なる用途・目的があるということです。
ということで以下は該当のRFC、Function composition operatorsの紹介です。
PHP RFC: Function composition operators
Introduction
オブジェクト指向におけるcompositionは、一般的に「あるオブジェクトが他のオブジェクトへの参照を持つ」を表します。
関数型におけるcompositionは、一般的に「複数の関数をまとめてひとつにする」を表します。
いずれも有効で有用な機能であり、PHPのようなマルチパラダイム言語にとっては特にそうです。
しかし現在、PHPでは後者がサポートされていません。
このRFCでは、closureに対して新たな関数合成演算子を導入することで、後者を提供します。
このRFCはポイントフリースタイル、すなわち不要な中間変数を排除するプログラミング手法を簡単に実現します。
ポイントフリースタイルはJavaScript界隈などでも人気が高まっており、このスタイルはなじみ深いものとなるでしょう。
このRFCは、パイプライン演算子から自然に発展したものです。
Proposal
新たな演算子を提案します。
closure + closure;
合成演算子+は、概念的にはパイプライン演算子と似ていますが、ただし即座に実行はされません。
引数がひとつであるクロージャに適用すると、2つのクロージャをまとめた新たなクロージャを生成します。
引数をつけて呼び出した場合、引数は最初のクロージャに渡され、その結果がふたつめのクロージャに渡され、その結果が返されます。
これによって、新たな演算をシンプルに定義することができます。
新機能は単なる演算子なので、他の全ての言語機能と互換性があります。
たとえば条件付きパイプラインを構築したければ、if文と組み合わせるだけです。
$processor = htmlentities(...)
+ str_split(...)
+ (fn($x) => array_map(strtoupper(...), $x));
if ($some_flag) {
$processor += fn($x) => array_filter($x, fn($v) => $v != 'O');
}
$result1 = $processor('some string');
$result2 = $processor('other string');
ユーザ空間実装でよく使用されるメソッドチェーンや条件付きメソッドよりも、はるかに柔軟です。
合成演算子は式なので、式が使用可能な場所であればどこでも使用可能です。
Allowed arguments
合成演算子の両側には、第一級オブジェクトを含め、あらゆるClosureオブジェクトを指定可能です。
もしくは呼び出し可能オブジェクト、すなわち__invoke()メソッドを持つオブジェクトも指定できます。
クロージャ以外を禁止する理由は、主に簡潔さのためです。
PHPには文字列や配列との区別が困難な呼び出し可能構文が既に多く存在しており、そして文字列と配列には既に+演算子が使用されています。
最近は第一級callableやアロー関数で簡単にクロージャを作成できるため、対象をクロージャに限定することは利用の障害にはなりません。
今後部分適用が改定されるなどクロージャに改善が加えられれば、当然それらもサポートされます。
必須引数を複数持つクロージャは禁止であり、関数を引数不足で呼び出した場合と同じくエラーになります。
Logic description
合成演算子は、次のオブジェクトとおおむね同じ概念になります。
class ComposedClosure
{
private array $callables = [];
// 左辺がComposedClosureでない場合に呼ばれる
public static function make(callable $left, callable $right)
{
$this->callables[] = $left;
$this->callables[] = $right;
}
// 実行
public function __invoke(mixed $arg): mixed
{
foreach ($this->callables as $fn) {
$arg = $fn($arg);
}
return $arg;
}
// 合成演算子
public function add(callable $next): self
{
$new = new self();
$new->callables = [...$this->callables, $next];
return $new;
}
}
Syntax choice
文法の選択について。
パイプ演算子は、誰もが|>を使います。
合成演算子はいまだスタンダードが決まっていません。
Haskellは.を使いますが、これは予想とは逆の順番で実行されます。
F#やRubyでは>>が使用されます。
>>はPHPでは右シフトのビット演算子であり、現在は数値以外に使用できません。
オブジェクトには使用できないため、安全な選択肢のひとつです。
しかし、この記号には自然な合成代入演算子がありません。
自然な第一候補は>>=ですが、しかしこれはHaskellではより高度な処理を行うbind演算子として使われています。
そのため将来bind演算子を導入する際の障壁になる可能性があります。
+と.はどちらもオブジェクトに使うことはないため、利用可能な選択肢です。
また自然な合成代入演算子+=・.=も存在します。
Haskellとの混乱を避けるため、本RFCでは+を推奨します。
PHPとHaskellは.の実行順が正反対であり、Haskellのfoo . bar . bazはPHPでは$baz . $bar . $fooとなってしまうためです。
Why in the engine?
PHPエンジンで実装する理由。
ユーザランド実装における最大の問題はパフォーマンスです。
最小限の実装ですら、演算のたびに2・3回の関数呼び出しがかかるため、実行コストが高くなります。
ネイティブ実装ではそのようなコストはかかりません。
また、ユーザランド実装では不自然なネストも必要になってきます。
$fn = compose(
htmlentities(...),
str_split(...),
fn($x) => array_map(strtoupper(...), $x),
fn($x) => array_filter($x, fn($v) => $v != 'O'),
);
より複雑な実装では、大幅な速度低下を招くマジックメソッドを使ったり、多数のミドルウェアを経由したりと、たった二つの関数を合成したいだけのために過剰な装飾が施されています。
ネイティブ実装では、これらの課題が全て解決します。
また静的解析ツールにおいても、検証がより容易になるでしょう。
Why isn't pipe enough?
パイプで十分ではないと申すか?
パイプ演算子|>と非常によく似た機能がさらに必要なのか、という当然の疑問が上がるでしょう。
たしかに両方の演算子は似ていますが、それぞれ目的が異なっており、必ずしも互換性があるわけではありません。
単純なケースでは、関数合成はパイプで完全再現できます。
$newFunc = fn($x) => $x |> func1(...) |> func2(...) |> func3(...);
$newFunc($arg);
しかし、より大きな式を扱いたい場合や、パイプをグルーピングしたい場合などは、パイプ演算子では扱いにくくなります。
// 何回もループするので不適切
$result = $val
|> func1(...)
|> map(func2(...))
|> map(func3(...))
|> filter(func4(...))
|> filter(func5(...))
;
// 一般的に推奨される書き方だけど、読みにくい
$result = $val
|> func1(...)
|> map(fn($x) => $x |> func2(...) |> func3(...))
|> filter(fn($x) => $x |> func4(...) |> func5(...))
;
// わかりやすい
$result = $val
|> func1(...)
|> map(func2(...) + func3(...))
|> filter(func4(...) + func5(...))
;
また、条件によってパイプをしたりしなかったりする場合は、あまりに面倒なので紹介する気にもならなくなるほどの式となるでしょう。
むしろパイプしないほうがより簡単です。
$startVal = new Command();
$middlwares = [];
$middlewares[] = func1(...);
if ($isAdmin) {
$middlewares[] = func2(...);
}
if ($isTuesday) {
$middlewares[] = fizbin(...);
}
foreach ($middlewares as $m) {
$startVal = $m($startVal);
}
$result = $startVal;
関数合成なら、もっと簡単に書けるうえにパフォーマンスも向上します。
$startVal = new Command();
$middlewares = func1(...);
if ($isAdmin) {
$middlewares += func2(...);
}
if ($isTuesday) {
$middlewares += fizbin(...);
}
$result = $middelwares($startVal);
関数合成を使ってパイプを再現することはできます。
$result = (func1(...) + func2(...) * func3(...))($arg);
しかしそうすると括弧だらけになっていまい、さらにパイプの利点左から右へも失われてしまいます。
そのため、似ているにもかかわらず、両者は異なるユースケースを持っており、競合の立場ではなく補完の立場にあります。
( たとえば減算は負の加算で完全に再現できますが、減算演算子を個別に提供していない言語は皆無です。そういうことです。 )
Future Scope
将来の展望。
本RFCには含まれていません。
・オブジェクトへの__bindメソッドとbind機能の実装。
右辺が左辺のメソッドに渡され、必要に応じて呼び出されます。
・+以外の算術演算子-・*・/
Backward Incompatible Changes
互換性のない変更はありません。
Patches and Tests
感想
正直個人的には、単なるクロージャの時点ですらあまり活用できていないくらいなので、関数合成もそんなに使いそうにないです。
しかしフレームワークやコアな部分を開発している人にとっては、多くの使い出がある機能ではないでしょうか。
ぜひ楽しい使い方を見つけてみてください。
まあ、関数合成の土台となる部分適用の土台となるパイプライン演算子が2025年11月リリースのPHP8.5でようやくお披露目になるところなので、まだまだ先のことではありますけどね。
実際に関数合成が世に出るころは、パイプラインと部分適用のフィードバックを受けることで、現在の仕様からさらに改善されているのではないかと思います。