🏭

PHPとgeneratorのお話

2024/12/23に公開

この記事は、OPENLOGI アドベントカレンダー 2024 22日目の記事です

株式会社オープンロジでエンジニアをしている原田と申します。
オープンロジのプロダクトではPHPを採用しております。本記事ではPHPの機能の一つであるgeneratorについて、簡単にご紹介します。

この記事の対象は、以下のような方々を想定しています

  • generatorを知らない方
  • 知ってはいるが、実際に使ったことがない方
  • どのような場面で使えるのか具体的なイメージが湧かない方

generatorは、どのプログラミング言語にも存在する概念ではないですし、Webアプリケーションにおける必要性は比較的少ないため、意外と知られていない機能の一つかもしれません。Pythonではかなりよく使われている印象がありますが、JavaやGo、Rust[1]にはありません。JavaScriptではES6から導入されました。
ちなみに筆者も最初はPHPへの偏見から、「PHPにgeneratorなんてない」と思い込んでいましたが、PHP5.5の時に実装されたようです。

この記事では、PHPでのgeneratorを用いた実装の具体例を紹介しながら、その便利さをお伝えしていきます。

※ Pythonなどでしばしば利用される非同期処理やコルーチン的な使い方については触れません。
※ 一般的な概念や役割としては generator PHPにおける機能、クラス名としては Generator 表記が正確かと思いますが、この記事では特にクラス名であることを強調したい場合以外、 generator 表記を行います。

generatorとはなんぞや

さて、まずはgeneratorとはなんぞや?ということの簡単な説明のため、教科書的な話題です。
無限に続くシーケンスを生成するという題材で、generatorを紹介します。

function infiniteSequence(int $start = 0): Generator {
    echo 'start' . PHP_EOL;
    $current = $start;
    while (true) {
        echo 'wait for next' . PHP_EOL;
        yield $current++;
    }
}

一見すると無限ループのような実装ですが、infiniteSequenceを実行しても処理は即座には実行されません。yieldによる遅延評価のため、最初に値が要求されるタイミングで処理が開始されます。

以下のように呼び出してみると動きがよくわかるのですが、


$iterator = infiniteSequence(10);
// この時点では `start` は出力されない

foreach ($iterator as $value) {
    echo $value . PHP_EOL;
    if ($value >= 15) break;
}

// iteratorを回すことで、初めて評価される
> start
> wait for next
> 10
> wait for next
> 11
> wait for next
> 12
> wait for next
> 13
> wait for next
> 14
> wait for next
> 15
  • infiniteSequence を実行すると、Generator のインスタンスが返される。このインスタンスはiteratorとして反復処理を行うことができる。
  • infiniteSequence を実行したタイミングではこの関数内の処理が評価されない
  • このインスタンスの反復処理が行われるたびに、yield に渡した値が返される

yield による遅延評価は、イテレーターが次の値を要求されるたびに行われるため、一般的に大量データの逐次処理と相性が良いとされています。上の例では、 echo 'wait for next' . PHP_EOL の行がめちゃくちゃ重い処理だったとしてもその処理が最初に実行されるのはなくイテレーションのたびに行われるという事です。
また、generatorを使うことで、シーケンス生成の処理を他のロジックから切り離すことができ、コードの可読性が向上するケースがあります。

generatorの具体的な操作方法は、currentnext などのiteratorの関数とともに理解できますが、このあたりの詳細は別の資料に譲るとして、この記事ではいくつかの活用事例を紹介します。

事例

callbackとiterator

generatorを利用しない場合、callbackによる実装が代替となるケースがよくあります。
まずは使い勝手を比較するため、ファイルの逐次処理についてみてみましょう。

generatorを使わない場合

function readFile(Closure $callback) {
    $handle = fopen('large_file.txt', 'r');
    try {
        while (($line = fgets($handle)) !== false) {
            $callback($line);
        }
    } finally {
        fclose($handle);
    }
}

function processFile(){
    readFile(function($line) {
        echo $line . PHP_EOL;
    });
}

generatorを使う場合

function readFile(): Generator {
    $handle = fopen('large_file.txt', 'r');
    
    try {
        while (($line = fgets($handle)) !== false) {
            yield $line;
        }
    } finally {
        fclose($handle);
    }
}

// 使用例
function processFile(){
    foreach (readFile() as $line) {
        echo $line . PHP_EOL;;
    }
}

前者はcallbackを利用し、後者はgeneratorを使って実現しています。
筆者としては、callbackを利用する場合はコードのフローが直線的では無くなるため、generator明示的にイテレーションの流れが記述される方が全体の処理が見通しやすいと感じますが、このようなシンプルな実装では、callbackの方がより簡潔に書ける場合もあります。
この程度の違いであれば、どちらを選ぶかは好みによると言えるでしょう。

ツリーやグラフのトラバース

ツリーやグラフ構造をトラバースする際、再帰処理にgeneratorを使うと便利です。
通常の実装では、データ構造の処理とロジックが密結合しがちで、これを分離するには中間状態を用意する必要があります。

