1. ホーム
  2. PHP

【PHP】ジェネレーター(yield)の使い方|大量データをメモリを節約して処理する方法

Share

10万行の CSV を読み込んで加工する処理を書いたら「Allowed memory size of … exhausted」で止まってしまった——PHP でデータをまとめて扱っていると、こうした場面によく出会います。原因は、全件を配列に詰めてから返していることです。これを解決するのが ジェネレーターで、yield というキーワードを使って値を1件ずつ作りながら渡せます。この記事では、ジェネレーターの基本の書き方から、配列を返す関数とのメモリ使用量の違い、yield from による委譲、Generator クラスのメソッド、そして「巻き戻せない」という制約までを解説します。

配列を返す関数がメモリを使い切ってしまう理由

まずは、なぜジェネレーターが必要になるのかを整理しておきます。次のような「値をまとめて作って返す関数」は、PHP を書いていれば誰でも書いたことがあるはずです。

array-version.php
<?php
function makeArray(int $max): array
{
    $numbers = [];
    for ($i = 1; $i <= $max; $i++) {
        $numbers[] = $i;   // 100万件すべてを配列に溜める
    }
    return $numbers;       // 溜め終わってから、はじめて返る
}

foreach (makeArray(1000000) as $n) {
    // 1件ずつ使いたいだけなのに、
    // ここに来る前に100万件分のメモリを確保している
}

この関数は return に到達するまで値を返しません。つまり 呼び出し側が1件目を受け取るときには、すでに100万件すべてがメモリ上に載っているわけです。使うのは1件ずつなのに、全件分のメモリを先払いしている状態です。ファイルの行数やデータベースの件数が増えれば、そのままメモリ使用量も比例して増えていきます。

ジェネレーターはここを変えます。値を溜め込まず、呼び出し側が「次の値をください」と要求した時点で1件だけ作って渡し、その場で処理を止めるという動き方をします。何件あっても同時にメモリ上に存在するのは基本的に1件分なので、巨大な CSV やログファイルを扱ってもメモリ使用量がほとんど増えません。この「必要になるまで作らない」性質を遅延評価と呼びます。

ジェネレーターの基本 — 関数に yield を書くだけ

ジェネレーターを作るのに特別な宣言は要りません。関数の中に yield が1つでも含まれていれば、その関数は自動的にジェネレーター関数になります。return の代わりに yield で値を渡す、という書き換えだけです。

sample.php
<?php
function numbers(): Generator
{
    echo '関数の中に入りました' . PHP_EOL;
    yield 1;
    echo '1 を渡したあと' . PHP_EOL;
    yield 2;
    echo '2 を渡したあと' . PHP_EOL;
    yield 3;
    echo '関数の最後まで来ました' . PHP_EOL;
}

$gen = numbers();               // ここでは本体は1行も実行されない
var_dump($gen instanceof Generator);   // bool(true)

foreach ($gen as $n) {
    echo "受け取った: {$n}" . PHP_EOL;
}

実行すると次のように出力されます。

実行結果
bool(true)
関数の中に入りました
受け取った: 1
1 を渡したあと
受け取った: 2
2 を渡したあと
受け取った: 3
関数の最後まで来ました

ここで押さえたいポイントは2つです。1つめは、numbers() を呼んだ時点では関数の本体が実行されないこと。var_dump() の結果が先に出ていることから分かるように、呼び出しの戻り値は Generator クラスのオブジェクトで、中の echo はまだ動いていません。

2つめは、foreach が値を要求するたびに yield のところまで進んで一時停止すること。yield 1; に到達すると 1 を foreach に渡してそこで止まり、次のループでその続き(echo '1 を渡したあと')から再開します。ローカル変数や実行位置は保持されたままなので、途中から自然に処理が続くわけです。一時停止と再開を繰り返しながら値を1件ずつ渡していく、これがジェネレーターの動き方です。

なお、キーを指定せずに yield 1; のように書いた場合、foreach で受け取れるキーは 0 から始まる連番が自動で振られます。戻り値の型宣言は Generator、あるいは iterable や Traversable でも構いません(array にはできません)。

配列を返す関数とメモリ使用量を比べる

