かなり今更な感じはしますが、最近LaravelのLazyCollectionをよく使うことがあり、改めて整理しておきたくなったので書き連ねてみたいと思います。ほぼ個人的な備忘録的なものになってしまったため無駄に長いかもしれませんが、参考になる方がいたら嬉しいです。
これは何か
マニュアル通りの説明になってしまいますが、LaravelのCollection
の遅延版で、元々PHPにはGeneratorというものがありましたが、LazyCollection
はそれをLaravelのCollection
風に使えるようにしたものになります。
どんな順番で評価されるか
まずはtinker上で簡単なサンプルで確認してみます。同一値を返すmap
、3より大きな数値にfilter
、10掛けるmap
。
$eagerCollection = \Illuminate\Support\Collection::times(5);
$lazyCollection = \Illuminate\Support\LazyCollection::times(5);
$createCase = function ($collection) {
return $collection
->map(function ($i) {
dump("map const {$i}");
return $i;
})
->filter(function ($i) {
dump("filter {$i}");
return $i > 3;
})
->map(function ($i) {
dump("map {$i}");
return $i * 10;
});
};
eagerの場合
全ての要素が上から順番に評価されていきます。結果もすぐ出力されました。
>>> $case1 = $createCase($eagerCollection);
^ "map const 1"
^ "map const 2"
^ "map const 3"
^ "map const 4"
^ "map const 5"
^ "filter 1"
^ "filter 2"
^ "filter 3"
^ "filter 4"
^ "filter 5"
^ "map 4"
^ "map 5"
=> Illuminate\Support\Collection {#6287
all: [
3 => 40,
4 => 50,
],
}
結果Collectionに要素を追加してみます。こちらも普通に直感的な結果が返ります。
>>> $case1->concat([111]);
=> Illuminate\Support\Collection {#6269
all: [
3 => 40,
4 => 50,
5 => 111,
],
}
lazyの場合
LazyCollectionの場合は、まずこの時点では計算結果が返らず、中にClosureを持ったLazyCollection
が返ってきます。
>>> $case2 = $createCase($lazyCollection);
=> Illuminate\Support\LazyCollection {#6277
+source: Closure() {#6274 …4},
}
要素の追加をしても同じです。
>>> $case2->concat([111]);
=> Illuminate\Support\LazyCollection {#6271
+source: Closure() {#6272 …4},
}
ではこのLazyCollectionをcollect
メソッドで通常のCollectionに変化させ、Closureを評価してみます。eagerの時とだいぶ違います。まず最初の要素が必要となるためにその値が取得され、次に最初のmapが評価され、filterされます。最初の要素の1
はfilter処理で弾かれますから、ここで次の処理には回らず、同様に今度は2要素目が取得…以下同様、という形になります。
>>> $case2->collect()
^ "map const 1"
^ "filter 1"
^ "map const 2"
^ "filter 2"
^ "map const 3"
^ "filter 3"
^ "map const 4"
^ "filter 4"
^ "map 4"
^ "map const 5"
^ "filter 5"
^ "map 5"
=> Illuminate\Support\Collection {#6271
all: [
3 => 40,
4 => 50,
],
}
上のでなんか違うと思ったら、$case2->concat([111])
が追加されてません。代入してなかったから当然だということで、改めて試してみると、
>>> $case2->concat([111])->collect()
^ "map const 1"
^ "filter 1"
^ "map const 2"
^ "filter 2"
^ "map const 3"
^ "filter 3"
^ "map const 4"
^ "filter 4"
^ "map 4"
^ "map const 5"
^ "filter 5"
^ "map 5"
=> Illuminate\Support\Collection {#6255
all: [
40,
50,
111,
],
}
ちゃんと末尾に追加されました。
eachとtapEach
今度は途中でchunkしたものをeachで表示してみます
$eagerCollection = \Illuminate\Support\Collection::times(10);
$lazyCollection = \Illuminate\Support\LazyCollection::times(10);
$createCase2 = function ($collection) {
return $collection
->filter(function ($i) {
dump("filter {$i}");
return $i % 2 === 0;
})
->values()
->map(function ($i) {
dump("map const {$i}");
return $i;
})
->chunk(3)
->each(function ($chunked) {
dump("each {$chunked->values()}");
});
};
まずはeager。特に気になるところはありません。1から10までの偶数が選択され、3つずつの配列に分割されてます。
>>> $case3 = $createCase2($eagerCollection);
^ "filter 1"
^ "filter 2"
^ "filter 3"
^ "filter 4"
^ "filter 5"
^ "filter 6"
^ "filter 7"
^ "filter 8"
^ "filter 9"
^ "filter 10"
^ "map const 2"
^ "map const 4"
^ "map const 6"
^ "map const 8"
^ "map const 10"
^ "each [2,4,6]"
^ "each [8,10]"
=> Illuminate\Support\Collection {#6311
all: [
Illuminate\Support\Collection {#6308
all: [
2,
4,
6,
],
},
Illuminate\Support\Collection {#6310
all: [
3 => 8,
4 => 10,
],
},
],
}
次はlazyで。
>>> $case4 = $createCase2($lazyCollection);
^ "filter 1"
^ "filter 2"
^ "map const 2"
^ "filter 3"
^ "filter 4"
^ "map const 4"
^ "filter 5"
^ "filter 6"
^ "map const 6"
^ "each [2,4,6]"
^ "filter 7"
^ "filter 8"
^ "map const 8"
^ "filter 9"
^ "filter 10"
^ "map const 10"
^ "each [8,10]"
=> Illuminate\Support\LazyCollection {#6304
+source: Closure() {#6302 …4},
}
あれ?まだ評価したつもりないのに結果以外はされちゃってますね。each
というのはそこで評価が走るみたいです、といってもforeach
的なものなのでそういうものなんでしょうけども。ただこれは戻り値はvoidではなくLazyCollection
が返されてます。なのでここから評価すると
>>> $case4->collect()
^ "filter 1"
^ "filter 2"
^ "map const 2"
^ "filter 3"
^ "filter 4"
^ "map const 4"
^ "filter 5"
^ "filter 6"
^ "map const 6"
^ "filter 7"
^ "filter 8"
^ "map const 8"
^ "filter 9"
^ "filter 10"
^ "map const 10"
=> Illuminate\Support\Collection {#6261
all: [
Illuminate\Support\LazyCollection {#6298
+source: [
2,
4,
6,
],
},
Illuminate\Support\LazyCollection {#6301
+source: [
3 => 8,
4 => 10,
],
},
],
}
こうなります。each
を含むとそこで一旦評価されちゃうのに少し違和感がありましたが、Collectionではmap
やreduce
などで各種ループ処理を行う一方でeach
のような手続き的なメソッドがあるのがちょっと矛盾していると感じたからかも知れないです。
こういったことに対応するためなのか、LazyCollection
にだけtapEach
というメソッドが用意されてます。
$createCase2 = function ($collection) {
return $collection
->filter(function ($i) {
dump("filter {$i}");
return $i % 2 === 0;
})
->values()
->map(function ($i) {
dump("map const {$i}");
return $i;
})
->chunk(3)
->tapEach(function ($chunked) {
dump("each {$chunked->values()}");
});
};
これで先ほどと同じように書いてみると
>>> $case4 = $createCase2($lazyCollection);
=> Illuminate\Support\LazyCollection {#6317
+source: Closure() {#6315 …4},
}
評価されません。評価をすれば、同じ結果が出力されます。
>>> $case4->collect();
^ "filter 1"
^ "filter 2"
^ "map const 2"
^ "filter 3"
^ "filter 4"
^ "map const 4"
^ "filter 5"
^ "filter 6"
^ "map const 6"
^ "each [2,4,6]"
^ "filter 7"
^ "filter 8"
^ "map const 8"
^ "filter 9"
^ "filter 10"
^ "map const 10"
^ "each [8,10]"
=> Illuminate\Support\Collection {#6269
all: [
Illuminate\Support\LazyCollection {#6253
+source: [
2,
4,
6,
],
},
Illuminate\Support\LazyCollection {#6264
+source: [
3 => 8,
4 => 10,
],
},
],
}
tapEach
はLazyCollection
のためのメソッドで、通常のCollection
は持ってません。map
と何が違うのか、というのは基本的にこれはvoidな処理を中で行うからなんでしょうけど、こういう書き方ができるとマニュアルにはあります。
$tapEachCase = $lazyCollection
->map(function ($i) {
dump("map {$i}");
return $i * 2;
})
->tapEach(function ($i) {
dump("each {$i}");
})
->take(3);
これを評価すると、
>>> $tapEachCase->collect();
^ "map 1"
^ "each 2"
^ "map 2"
^ "each 4"
^ "map 3"
^ "each 6"
=> Illuminate\Support\Collection {#6230
all: [
2,
4,
6,
],
}
ということで、最後の->take(3)
の数しか各種Collectionメソッドが走りません。これをeach
にしてしまうと定義したときにすぐ評価されてしまうため、$lazyCollection
の要素数だけ処理が走ってしまいます。個人的には何で each
的な処理、つまり値を返さないはずの処理の後にtake
が指定できるんだろう?と思いますけど、便利なのかも知れないです。
何で必要なのか
処理コストの抑制
長々と事例を並べてきましたが、遅延リストはこのようにリストの要素を必要に応じて取り出し処理する仕組みです。通常のCollection
では今まで見てきた通り全ての要素に対して処理がされます。
通常のCollection
の何が困るのか?というと、次のように要素数が大量になった際のケースです。
$hugeEagerCollection = \Illuminate\Support\Collection::times(1000000);
$hugeLazyCollection = \Illuminate\Support\LazyCollection::times(1000000);
$createCase3 = function ($collection) {
return $collection
->filter(function ($i) {
dump("filter {$i}");
return $i % 2 === 0;
})
->take(3);
};
このような場合、遅延されていないリストの場合は100万の要素全てに対して偶数判定がされてしまいます。当然処理時間もかさみます。
>>> $case5 = $createCase3($hugeEagerCollection);
^ "filter 1"
^ "filter 2"
^ "filter 3"
^ "filter 4"
...
^ "filter 1000000"
遅延されてると結果に必要な数だけ処理して終了します。即座に結果が返ってきます。というよりも、遅延されてるリストというのはリスト生成時に要素数分の値は存在していません。処理に必要になって、初めてLazyCollection生成時に定義されたyield
呼び出しが行われ要素の値が発生することになります。
>>> $case6 = $createCase3($hugeLazyCollection);
=> Illuminate\Support\LazyCollection {#6256
+source: Closure() {#6261 …4},
}
>>> $case6->collect();
^ "filter 1"
^ "filter 2"
^ "filter 3"
^ "filter 4"
^ "filter 5"
^ "filter 6"
=> Illuminate\Support\Collection {#6254
all: [
1 => 2,
3 => 4,
5 => 6,
],
}
>>>
更にこういう無限リストでも、
$infiniteLazyCollection = \Illuminate\Support\LazyCollection::make(function () {
$i = 0;
while(true) {
yield ++$i;
}
});
全く問題なく即答してくれます。これは遅延リストでないと実現できません。
>>> $case7 = $createCase3($infiniteLazyCollection);
=> Illuminate\Support\LazyCollection {#6249
+source: Closure() {#6248 …4},
}
>>> $case7->collect();
^ "filter 1"
^ "filter 2"
^ "filter 3"
^ "filter 4"
^ "filter 5"
^ "filter 6"
=> Illuminate\Support\Collection {#6235
all: [
1 => 2,
3 => 4,
5 => 6,
],
}
更に、メモリコストの面でもメリットがあります。
通常のCollection
ではこのように結果として単一の数値だけを求めたい場合でも、
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
46.25MB⏎
>>> $hugeEagerCollection = \Illuminate\Support\Collection::times(1000000);
>>> $createCase4 = function ($collection) {
... return $collection
... ->filter(function ($i) {
... return $i % 2 === 0;
... })
... ->reduce(function ($acc, $i) {
... return $acc + 1;
... }, 0);
... };
=> Closure($collection) {#6265 …2}
>>> $case7 = $createCase4($hugeEagerCollection);
=> 500000
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
80.25390625MB⏎
のように元となるリスト要素のサイズによってメモリを消費しますが、
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
46.25MB⏎
>>> $hugeLazyCollection = \Illuminate\Support\LazyCollection::times(1000000);
=> Illuminate\Support\LazyCollection {#6247
+source: Closure() {#6242 …2},
}
>>> $createCase4 = function ($collection) {
... return $collection
... ->filter(function ($i) {
... return $i % 2 === 0;
... })
... ->reduce(function ($acc, $i) {
... return $acc + 1;
... }, 0);
... };
=> Closure($collection) {#6266 …2}
>>> $case8 = $createCase4($hugeLazyCollection);
=> 500000
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
48.25MB⏎
遅延されてれば要素数が大きくてもメモリ消費を抑えることができます。
Stream
少し大きな観点で、遅延リストは関数型プログラミングの文脈でStream
という名前で登場することがありました(今でもあるんでしょうか)。SICPのこの箇所などが有名のようです。ここでは前段でプログラミングのモデルとして、処理プロセス中の状態変化を変数への代入によって解決するモデルが紹介されますが、そのモデルでは意図しない値の変化を防ぐために、特に非同期処理時には処理順序を意識した複雑で不具合を生みやすい記述が必要になってしまうという問題提起がされます。そして、代入とは違うアプローチとして、状態変化を無限リストとして表現するモデルが紹介されてます。無限リストはそのままでは現実的ではありませんが、その評価を遅延させることによって実際に計算機上で利用可能な形になります。
プログラミングで状態をどのように扱うか、というのは今でも代入に関連した不具合で皆が頭を悩ませる大きな問題の一つですが、Stream
によって代入を使わずに状態変化を管理するアプローチは面白いと思いました。ここでは詳細に扱えませんが先ほどのSICPの章は一読の価値があるかと思います。
応用例(データベース(MySQL)関連)
LaravelのLazyCollection
でよく登場する利用例は、DBから大量データを検索した際に使われるケースでしょう。
以前からLaravelのQuery Builderにはcursor
というメソッドがあり、これはPHPのGenerator
を返すようになってました。これが6.xでLazyCollection
を返すように変わりました。
試してみます。まずは大量データを作らないといけないのでこんな感じで100万件レコードのテーブルを用意します。データ内容もそこそこボリュームを持つように適当に詰め込みます。
>>> \DB::statement('create database test');
=> true
>>> \DB::statement('create table test.test (id int auto_increment, hoge varchar(512), fuga varchar (512), hogefuga varchar (512), primary key(id))');
=> true
>>>
>>> foreach (range(1, 10000) as $i) {
... $values = collect(range(1, 100))
... ->map(function ($i) {
... return [
... 'hoge' => \Illuminate\Support\Str::random(512),
... 'fuga' => \Illuminate\Support\Str::random(512),
... 'hogefuga' => \Illuminate\Support\Str::random(512)
... ];
... });
... \DB::table('test.test')->insert($values->all());
... }
getとcursorの比較
ではQuery Builder使って検索してみましょう。先ほどと同じように、使用メモリを見てみます。まずはcursor
使わないで全件取ってからid
カラムのリストを10件取得してみます。
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
46.25MB⏎
>>> \DB::table('test.test')->select('id')->get()->take(10)->pluck('id');
=> Illuminate\Support\Collection {#6251
all: [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
],
}
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
486.25MB⏎
id
だけですけど、全件取得しているので結構使いますね。
次にcursor
で試してみます。
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
46.25MB⏎
>>> \DB::table('test.test')->select('id')->cursor()->take(10)->pluck('id')->collect();
=> Illuminate\Support\Collection {#6245
all: [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
],
}
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
78.25MB⏎
かなり使用メモリ節約できているのが分かります。もちろん通常は
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
46.25MB⏎
>>> \DB::table('test.test')->select('id')->take(10)->get()->pluck('id');
=> Illuminate\Support\Collection {#6242
all: [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
],
}
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
48.25MB⏎
こうやって全件取らないでクエリ側で絞るでしょう。ですが、Collection
やLazyCollection
にしてしまえば取得した各要素に対してSQLでは難しい複雑な計算やフィルタ処理などができます。
cursorは本当に軽い?
では、先ほどは控えめにSQL部分でid
のカラムだけ取得してましたが、全カラム取ってみて比較してみます。
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
46.25MB⏎
>>> \DB::table('test.test')->get()->take(10)->pluck('id');
これは私のPC上では結果出る前にtinkerが落ちてしまいました。ではcursor
ではどうでしょうか。
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
46.25MB⏎
>>> \DB::table('test.test')->cursor()->take(10)->pluck('id')->collect();
=> Illuminate\Support\Collection {#6232
all: [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
],
}
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
1786.25MB⏎
返ってきました。ですが、LazyCollection
は大量の要素があっても必要な数だけ処理するはずなのに、ここではずいぶんメモリを使ってしまってるように見えます。
実はcursor
メソッドはデフォルトでlazyにデータベースのレコード値を順次取得していくLazyCollection
を返しているわけではありません。これはLaravelのIssueでも上がっていましたが、
Using Cursor on large number of results causing memory issues #14919
通常はクエリ発行時にデフォルトでPDO::MYSQL_ATTR_USE_BUFFERED_QUERY
がtrue
になっている、つまりクエリ結果がPHPプロセスのメモリ上に即座に載ってしまう、そういう設定になってます。
なので、もしこのモードをOFFにしたければ明示的にそう設定しないといけません。
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
46.25MB⏎
>>> \DB::connection()->getPdo()->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
=> true
>>> \DB::table('test.test')->cursor()->take(10)->pluck('id')->collect();
=> Illuminate\Support\Collection {#6238
all: [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
],
}
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
48.25MB⏎
全くメモリ使用量が違うのが分かります。
何でこの設定を使わないのか?というのは先ほどのリンク先にも記載がありますが、非バッファクエリのモードの場合、まずデータ取得が終わるまでDBサーバー上に結果データがあり、かつそれを順次取得していくということでDB負荷が上がるという問題があります。これはすぐ想像できることですが、更に結果データ取得をしている間、同一コネクションを使って別のクエリを実行することができない、という欠点があります。これではデータ取得をしながらそのデータを元に別のDB関連処理をする、ということはできない、という話になってしまいます。
なので、実際に非バッファクエリを使う場合は上のLaravelのIssueに記載があるように、デフォルトのバッファクエリなコネクション定義とは別に、非バッファクエリ用のコネクション定義を追加して、処理の中で用途別に使い分ける、という形になりそうです。
cursor
というメソッド名からしてDBのカーソル、つまりデータの位置情報だけが返ってきて、レコード取得のiterationによって順次実データがメモリ上に持ってこられるように思い込んでしまいかねない(私は最初そう思ってました)ので、注意が必要かと思います。
cursorは評価時にSQLが発行される
通常のCollection
型のクエリ結果はBuilderのget
を呼び出した時に取得され、そこでSQLが発行されますが、cursor
を呼び出したときにはSQLは発行されてません。
発行されたSQLを表示させるようにして見てみます。
>>> \DB::listen(function($q) {
... dump($q->sql);
... });
=> null
>>> $recordIds = \DB::table('test.test')->cursor()->take(3)->pluck('id');
=> Illuminate\Support\LazyCollection {#6230
+source: Closure() {#6231 …4},
}
クエリは実行されてません。この後に評価をしてみると、
>>> $recordIds->collect();
^ "select * from `test`.`test`"
=> Illuminate\Support\Collection {#6235
all: [
1,
2,
3,
],
}
>>> $recordIds->collect();
^ "select * from `test`.`test`"
=> Illuminate\Support\Collection {#6232
all: [
1,
2,
3,
],
}
評価の度にクエリが実行されてます。
先ほど述べたように、cursor
はデフォルト設定ではデータを全てメモリ上に持ってきてしまってる、ということなんですけど、評価の度にそれを繰り返すんですね。ちょっとここも変かな、と感じましたがどうなんでしょうか。
最後に
DBでの利用例は少し注意が必要な感じではありましたが、今までだとCollectionの要素数を常に意識しながら各種リスト操作をしたり、大量データを扱う場合は適宜分割処理をするよう実装に工夫が必要でしたが、LazyCollection
使うことで非常に簡潔に書くことが可能になります。
リスト操作はプログラミングの最も基本的な要素ですので、LazyCollection
の出番は多いかと思います。通常のCollection
とは違った評価順になるなど最初慣れが必要かとは思いますが、積極的に使っていきたい機能だと思いました。