😸

PHPのジェネレータについて調べた件

2024/03/27に公開

概要と前説

  • PHPでアウトオブメモリーエラーが出る場合に、メモリ節約的な書き方として有用
  • ドライバ次第だが、DBカーソルでジェネレータ対応しているものがある
  • 見た目的には直感的では無くなるかも(実行タイミングがズレて見える)

とりあえず細かい話は下記を参照

下記の2つの記事を読めば、大体理解できる。
(英語記事だけどコード多いから気にならない)

https://doeken.org/blog/generators-over-arrays
https://medium.com/ifixit-engineering/functional-programming-with-php-generators-837a6c91b0e3

ちょっとぱっと見、とっつきづらいですが、まぁ慣れればなんともない。

不便な点と解決するライブラリ

PHP標準ライブラリのarray_mapなどと組み合わせて使えないので不便ではある。

一応、ジェネレータ対応のUtilライブラリがあるので、そちらを使えば相当の実装はできる。
(記事の方でも実装例があるから、自分で使う分だけ実装するのもあり)

ジェネレータ用のUtilライブラリ
https://github.com/nikic/iter

コード例

下記は、全て 123 と標準出力する。

// ジェネレータでは無い書き方(配列を作成して出力する)
function createArr()
{
    return [1, 2, 3];
}
$arr = createArr();
foreach ($arr as $value) {
    echo $value;
}

// ジェネレータ1(シンプルなパターン)
function generator1()
{
    yield 1;
    yield 2;
    yield 3;
}
$generator = generator1(); // ここの時点では実行されない
foreach ($generator as $value) { // ここのforeachで、1行ずつ実行される
    echo $value;
}

// ジェネレータ2(for文バージョン)
function generator2()
{
    for ($i = 1; $i <= 3; $i++) {
        yield $i;
    }
}
foreach (generator2() as $value) {
    echo $value;
}

ジェネレータのざっくり説明

  1. ファイル読み込み
  2. 何か処理
  3. ファイル書き出し

上記の処理をやる場合、小さいファイルだと1が全部終わってから処理をしてもすぐに終わる。

ただし、1.が巨大なデータだとメモリに入らずアウトオブメモリーになってしまう。

なので、ストリーミング処理のイメージで、
(Unixコマンドなどではパイプを組み合わせて実行するイメージでも可)

  1. 少し読み込み
  2. 何か処理
  3. 終わった分ファイル書き出し
  4. 1.に戻って全部読み込み終わるまで繰り返す

とやっていけば、メモリ消費量を削減できるというイメージ。

1度に読み込まないことが肝となる。

じゃあ、Webシステムだとどうなるのか?

Webシステムの場合

あまり変わらない。

  1. DB or APIなどから少しずつ読み込み
  2. 何か処理
  3. htmlやjsonの書き出し(ブラウザ側にストリーミングで渡していくイメージ)
  4. 1.に戻って全部読み込み終わるまで繰り返す

調べてないが、htmlは少し生成して渡すといったことはできそう。

だが、jsonの場合、言語のデータ形式から、jsonに変換するところでメモリに入ると思う。。。

Laravelで使う場合

データの起点はDBなので、まずEloquentで少しずつ読み込める必要がある。

Eloquentのドキュメント
https://laravel.com/docs/11.x/eloquent#cursors

カーソルかlazyを使うとできそう。

カーソルではジェネレータを返すらしいので、少しずつの読み込みができる。

あとは、処理の部分もジェネレータで動くようにして、Laravel側の最後の処理に渡せればうまくいく。

使用イメージは下記。

$generator = Flight::where('destination', 'Zurich')->cursor();
foreach ($generator as $flight) {
    // ...
}

最後に実際の実装例

PHPだけで動かす場合のサンプル

// generatorでmap処理を行うUtil関数
function iterator_map(callable $cb, iterable $itr): iterable {
    foreach ($itr as $key => $value) {
        yield $cb($value, $key);
    }
}

function generator1()
{
    yield 1;
    yield 2;
    yield 3;
}
$generator2 = iterator_map(function ($value) {
    return $value * 2; // 2倍する
}, generator1());

foreach ($generator2 as $value) { // generator1, generator2の処理が1行ずつ実行される
    echo $value; // echo out: 246
}

Eloquentのカーソルを使う場合のサンプル

$generator1 = Flight::where('destination', 'Zurich')->cursor();
$generator2 = iterator_map(function ($row) {
    return $row; // 何か処理
}, $generator1);
foreach ($generator2 as $value) {
}

感想

そこまで大きくコードイメージを損なわずに、関数型っぽい書き方もできるようになるなぁと。

あと、10%ぐらい処理速度が上がったので、メモリだけでなく処理効率も上がる。

ただ、ジェネレータに慣れてないメンバーが多い場合、通常の書き方をしておいたほうが良いのでは?とも思った。

使うにしても、メモリ節約したい箇所だけなど、局所的に使うと良いかと。

Discussion