効果を数字で確認してみましょう。100万件の連番を合計する処理を、配列版とジェネレーター版で書き、memory_get_peak_usage() でピークメモリを比較します。引数に true を渡すと、PHP が OS から確保した実際のメモリ量を取得できます。

memory-compare.php
<?php
// 配列版:全件を溜めてから返す
function makeArray(int $max): array
{
    $numbers = [];
    for ($i = 1; $i <= $max; $i++) {
        $numbers[] = $i;
    }
    return $numbers;
}

// ジェネレーター版:1件ずつ渡す
function makeGenerator(int $max): Generator
{
    for ($i = 1; $i <= $max; $i++) {
        yield $i;
    }
}

$mode = $argv[1] ?? 'array';   // コマンドライン引数で切り替える
$sum  = 0;

if ($mode === 'array') {
    foreach (makeArray(1000000) as $n) {
        $sum += $n;
    }
} else {
    foreach (makeGenerator(1000000) as $n) {
        $sum += $n;
    }
}

printf(
    "%s: 合計 %d / ピークメモリ %.2f MB\n",
    $mode,
    $sum,
    memory_get_peak_usage(true) / 1024 / 1024
);

PHP 8.5 のコマンドラインで実行した結果です(数値は環境によって変わります)。

ターミナル
$ php memory-compare.php array
array: 合計 500000500000 / ピークメモリ 18.02 MB

$ php memory-compare.php generator
generator: 合計 500000500000 / ピークメモリ 2.00 MB

合計値は当然どちらも同じですが、メモリ使用量は 18.02 MB と 2.00 MB で 9 倍近い差が付きました。しかも配列版は件数を10倍にすればメモリも約10倍になる一方、ジェネレーター版は件数を増やしてもほとんど変わりません。同時に保持しているのが1件分だけなので、100万件でも1億件でもメモリの消費量は同程度で済みます。この性質があるおかげで、行数の見当がつかないファイルでも安心して回せるようになります。

逆に言えば、扱う件数が数十件程度と分かっているなら、配列を返す関数のままで何も問題ありません。ジェネレーターが効くのは「件数が多い」「件数が読めない」「1件ずつ処理すれば済む」という条件がそろったときです。

キー付きの値を渡す — yield $key => $value

配列と同じように、キーと値のペアを渡すこともできます。書き方は yield キー => 値; です。連想配列を1件ずつ渡したいときや、行番号を添えたいときに使います。

keyed.php
<?php
function config(): Generator
{
    yield 'host' => 'localhost';
    yield 'port' => 3306;
    yield 'charset' => 'utf8mb4';
}

foreach (config() as $key => $value) {
    echo "{$key} = {$value}" . PHP_EOL;
}
// host = localhost
// port = 3306
// charset = utf8mb4

キーは文字列でも数値でも構いません。配列のキーと違って重複しても構わない点が特徴で、同じキーを何度 yield しても、foreach はそのつど素直に受け取ります。ジェネレーターは値の並びを渡すだけで、配列のようにキーで一意に管理する仕組みは持っていないためです。ただし後述の iterator_to_array() で配列に変換するとキーが衝突して値が上書きされるので、そこだけは注意してください。

yield from で別のジェネレーターや配列に処理を任せる

yield from を使うと、別のジェネレーターや配列(Traversable を実装したオブジェクトも可)の中身を、そのまま自分が yield したかのように流せます。これを委譲と呼びます。処理を小さなジェネレーターに分けて組み合わせたいときに便利です。

yield-from.php
<?php
function inner(): Generator
{
    yield 1;
    yield 2;
}

function outer(): Generator
{
    yield 0;
    yield from inner();      // 別のジェネレーターに任せる
    yield from [10, 20];     // 配列もそのまま流せる
    yield 99;
}

foreach (outer() as $key => $value) {
    echo "key={$key} value={$value}" . PHP_EOL;
}

値は 0, 1, 2, 10, 20, 99 の順に流れてきます。ただしキーの出力を見ると、少し意外な結果になります。

実行結果(キー付き)
key=0 value=0
key=0 value=1
key=1 value=2
key=0 value=10
key=1 value=20
key=1 value=99

これは yield from がキーを振り直さず、委譲先が持っているキーをそのまま引き継ぐためです。inner() の中の自動連番は 0, 1 から始まりますし、配列 [10, 20] のキーも 0, 1 です。さらに委譲から戻ったあとの yield 99; は、outer() 自身の連番の続き(1)を使います。結果としてキーが重複するので、yield from を使うときはキーに意味を持たせないか、自分で明示的にキーを付けるのが安全です。