密結合

function dfsWithoutGenerator(Node $node): void {
    $result = [];
    $result[] = $node->value;
    foreach ($node->children as $child) {
        someProcess($child);
        dfsWithoutGenerator($child);
    }
}

中間状態の利用

function dfsWithoutGenerator(Node $node): array {
    $result = [];
    $result[] = $node->value;
    foreach ($node->children as $child) {
        $result = array_merge($result, dfsWithoutGenerator($child));
    }
    return $result;
}

foreach (dfsWithoutGenerator($tree) as $value) {
    echo $value . PHP_EOL;
}

一方、generatorを使うことで、ツリーの探索と処理を明確に分離でき、中間状態を排除することでコードの簡潔性が向上します。

function dfs(Node $node): Generator {
    yield $node->value;
    foreach ($node->children as $child) {
        yield from dfs($child);
    }
}

foreach (dfs($tree) as $value) {
    echo $value . PHP_EOL;
}

ロジックの分離

次に、ロジックの分離について掘り下げるため、データベースから取得したデータの処理を見ていきます。

以下のデータ取得処理に、いくつかの要件を追加しながら実装を改良していきます。

function processAll(){
    foreach (fetchAll() as $row) {
        // 行ごとの処理
    }
}

このコードの主な問題点は、大量のデータを扱う場合のメモリ消費です。データベースカーソルを使用すれば1件ずつデータを取得できますが、これはデータベースやPDOの制約に依存するため、実際には利用が難しいこともあります[2]。そのため、一般的な解決策としては、データを小分けに処理するchunk処理が挙げられます。

chunk処理

愚直に(雑に)実現するとこのような形でしょうか。

function fetchChunkAndProcessRow(Closure $callback)
{
    $offset = 0;
    $chunkSize = 1000;

    while (true) {
        $rows = fetch($chunkSize, $offset);

        if (empty($rows)) {
            break; // データがなくなったら終了
        }

        foreach ($rows as $row) {
            $callback($row);
        }

        $offset += $chunkSize;
    }
}

function processAll() {
    fetchChunkAndProcessRow(function($row) {
        // do something;
    });
}

この方法ではメモリ使用量を抑えられますが、その実現にはcallbackを適用できるよう構造を変更する必要がありました。

一方、generatorを利用する場合は、変更をデータ取得部分に限定できるため、全体の構造を維持したまま実現が可能です。

function fetchAll(): Generator {
    $offset = 0;
    $chunkSize = 1000;

    while (true) {
        $rows = fetch($chunkSize, $offset);

        if (empty($rows)) {
            break;
        }
        yield from $rows;
        $offset += $chunkSize;
    }
}

function processAll(){
    foreach (fetchAll() as $row) {
        // 行ごとの処理
    }
}

ただし、ロジックの分離という観点では、前者でもcallback 関数を用いた分離が行えています。そのため、上述したファイル処理の例と同様にgeneratorの恩恵をあまり感じないかもしれません。

では次に、さらに要件を追加して、取得したデータを50件ずつバッチ処理することを考えてみます。

バッチ処理

50件ずつ処理するためには、取得したレコードを50件ごとにまとめる必要があります。もしfetchのロジックを修正しない場合、このように変数にデータを貯める仕組みを追加する必要があります。

function processAll() {
    $temporary = []
    fetchAndProcessEach(function(array $row) use (&$temporary) {
        temporary[] = $row;
        if (count($temporary) >= 50) {
            processBatch($temoprary);
            $temporary = [];
        }
    });
    if (!empty($temporary)) {
        processBatch($temoprary);
    }
}

しかし、この方法では、バッチ化とメインロジックが密結合してしまい、変更の柔軟性が低くなります。また、コードの可読性や再利用性も大きく損なわれます。

一方で、fetch側にバッチ化の処理を押し込めることも可能ですが、この場合はデータ取得とバッチ化のロジックが絡み合い、別の問題を引き起こします。

function fetchChunkAndProcessRow(Closure $callback)
{
    $offset = 0;
    $chunkSize = 1000;
    $temporary = [];

    while (true) {
        $rows = fetch($chunkSize, $offset);
        if (empty($rows)) {
            break;
        }

        foreach ($rows as $row) {
            $temporary[] = $row;
            if (count($temporary) > 50) {
                $callback($temporary);
                $temporary = [];
            }
        }

        $offset += $chunkSize;
    }
    if (!empty($temporary)) {
        $callback($temporary);
    }
}

generatorを活用することで、以下のように「50件ごとにバッチ化する処理」を専用のロジックとして独立させることができます。

function chunkIntoBatches(iterable $rows, int $batchSize) {
    $batch = [];
    foreach ($rows as $row) {
        $batch[] = $row;
        if (count($batch) === $batchSize) {
            // 配列をyieldする
            yield $batch;
            $batch = [];
        }
    }
    if (!empty($batch)) {
        yield $batch;
    }
}

