38
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【PHP8.5】PHPでパイプライン演算子が使えるようになる

Posted at

PHPは元々関数をベースに発展してきた言語です。

PHP8.4
$string = random_bytes(10);
$string = str_repeat($string, 2);
$string = str_replace($string, 'c', 'z');
$string = substr($string, 5);
$len = strlen($string);

↑は適当に作っただけなので全く意味のない処理ですが、だいたいこんなかんじで値に対して順番に関数を適用していくかんじです。
まあこの後クラスやらClosureやらどんどん入ってきて、なんでもできるマルチパラダイム言語になりつつありますが。

さてこの書き方、1文にまとめようとするととたんにわかりにくくなります。

$len = strlen(substr(str_replace(str_repeat(random_bytes(10), 2), 'c', 'z'), 5));

とてもつらい。

ということでパイプライン演算子のRFCが提出されました。

PHP8.5
$len = random_bytes(10)
    |> fn($x) => str_repeat($x, 2)
    |> fn($x) => str_replace($x, 'c', 'z')
    |> fn($x) => substr($x, 5)
    |> strlen(...);

現在投票中ですが、ほぼ間違いなく受理され、PHP8.5から利用可能になる予定です。
楽しみですね。

以下は該当のRFC、Pipe operator v3の紹介です。

Pipe operator v3

Introduction

オブジェクト指向コードにおいてcompositionとは一般的に、「あるオブジェクトが別のオブジェクトへの参照を持つ」を意味します。
関数型プログラミングにおいてcompositionとは一般的に、「複数の関数を繋ぎ合わせて新しい関数を作る」を意味します。
どちらも有効かつ有用な手法であり、PHPのようなマルチパラダイム言語にとっては特に有能です。

compositionは主に、即時実行と遅延実行の2種類があります。
即時実行は通常、パイプライン演算子で実装されます。
遅延実行は通常、合成演算子で実装されます。
合成演算子は、二つの関数を受け取り、それぞれの関数を順番に呼び出す新しい関数を生成します。
この二つを組み合わせることにより、不要な中間変数を排除するプログラミング手法、ポイントフリースタイルが実現されます。
ポイントフリースタイルはJavaScript界隈などで人気が高まっており、JavaScript開発者には馴染みのある手法です。

このRFCでは、パイプライン演算子を、他のほとんどの言語と同様の文法で導入します。
関数合成については、今後別のRFCで展開する予定です。

構文の例です。

function getUsers(): array {
    return [
        new User('root', isAdmin: true),
        new User('john.doe', isAdmin: false),
    ];
}
 
function isAdmin(User $user): bool {
    return $user->isAdmin;
}
 
// パイプライン演算子
$numberOfAdmins = getUsers()
    |> fn ($list) => array_filter($list, isAdmin(...)) 
    |> count(...);
 
var_dump($numberOfAdmins); // int(1);

Proposal

あたらしい演算子|>を導入します。

mixed |> callable;

|>は、右側にcallableを受け取り、左側の値をそのcallableに渡します。
評価結果は、callableの結果となります。
以下の2コードは、論理的に同等です。

$result = "Hello World" |> strlen(...);

$result = strlen("Hello World");

一回だけの使用ではあまり役に立ちませんが、複数を連鎖させるときに有用です。
すなわち、以下の2コードも同等です。

$result = "Hello World"
    |> htmlentities(...)
    |> str_split(...)
    |> fn($x) => array_map(strtoupper(...), $x)
    |> fn($x) => array_filter($x, fn($v) => $v != 'O');

$temp = "Hello World";
$temp = htmlentities($temp);
$temp = str_split($temp);
$temp = array_map(strtoupper(...), $temp);
$temp = array_filter($temp, fn($v) => $v != 'O');
$result = $temp;

パイプの左側は任意の式や値を指定可能です。
右側には、引数がひとつのcallableを指定可能です。
必須パラメータが2つ以上の関数は許可されておらず、引数が不足のエラーになります。
右側が有効なcallableと評価されない場合はエラーになります。

パイプライン演算子は式なので、式が有効な場所であればどこでも使用可能です。

Precedence

パイプライン演算子は左結合です。
左側が、右側より先に評価されます。

優先順位は、よくある使用例を考慮して選択されました。
比較演算より先に評価されるため、結果を比較できます。
数学演算よりは後です。

// 同じ
$res1 = 5 + 2 |>  someFunc(...);
$res1 = (5 + 2) |>  someFunc(...);
 
// true
$res1 = 'beep' |> strlen(...) == 4;
 
// ??より先
$user = $id
     |> get_username(...)
     ?? 'default';
 
// 括弧が必要
$res1 = 5 |> ($user_specified_func ?? defaultFunc(...));
 