キーを気にせず値だけを順番に扱えばよい場面では、この挙動は問題になりません。「複数のファイルを順番に読む」「複数のジェネレーターを1本につなげる」といった用途で素直に使えます。

return と getReturn() で処理後の値を受け取る

ジェネレーター関数の中でも return は書けます。ただし意味が変わり、値を返すのではなくジェネレーターを終了させ、その値を「戻り値」として保存するという動きになります。保存された値は getReturn() メソッドで取り出せます。「1件ずつ処理しつつ、最後に処理した件数も知りたい」というときに使えます。

get-return.php
<?php
function eachLine(array $lines): Generator
{
    $count = 0;
    foreach ($lines as $line) {
        $count++;
        yield $line;
    }
    return $count;   // ジェネレーターの戻り値として保存される
}

$gen = eachLine(['a', 'b', 'c']);

foreach ($gen as $line) {
    echo $line . PHP_EOL;
}

echo '処理件数: ' . $gen->getReturn() . PHP_EOL;   // 処理件数: 3

注意点は、getReturn() はジェネレーターが最後まで実行し終わったあとでしか呼べないことです。foreach を回す前や、break で途中で抜けた状態で呼ぶと Exception が投げられます。

get-return-error.php
<?php
$gen = eachLine(['a', 'b', 'c']);

// まだ1件も回していない状態で呼ぶ
$gen->getReturn();
// Fatal error: Uncaught Exception:
// Cannot get return value of a generator that hasn't returned

また return; のように値を書かずに終了した場合や、関数の最後まで到達して自然に終わった場合の戻り値は null になります。ジェネレーターを途中で break することがあり得る処理では、getReturn() に頼らず、呼び出し側で件数を数えるほうが確実です。

Generator クラスの主なメソッド

ジェネレーターは Generator クラスのオブジェクトで、このクラスは Iterator インターフェイスを実装しています。foreach はこれらのメソッドを裏側で自動的に呼んでいるので普段は意識しなくてよいのですが、手動で1件ずつ進めたいときには直接使えます。

メソッド説明
current()現在停止している yield の値を返す。まだ開始していなければ最初の yield まで進めてから返す
key()現在の値に対応するキーを返す(指定していなければ自動連番)
next()次の yield まで処理を進める。戻り値はなく、値の取得は current() で行う
valid()まだ値が残っていれば true、終了していれば false を返す
send()現在の yield 式の結果として値を送り込み、次の yield まで進めてその値を返す
getReturn()return で設定された戻り値を返す。終了前に呼ぶと Exception
rewind()最初の yield まで進める。すでに先へ進んでいる場合は Exception(巻き戻しはできない)
throw()現在の yield の位置で例外を投げ、ジェネレーターの中で受け取らせる

手動で回すと、foreach が内部で何をしているかがはっきり分かります。

manual.php
<?php
function pairs(): Generator
{
    yield 'a' => 1;
    yield 'b' => 2;
    return 'done';
}

$gen = pairs();

while ($gen->valid()) {
    echo $gen->key() . ' => ' . $gen->current() . PHP_EOL;
    $gen->next();
}
// a => 1
// b => 2

echo $gen->getReturn() . PHP_EOL;   // done

send() でジェネレーターに値を送り込む

send() はやや特殊で、ジェネレーターから値を受け取るのではなく、ジェネレーターへ値を渡すためのメソッドです。関数の中で $received = yield; のように yield を式として書いておくと、send() で渡した値が $received に入ります。値を受け取り続ける「消費者」を書きたいときに使います。

send.php
<?php
function logger(): Generator
{
    while (true) {
        $line = yield;              // send() で渡された値がここに入る
        if ($line === null) {
            break;                  // null を送られたら終了
        }
        echo '[LOG] ' . $line . PHP_EOL;
    }
    return 'closed';
}

$log = logger();
$log->send('起動しました');   // 最初の yield まで自動で進んでから値が渡る
$log->send('処理中です');
$log->send(null);             // 終了させる

echo $log->getReturn() . PHP_EOL;
// [LOG] 起動しました
// [LOG] 処理中です
// closed

