PHPのジェネレータについて調べた件
概要と前説
- PHPでアウトオブメモリーエラーが出る場合に、メモリ節約的な書き方として有用
- ドライバ次第だが、DBカーソルでジェネレータ対応しているものがある
- 見た目的には直感的では無くなるかも(実行タイミングがズレて見える)
とりあえず細かい話は下記を参照
下記の2つの記事を読めば、大体理解できる。
(英語記事だけどコード多いから気にならない)
ちょっとぱっと見、とっつきづらいですが、まぁ慣れればなんともない。
不便な点と解決するライブラリ
PHP標準ライブラリのarray_mapなどと組み合わせて使えないので不便ではある。
一応、ジェネレータ対応のUtilライブラリがあるので、そちらを使えば相当の実装はできる。
(記事の方でも実装例があるから、自分で使う分だけ実装するのもあり)
ジェネレータ用のUtilライブラリ
コード例
下記は、全て 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が全部終わってから処理をしてもすぐに終わる。
ただし、1.が巨大なデータだとメモリに入らずアウトオブメモリーになってしまう。
なので、ストリーミング処理のイメージで、
(Unixコマンドなどではパイプを組み合わせて実行するイメージでも可)
- 少し読み込み
- 何か処理
- 終わった分ファイル書き出し
- 1.に戻って全部読み込み終わるまで繰り返す
とやっていけば、メモリ消費量を削減できるというイメージ。
1度に読み込まないことが肝となる。
じゃあ、Webシステムだとどうなるのか?
Webシステムの場合
あまり変わらない。
- DB or APIなどから少しずつ読み込み
- 何か処理
- htmlやjsonの書き出し(ブラウザ側にストリーミングで渡していくイメージ)
- 1.に戻って全部読み込み終わるまで繰り返す
調べてないが、htmlは少し生成して渡すといったことはできそう。
だが、jsonの場合、言語のデータ形式から、jsonに変換するところでメモリに入ると思う。。。
Laravelで使う場合
データの起点はDBなので、まずEloquentで少しずつ読み込める必要がある。
Eloquentのドキュメント
カーソルか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