// 括弧が必要
$res1 = 5 |> ($config['flag'] ? enabledFunc(...) : disabledFunc(...));

Performance

現在の実装は完全にコンパイラで動作しており、実質的にはコンパイル時にパイプライン演算子を通常の式に変換しています。
つまり、パイプライン演算子の実行にはオーバーヘッドが発生しません。

Callable styles

パイプライン演算子は、PHPがサポートしているあらゆる呼び出し可能構文をサポートします。
現在最も一般的な形式はファーストクラス呼び出し可能構文、すなわちstrlen(...)であり、非常にスムーズに連携します。
今後部分関数のRFCなどが受理されれば、それらもサポートされます。

References

いつものように、リファレンスが問題になります。
サポートするのは単純な場合であれば容易ですが、複雑な場合は困難になります。
従って一律で禁止するほうが適切です。

$arr = ['a' => 'A', 'b' => 'B'];
 
$val = 'C';
 
function inc_print(&$v) {
  $v++;
  print $v;
}
 
// 容易に対応可能
$val |> inc_print(...);
 
// 対応困難であり、不可能かもしれない
$arr |> inc_print(...);

パイプライン演算子の設計は、データが純粋関数的に左から右へと流れるというものです。
リファレンスはこの構造を破壊し、潜在的な遠隔操作による不審な動作を引き起こします。
そもそもリファレンスが適切なユースケースはほとんどありません。

そのため、パイプライン演算子の右側にリファレンスは許可されません。
上記の例は、ふたつともエラーになります。

唯一の例外はprefer-ref関数であり、標準ライブラリにのみ存在する、ユーザ実装不可能な関数です。
値とリファレンスいずれを受け取るかによって動作が異なる関数が存在しますが、その関数をパイプライン演算子に使用すると値渡しされたものとして扱います。

Syntax choice

F#ElixirOCamlいずれも|>を使用しています。
JavaScriptも長い議論の結果|>になります。
パイプライン演算子の標準的な構文です。

Use cases

パイプライン演算子の使用例は多岐にわたります。
関数のネスト解消、純粋関数、複雑な処理を単一の式に整理する、拡張関数など。

以下は、実際に書いたことのあるコードの置き換え例です。

String manipulation

// 実際の関数はもっと複雑だったが、説明のため簡単にする
function splitString(string $input): array
{
    return explode(' ', $input);
}
 
// スネークケースにする
$result = 'Fred Flintstone'
    |> splitString(...)           // 配列にする
    |> fn($x) => implode('_', $x) // _で結合する
    |> strtolower(...)            // 小文字にする
; // fred_flintstone
  
// ローワーキャメルケースにする 
$result = 'Fred Flintstone'
    |> splitString(...),
    |> fn($x) => array_map(ucfirst(...), $x)  // 先頭を大文字にする
    |> fn($x) => implode('', $x)              // 結合する
    |> lcfirst(...)                           // 最初だけ小文字にする
; // fredFlintstone

Array combination

$arr = [
  new Widget(tags: ['a', 'b', 'c']),
  new Widget(tags: ['c', 'd', 'e']),
  new Widget(tags: ['x', 'y', 'a']),
];
 
$result = $arr
    |> fn($x) => array_column($x, 'tags') // tagsを取得
    |> fn($x) => array_merge(...$x)       // フラットな配列にする
    |> array_unique(...)                  // 
    |> array_values(...)                  // 
;
 
// $result = ['a', 'b', 'c', 'd', 'e', 'x', 'y'. 'z']

現在でも1行で書けますが、非常によくないでしょう。

array_values(array_unique(array_merge(...array_column($arr, 'tags'))));

Shallow calls

関数合成にパイプを使うと、密接なタスクを分離することができます。
少し不自然な例ですが、以下を考えてみましょう。

function loadWidget($id): Widget
{
    $record = DB::query("something");
    return makeWidget($record);
}
 
function loadMany(array $ids): array
{
    $data = DB::query("something");
    $ret = [];
    foreach ($data as $record) {
        $ret[] = $this->makeWidget($record);
    }
    return $ret;
}
 
function makeWidget(array $record): Widget
    // 実際はここがもっと複雑
    return new Widget(...$record);
}

この例では、makeWidget()を実行せずにloadWidget()loadMany()だけをテストすることができません。
関数やメソッドがより深いネストになっている場合、大きな問題になることがよくあります。
関数チェーンが容易になることで、以下のようにできるでしょう。

function loadWidget($id): array
{
    return DB::query("something");
}
 
function loadMany(array $ids): array
{
    return DB::query("something else");
}
 
function makeWidget(array $record): Widget
    // Assume this is more complicated.
    return new Widget(...$record);
}
 
$widget = loadWidget(5) |> makeWidget(...);
 