まだ開始していないジェネレーターに send() すると、最初の yield まで自動的に進めてから値が渡されるので、事前に current() を呼んで準備する必要はありません。とはいえ send() はコルーチンのような高度な使い方向けで、メモリ節約という目的だけなら覚えなくても困りません。

実践例:巨大なファイルを1行ずつ読む

ジェネレーターがもっとも役立つのがファイル処理です。file() や file_get_contents() はファイル全体をメモリに読み込むため、数百 MB のログや CSV では簡単にメモリ上限に達します。fopen() でハンドルを開き、fgets() で1行ずつ読んで yield すれば、ファイルサイズに関係なく一定のメモリで処理できます。

file-reader.php
<?php
function readLines(string $path): Generator
{
    $handle = fopen($path, 'r');
    if ($handle === false) {
        throw new RuntimeException("ファイルを開けません: {$path}");
    }

    try {
        $lineNo = 1;
        while (($line = fgets($handle)) !== false) {
            // 行末の改行を取り除いて渡す
            yield $lineNo++ => rtrim($line, "\r\n");
        }
    } finally {
        // break で途中終了しても必ず閉じられる
        fclose($handle);
    }
}

foreach (readLines('access.log') as $lineNo => $line) {
    if (str_contains($line, 'ERROR')) {
        echo "{$lineNo} 行目: {$line}" . PHP_EOL;
    }
}

ここで重要なのが try ... finally です。ジェネレーターは break や return で途中終了することがあり、その場合 while ループの後ろに書いた fclose() には到達しません。finally に置いておけば、途中で捨てられたジェネレーターが破棄されるときにも実行されるので、ファイルハンドルを閉じ忘れずに済みます。データベース接続や外部リソースを開くジェネレーターでも同じ書き方が有効です。

この readLines() は「1行ずつ渡す」だけの部品なので、ほかのジェネレーターと数珠つなぎにできるのも利点です。ジェネレーターを受け取ってジェネレーターを返す関数を書けば、条件で絞る・形を変えるといった処理を、メモリを増やさずに重ねられます。

pipeline.php
<?php
// 条件に合う行だけを通す
function onlyMatching(iterable $lines, string $keyword): Generator
{
    foreach ($lines as $lineNo => $line) {
        if (str_contains($line, $keyword)) {
            yield $lineNo => $line;
        }
    }
}

// タブ区切りを配列に分解する
function toColumns(iterable $lines): Generator
{
    foreach ($lines as $lineNo => $line) {
        yield $lineNo => explode("\t", $line);
    }
}

$rows = toColumns(onlyMatching(readLines('access.log'), 'ERROR'));

foreach ($rows as $lineNo => $columns) {
    // ここに来るまで、メモリ上には1行分しか載っていない
    echo $lineNo . ': ' . $columns[0] . PHP_EOL;
}

関数を3つ重ねていますが、途中で配列を作っていないのでメモリ使用量は1行分のままです。同じことを配列で書くと、絞り込み後の配列と分解後の配列がそれぞれメモリに載ります。引数の型を iterable にしておくと、ジェネレーターでも普通の配列でも受け取れるので使い回しが効きます。

範囲や無限の並びを遅延生成する

range() は指定した範囲の配列をその場で作るので、範囲が広いとメモリを消費します。同じことをジェネレーターで書けば、値は必要になった時点で計算されます。終わりのない並びも表現できるのがジェネレーターならではの点で、break で好きなところで止められます。

lazy-range.php
<?php
// range() のように振る舞うが、配列は作らない
function lazyRange(int $start, int $end, int $step = 1): Generator
{
    for ($i = $start; $i <= $end; $i += $step) {
        yield $i;
    }
}

foreach (lazyRange(0, 10, 3) as $n) {
    echo $n . ' ';
}
echo PHP_EOL;   // 0 3 6 9

// 終わりのない並びも書ける
function naturals(): Generator
{
    $i = 1;
    while (true) {
        yield $i++;
    }
}

foreach (naturals() as $n) {
    if ($n > 5) {
        break;      // 呼び出し側で止める
    }
    echo $n . ' ';
}
echo PHP_EOL;   // 1 2 3 4 5

