PHPとgeneratorのお話
この記事は、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
の具体的な操作方法は、current
や next
などの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
は便利なツールですが、その仕様を理解して適切に使用することが、予期せぬトラブルを防ぐ鍵となります。ご利用は計画的に!
Discussion