$widgets = [1, 4, 5] 
    |> loadMany(...) 
    |> fn(array $records) => array_map(makeWidget(...), $records);

後者は高階関数や部分適用でさらに簡素化できるみこみです。

さらに、パイプは任意のオブジェクトを受け取ることができるので、関数にパッケージ化できます。

// 現実的に使われそうなAPI
function loadSeveral($id) {
  return $id 
      |> loadMany(...) 
      |> fn(array $records) => array_map(makeWidget(...), $records);
}
 
$profit = [1, 4, 5] 
    |> loadSeveral(...)
    |> fn(array $ws) => array_filter(isOnSale(...), $ws)
    |> fn(array $ws) => array_map(sellWidget(...), $ws)
    |> array_sum(...);

以上で、プロセスのロジックフロー全体をコンパクトでテスト可能な操作にカプセル化できました。

Streams

ストリームのリソースに対してもパイプを使うことができます。
簡単な例を示します。

function decode_rot13($fp): \Generator
{
    while ($c = fgetc($fp)) {
        yield str_rot13($c);
    }
}
 
// 文字列を受け取ってバッファを返す
function lines_from_charstream(iterable $it): \Closure
{
    $buffer = '';
    return static function () use ($it, &$buffer) {
        foreach ($it as $c) {
            $buffer .= $c;
            while (($pos = strpos($buffer, PHP_EOL)) !== false) {
                yield substr($buffer, 0, $pos);
                $buffer = substr($buffer, $pos);
            }
        }
    };
}
 
 
fopen('pipes.md', 'rb') // 変数に代入していないので、GCで自動削除される
    |> decode_rot13(...)
    |> lines_from_charstream(...)
    |> map(str_getcsv(...))
    |> map(Product::create(...))
    |> map($repo->save(...))
;

これは機能のデモンストレーションであり、特定の使い方を推奨するものではありません。
しかし、ストリームを構造化された方法で容易に扱うことができる可能性があります。

Existing implementations

パイプを再現しようとしているユーザ空間ライブラリの例。
いずれも、ネイティブ構文に比べると必然的に扱いにくくなってしまいます。

・PHP League League\Pipeline
・Laravel Illuminate/Pipeline
PSL pipe
Sebastiaan Luca
PipePie
ZenPipe
Crell/fp

多くのブログ等でパイプラインパターンが取り上げられています。

Why in the engine?

ユーザ空間実装による最大の問題点はパフォーマンスです。
最もシンプルな実装(Crell/fp)でさえ、全ての操作が2-3回関数を呼び出すため、比較的コストがかかります。
ネイティブ実装では、そのような追加のオーバーヘッドは発生しません。

pipe($someVal,
    htmlentities(...),
    str_split(...),
    fn($x) => array_map(strtoupper(...), $x),
    fn($x) => array_filter($x, fn($v) => $v != 'O'),
);

このコードにはコンパイルで削除できないところが、2つのクロージャhtmlentities(...)・str_split(...)、pipe()自身の呼び出し、そしてpipe()内にあるforeachループがあります。
これらはネイティブ演算子では全て除去されます。

より複雑な実装では、通常の関数呼び出しより遙かに遅いマジックメソッドを呼んだり複数のミドルウェアを使っていたりと、2つの関数を合成するだけの目的には過剰な処理となっています。

Future Scope

今後の予定。
このRFCには含まれていません。

このRFCは、compositionベースのコードを導入するためのステップ1として位置付けられています。
今後導入される予定の、他の新機能と連携します。

Language features

クロージャの合成演算子
これは複数のクロージャを合成し、新たな呼び出し可能オブジェクトを作成します。
これによって、新しい操作をシンプルに定義し、変数に保存しておいて後から使用することができます。
合成演算子はただの演算子なので、他の全ての言語機能と互換性があります。

筆者は、合成演算子はパイプに必須であり、いまの機能だけは不完全であると確信しています。
しかしパイプはコンパイル時に容易に実装できますが、合成演算子は大きな作業が必要となります。
そのため合成演算子は個別のRFCとして分割されました。

一般的な部分関数。
かつてのRFCでは、複雑さを正当化できるほどのユースケースがないとして却下されましたが、関心は大きく、関数合成のユーザビリティを向上させます。
より複雑でない実装がみつかれば、可決される可能性があります。

独自バインドメソッド。
オブジェクトに__bind()等のマジックメソッドを実装し、たとえば>>=など専用の演算子を付与します。
左辺のオブジェクトに実装すると、右辺の値が渡されます。
この機能によって、たとえばPHPにおいてモナドのような動作を再現することができます。

Iterable API

一時期、array_map()などをよりわかりやすいAPIに置き換えようとした、iterableAPIの議論が行われていました。
パイプはその使用に最適です。