naturals() は while (true) で無限に値を作りますが、値を要求されない限り止まったままなので無限ループにはなりません。「上限が決まっていない連番を振る」「条件を満たす値が見つかるまで試す」といった処理を素直に書けます。

ジェネレーターは巻き戻せず、数えることもできない

便利な一方で、ジェネレーターには配列にはない制約があります。値を溜め込まないという性質の裏返しなので、避けようとするのではなく「そういうものだ」と把握しておくのが大切です。

一度回したら先頭には戻せない

ジェネレーターは一方通行です。過去の値を保持していないため、rewind() で先頭に戻すことはできません。最初の yield より先に進んだ状態で rewind() を呼ぶと Exception になります。

rewind-error.php
<?php
$gen = lazyRange(1, 3);

$gen->rewind();       // 最初の yield までなので、ここはエラーにならない
$gen->next();         // 2件目へ進む
$gen->rewind();       // 進んだあとで巻き戻そうとすると…
// Fatal error: Uncaught Exception:
// Cannot rewind a generator that was already run

同じジェネレーターオブジェクトを foreach で2回回した場合も同様で、2回目は Cannot traverse an already closed generator という Exception になります。もう一度使いたいときはジェネレーター関数を呼び直して、新しいジェネレーターを作るのが正解です。

reuse.php
<?php
// NG: 同じオブジェクトを2回回す
$gen = lazyRange(1, 3);
foreach ($gen as $n) { /* ... */ }
foreach ($gen as $n) { /* Exception になる */ }

// OK: そのつど呼び直す
foreach (lazyRange(1, 3) as $n) { /* ... */ }
foreach (lazyRange(1, 3) as $n) { /* 問題なく動く */ }

ジェネレーターを変数に入れて別のメソッドへ渡すような書き方をしていると、どこかで一度回されていることに気付かないままエラーになりがちです。使い回す前提のデータならジェネレーターを持ち回さず、ジェネレーターを作る関数(あるいはクロージャ)そのものを渡すと、必要なタイミングで新しく作れるので安全です。

count() や添字アクセスは使えない

ジェネレーターは Iterator であって配列ではないので、配列向けの操作はほとんど通りません。count() に渡すと TypeError、$gen[0] のような添字アクセスは Error になります。array_map() や array_filter()、sort() といった配列関数にもそのままでは渡せません。

not-array.php
<?php
$gen = lazyRange(1, 3);

count($gen);
// TypeError: count(): Argument #1 ($value) must be of type
// Countable|array, Generator given

$gen[0];
// Error: Cannot use object of type Generator as array

件数を数えたい、あるいは並べ替えたいなど、どうしても全件を配列として扱う必要がある場合は iterator_to_array() で変換できます。ただしこの時点で全件がメモリに載るため、ジェネレーターを使った意味はなくなります。件数が確実に少ないと分かっているときだけの手段です。

to-array.php
<?php
$array = iterator_to_array(lazyRange(1, 5));
echo count($array) . PHP_EOL;   // 5

// キーが重複しうるジェネレーターは第2引数に false を渡す
// (true のままだと後の値で上書きされてしまう)
$array = iterator_to_array(outer(), false);
print_r($array);   // [0, 1, 2, 10, 20, 99] が連番のキーで入る

iterator_to_array() の第2引数($preserve_keys)は既定で true で、ジェネレーターのキーをそのまま配列のキーに使います。前述の yield from の例のようにキーが重複していると、同じキーの値が上書きされて件数が合わなくなります。値の並びだけが欲しいときは false を渡してキーを振り直させてください。

件数を数えるだけなら、配列に変換せず iterator_count() を使う手もあります。とはいえ数えるために全件を消費してしまい、そのジェネレーターは再利用できなくなるので、結局は「件数が必要な処理設計自体を見直す」ほうが健全です。前述の return と getReturn() で処理件数を返すようにしておけば、1回のループで値の処理と件数の取得を両立できます。

ジェネレーターが動かない・値が取り出せないとき

ジェネレーターは「呼んでも実行されない」という点が通常の関数と大きく違うため、慣れないうちは想定と違う動きに見えることがあります。よく詰まるところを原因ごとに見ていきます。

関数を呼んだのに中の処理が動かない

「ジェネレーター関数に echo やログ出力を入れたのに、何も出ない」というときは、戻り値を foreach などで消費していないのが原因です。ジェネレーター関数を呼んだだけでは Generator オブジェクトが返るだけで、本体は1行も実行されません。

