24
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

OPENLOGIAdvent Calendar 2020

Day 11

LazyCollection備忘録

Last updated at Posted at 2020-12-10

かなり今更な感じはしますが、最近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ではmapreduceなどで各種ループ処理を行う一方で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,
         ],
       },
     ],
   }

tapEachLazyCollectionのためのメソッドで、通常の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

こうやって全件取らないでクエリ側で絞るでしょう。ですが、CollectionLazyCollectionにしてしまえば取得した各要素に対して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_QUERYtrueになっている、つまりクエリ結果が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とは違った評価順になるなど最初慣れが必要かとは思いますが、積極的に使っていきたい機能だと思いました。

24
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?