iter\map()iter\filter()iter\first()iter\unique()などが実装されたとしたら、次のように書けるようになるでしょう。

$result = $pdo->query("Some complex SQL")
    |> filter(?, someFilter(...))
    |> map(?, transformer(...))
    |> unique(...)
    |> first(someCriteria(...));

Rejected Features

却下された機能。

パイプの後の関数に引数を自動適用する、つまり第一引数に自動的に渡されるようにする文法の議論が行われました。
これによって、よりパイプフレンドリーな記述が可能になります。
Elixirはそのような動作になっています。

// このRFC
$foo
  |> bar(...)
  |> fn($x) => array_filter($x, fn($v) => $v != 'O');
 
// Elixirスタイル
$foo
  |> bar()
  |> array_filter(fn($v) => $v != 'O');

筆者はこれを魅力的だと思いましたが、多くの開発者が予想外の動作だと感じました。
そのため、本RFCでは対象外になっています。

Backward Incompatible Changes

後方互換性のない変更はありません。

Proposed PHP Version(s)

PHP8.5。

Proposed Voting Choices

投票期間は2025/05/13~2025/05/26、投票の2/3の賛成で受理されます。
2025/05/19時点では賛成28反対6の賛成多数であり、おそらく可決されます。

Patches and Tests

https://github.com/php/php-src/pull/17118

感想

こう書きたかったんだけど、これは無理だったのだろうか。

だめ
$len = random_bytes(10)
    |> str_repeat(..., 2)
    |> str_replace(..., 'c', 'z')
    |> substr(..., 5)
    |> strlen(...);

右辺は引数をひとつしか取れないという妙な制限があるせいで、概ね毎回クロージャを強いられます。
ここだけは個人的にちょっと残念ですね。

ところでv3とあるように、このRFCは過去にも2回提出されたことがあります。

初回の提案はまだPHP Foundationが設立される前であり、どうやら開発リソース不足ということもあって放棄されたようです。

2回目の提案は却下されました。
RFC自体は今回とほとんど変わらないっぽいのですが、どうもスケジュール的におかしい感じです。
たとえば当初は|>の右側が先に評価されていたのですが、常に左側から評価されるよう修正されました。
で、そのわずか2日後に投票開始です。

あと、なんでかサンプルコードが文字列になっていて、最初にぱっと目に入るのがこれなのでえぇ……となりますね。

$result = "Hello World"
    |> 'htmlentities'
    |> 'str_split'
    |> fn($x) => array_map('strtoupper', $x)
    |> fn($x) => array_filter($x, fn($v) => $v != 'O');

ということでそういったあたりが不安視されたのか、賛成11反対17の反対多数で却下されています。

今回は3度目の正直ということで、過去の問題点は潰してきたみたいです。
正直RFCだけだと使い方がよくわからないところもあるのですが、プルリクを見るとたくさんの使用例が書いてあります。
こっちももっと主張して欲しいですね。
というか、いきなり複雑な使い方ばっかり書いてあるので、もっと単純なところから始めてほしいところ。

使用例

// 基本的な使い方
$res1 = "Hello" |> strlen(...); // 5
$res1 = "Hello" |> 'strlen';    // 5


// 基本的な使い方2
function _test1(int $a): int {
    return $a + 1;
}
function _test2(int $a): int {
    return $a * 2;
}

$res1 = 5 |> '_test1' |> _test2(...); // 12


// 優先順位
function _test1(int $a): int {
    return $a * 2;
}

$res1 = 5 + 2 |>  '_test1'; // 14 "5+2"が先
$res1 = 5 |> _test1(...) == 10 ; // true "5 |> _test1"が先


// デフォルト値のある関数は複数引数でも呼べる
function _test(int $a, int $b = 3) {
    return $a + $b;
}
$res1 = 5 |> '_test'; // 8


// ジェネレータ
function producer(): \Generator {
  yield 1;
  yield 2;
  yield 3;
}
function map_incr(iterable $it): \Generator {
  foreach ($it as $val) {
    yield $val +1;
  }
}
$result = producer() |> map_incr(...) |> iterator_to_array(...); // [2, 3, 4]


// 呼び出し順序
function foo()     { echo __FUNCTION__, PHP_EOL; return 1; }
function bar()     { echo __FUNCTION__, PHP_EOL; return false; }
function baz($in)  { echo __FUNCTION__, PHP_EOL; return $in; }
function quux($in) { echo __FUNCTION__, PHP_EOL; return $in; }

$result = foo()
  |> (bar() ? baz(...) : quux(...))
  |> var_dump(...); // foo・bar・quux・1の順

サンプルは何故かほとんど文字列で関数呼び出しされているんだけど、文字列だと引数が自動割り当てされるみたいです。

38
12
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?