function processAll(){
    foreach (chunkIntoBatches(fetchAll(), 50) as $rows) {
        processBatch($rows);
    }
}

この方法には次のような利点があります。

  • 責務が明確になり、高い変更容易性の実現
  • 再利用性と拡張性の向上

データ取得、chunk処理、バッチ処理がそれぞれ独立したロジックとして分離されるため、どの部分が何を担当しているかが明確になります。たとえば、データベースからの取得部分を変更しても、バッチ化や処理部分に影響を与えることはありません。また、構造を変えずに別の反復処理(フィルタリング処理や変換処理など)を挟むこともできます。
また、バッチ化のロジックは他の処理に簡単に再利用できます。

まとめ

このように、generatorを活用することで、以下のようなメリットを得られる場合があります。

大量データの逐次処理
遅延評価を活用することで、膨大なデータを一度にメモリに保持することなく、効率的に処理できます。これにより、メモリ制限のある環境でも柔軟に対応可能です。

ロジックの分離
データ取得、バッチ処理、データ加工といったロジックを明確に分離できるため、責務が明確になります。これにより、各処理の保守が容易になります。

再利用性と拡張性
バッチ処理やイテレーションのロジックを独立させることで、他の処理でも再利用が可能になります。また、要件の変更に対しても柔軟に対応でき、保守性や拡張性が向上します。

一方で、この記事では触れませんでしたがgeneratorにはいくつかの注意点もあります。

デバッグの難しさ
エラーや状態管理の問題を追跡するのが通常の関数よりも複雑になる場合があります。

終端処理
遅延評価を行うgeneratorは、最後まで消費しない限りすべての処理が実行されません。そのため、適切に実装されていない場合、イテレーションを途中で停止した際に意図しない未処理が発生したり、ファイルハンドルやデータベース接続などのリソースが適切に解放されないリスクがあります。

generatorは強力なツールですが、常に最適な選択肢であるわけではありません。例えば、処理が単純な場合や、特に遅延評価を必要としない場合には、通常の反復処理や配列操作の方が適していることも多くあります。
基本的には、通常の反復処理を優先しつつ、特定の課題を解決する手段としてgeneratorを取り入れるのが良いでしょう。適切な場面で活用することで、コードの効率性と柔軟性を大幅に向上させることができます。

おまけ

以上、generatorの便利さは伝わりましたでしょうか?
最後に、PHPのgeneratorを利用する際の怖いはまりポイントを一つご紹介します。

// 3回に分けたデータのfetchをすることをエミュレート
function fetch()
{
    echo 'load chunk 1' . PHP_EOL;
    yield from range(1, 3);
    echo 'load chunk 2' . PHP_EOL;
    yield from range(4, 6);
    echo 'load chunk 3' . PHP_EOL;
    yield from range(7, 9);
}

// 全件出力する
collect(fetch())->each(function ($value) {
    echo $value . PHP_EOL;
});

さて、何が出力されるでしょうか?

正解はこちらです。

load chunk 1
load chunk 2
load chunk 3
7
8
9

あれ、1から6が欠落していますね。これはなぜでしょうか?

原因

PHPのyield fromを利用した場合、内部のイテレーターのキーが0から再付番されるという仕様が原因です。
具体的には、fetch()を直接foreachで確認すると、次のような結果になります。

foreach (fetch() as $index => $row) {
    echo "$index / $row" . PHP_EOL;
}
load chunk 1
0 / 1
1 / 2
2 / 3
load chunk 2
0 / 4
1 / 5
2 / 6
load chunk 3
0 / 7
1 / 8
2 / 9

ご覧の通り、yield fromを使用した際、各チャンクのキーが0から再スタートしています。このキーの重複が、collect(fetch())の結果に影響を与えます。

collect()がデータを配列として扱った結果、後続のデータが同じキーで上書きされ、結果として最後のチャンク(7〜9)のみが残るという事態が発生します。

解決策

collect(fetch())のような処理は、実際のコードであまり使用されないかもしれません。しかし、どうしてもgeneratorで生成した値を配列に変換したい場合には、以下のように処理することでキーを振り直すことができます。

collect(iterator_to_array(fetch(), false))

この方法では、iterator_to_array関数を利用し、generatorから生成されたデータを配列に変換します。ここで、第二引数にfalseを指定することで、yield で設定されたキーを無視し、配列のキーを0から振り直します。

教訓

PHPのgeneratorを使用する際、キーの扱いには十分注意が必要です。特に、yield fromを用いる場合、キーが再付番される仕様を理解しておくことが重要です。まぁPHPではこれに限らず配列のキー問題はよく発生するものなので、常に気をつけましょう、という事かもしれませんね。

generator は便利なツールですが、その仕様を理解して適切に使用することが、予期せぬトラブルを防ぐ鍵となります。ご利用は計画的に!

脚注
  1. https://doc.rust-lang.org/edition-guide/rust-2024/gen-keyword.html ↩︎

  2. http://honeplus.blog50.fc2.com/blog-entry-219.html ↩︎

Discussion