not-running.php
<?php
function importUsers(array $rows): Generator
{
    foreach ($rows as $row) {
        // 何かの登録処理
        yield $row;
    }
}

// NG: 呼んだだけ。登録処理は1件も動かない
importUsers($rows);

// OK: 消費してはじめて動く
foreach (importUsers($rows) as $row) {
    // ...
}

これは、そもそも副作用が目的の処理をジェネレーターにしてしまったときに起こりやすい問題です。「値を返さず処理を実行するだけ」の関数なら yield は使わず、普通の関数として書くべきです。値を渡す必要がないのに yield を書くと、呼び出し側が消費を忘れた瞬間に処理ごと消えてしまいます。

「Cannot traverse an already closed generator」が出る

すでに最後まで回し終えた(あるいは閉じられた)ジェネレーターを、もう一度 foreach しようとしたときのエラーです。ログ出力用に一度ループしてから本処理でもう一度ループする、といったコードで起こります。前述のとおり、ジェネレーターオブジェクトは使い捨てなのでジェネレーター関数を呼び直すのが対処です。

2回以上ループする必要があると分かっているなら、そもそもジェネレーターが適していない可能性もあります。件数が現実的な範囲なら配列で受け取ったほうが素直ですし、大量データを2回走査したいなら、ジェネレーターを作る処理を関数やクロージャに切り出して、そのつど新しいジェネレーターを得られるようにしておきます。

getReturn() で例外が出る

Cannot get return value of a generator that hasn't returned は、ジェネレーターが終了していない状態で getReturn() を呼んだときのエラーです。foreach の中で呼んでいる、条件に合う値が見つかった時点で break している、そもそも回していない——のいずれかに該当していないか確認してください。

break の可能性がある処理では valid() で終了を確認してから呼ぶか、件数などの集計値をジェネレーターの戻り値に頼らず呼び出し側で数えるようにします。呼び出し側で数えるほうが、途中終了しても正しい値が得られるぶん扱いやすいことが多いです。

foreach のキーが重複する・上書きされる

yield from を使っているとキーが重複します。委譲先のキーがそのまま引き継がれるためで、バグではありません。キーを一意にしたいなら、委譲を使わず自分でループして yield 自前のキー => 値; と明示的に振ってください。

iterator_to_array() で配列にしたら件数が減っていた、というのも同じ原因です。キーが重複した状態で配列に変換すると、同じキーの値が後の値で上書きされます。第2引数に false を渡してキーを振り直せば全件が残ります。

ファイルハンドルや接続が閉じられない

ジェネレーターの中で fopen() したのに、ループの後ろに fclose() を書いているケースです。呼び出し側が break で抜けるとジェネレーターは yield の位置で止まったまま破棄されるので、その後ろのコードには到達しません。後始末は必ず finally に書くことで、途中終了でも確実に実行されます。同じ理由で、データベースのトランザクションのようにコミット・ロールバックが必要な処理をジェネレーターの中に置くのも避けたほうが安全です。

まとめ

ジェネレーターは、yield を1つでも含む関数を書くだけで作れます。呼び出しても本体は実行されず Generator オブジェクトが返り、foreach で消費するたびに yield の位置まで進んで一時停止します。全件を配列に溜めないため、100万件の連番でメモリ使用量が 18 MB から 2 MB まで下がったように、データ量が増えてもメモリ使用量がほとんど変わらないのが最大の利点です。yield $key => $value でキーを付けられ、yield from で別のジェネレーターや配列に委譲でき、return で設定した値は終了後に getReturn() で受け取れます。

一方で、ジェネレーターは一方通行で巻き戻せません。一度回したら rewind() では戻せず、同じオブジェクトを2回 foreach することもできないので、使い回したいときは関数を呼び直します。count() や添字アクセスもできず、どうしても配列が必要なら iterator_to_array() を使いますが、その時点でメモリの利点は失われます。ファイルやリソースを開くジェネレーターでは、後始末を finally に置くことも忘れないでください。巨大な CSV やログの処理、件数の分からないデータの走査といった場面で、まずは fopen と fgets を組み合わせた1行ずつの読み込みから試してみてください。

参考ページ