LazyCollection備忘録

かなり今更な感じはしますが、最近LaravelのLazyCollectionをよく使うことがあり、改めて整理しておきたくなったので書き連ねてみたいと思います。ほぼ個人的な備忘録的なものになってしまったため無駄に長いかもしれませんが、参考になる方がいたら嬉しいです。

## これは何か
マニュアル通りの説明になってしまいますが、Laravelの`Collection`の遅延版で、元々PHPには[Generator](https://www.php.net/manual/ja/language.generators.overview.php)というものがありましたが、`LazyCollection`はそれをLaravelの`Collection`風に使えるようにしたものになります。

## どんな順番で評価されるか
まずはtinker上で簡単なサンプルで確認してみます。同一値を返す`map`、3より大きな数値に`filter`、10掛ける`map`。

```php
$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の場合
全ての要素が上から順番に評価されていきます。結果もすぐ出力されました。

```php
>>> $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に要素を追加してみます。こちらも普通に直感的な結果が返ります。

```php
>>> $case1->concat([111]);
=> Illuminate\Support\Collection {#6269
     all: [
       3 => 40,
       4 => 50,
       5 => 111,
     ],
   }
```

### lazyの場合
LazyCollectionの場合は、まずこの時点では計算結果が返らず、中にClosureを持った`LazyCollection`が返ってきます。

```php
>>> $case2 = $createCase($lazyCollection);
=> Illuminate\Support\LazyCollection {#6277
     +source: Closure() {#6274 …4},
   }
```

要素の追加をしても同じです。

```php
>>> $case2->concat([111]);
=> Illuminate\Support\LazyCollection {#6271
     +source: Closure() {#6272 …4},
   }
```

ではこのLazyCollectionを`collect`メソッドで通常のCollectionに変化させ、Closureを評価してみます。eagerの時とだいぶ違います。まず最初の要素が必要となるためにその値が取得され、次に最初のmapが評価され、filterされます。最初の要素の`1`はfilter処理で弾かれますから、ここで次の処理には回らず、同様に今度は2要素目が取得…以下同様、という形になります。

```php
>>> $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])`が追加されてません。代入してなかったから当然だということで、改めて試してみると、

```php
>>> $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で表示してみます

```php
$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つずつの配列に分割されてます。

```php
>>> $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で。

```php
>>> $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`が返されてます。なのでここから評価すると

```php
>>> $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`というメソッドが用意されてます。

```php
$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()}");
        });
};
```

これで先ほどと同じように書いてみると

```php
>>> $case4 = $createCase2($lazyCollection);
=> Illuminate\Support\LazyCollection {#6317
     +source: Closure() {#6315 …4},
   }
```

評価されません。評価をすれば、同じ結果が出力されます。

```php
>>> $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な処理を中で行うからなんでしょうけど、こういう書き方ができるとマニュアルにはあります。

```php
$tapEachCase = $lazyCollection
    ->map(function ($i) {
        dump("map {$i}");
        return $i * 2;
    })
    ->tapEach(function ($i) {
        dump("each {$i}");
    })
    ->take(3);
```

これを評価すると、

```php
>>> $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`の何が困るのか？というと、次のように要素数が大量になった際のケースです。

```php
$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万の要素全てに対して偶数判定がされてしまいます。当然処理時間もかさみます。

```php
>>> $case5 = $createCase3($hugeEagerCollection);
^ "filter 1"
^ "filter 2"
^ "filter 3"
^ "filter 4"
...
^ "filter 1000000"
```

遅延されてると結果に必要な数だけ処理して終了します。即座に結果が返ってきます。というよりも、遅延されてるリストというのはリスト生成時に要素数分の値は存在していません。処理に必要になって、初めてLazyCollection生成時に定義された`yield`呼び出しが行われ要素の値が発生することになります。

```php
>>> $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,
     ],
   }
>>> 
```

更にこういう無限リストでも、

```php
$infiniteLazyCollection = \Illuminate\Support\LazyCollection::make(function () {
    $i = 0;
    while(true) {
        yield ++$i;
    }
});
```

全く問題なく即答してくれます。これは遅延リストでないと実現できません。

```php
>>> $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`ではこのように結果として単一の数値だけを求めたい場合でも、

```php
>>> 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⏎
```

のように元となるリスト要素のサイズによってメモリを消費しますが、

```php
>>> 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のこの箇所](https://mitpress.mit.edu/sites/default/files/sicp/full-text/book/book-Z-H-24.html#%_sec_3.5)などが有名のようです。ここでは前段でプログラミングのモデルとして、処理プロセス中の状態変化を変数への代入によって解決するモデルが紹介されますが、そのモデルでは意図しない値の変化を防ぐために、特に非同期処理時には処理順序を意識した複雑で不具合を生みやすい記述が必要になってしまうという問題提起がされます。そして、代入とは違うアプローチとして、状態変化を無限リストとして表現するモデルが紹介されてます。無限リストはそのままでは現実的ではありませんが、その評価を遅延させることによって実際に計算機上で利用可能な形になります。

プログラミングで状態をどのように扱うか、というのは今でも代入に関連した不具合で皆が頭を悩ませる大きな問題の一つですが、`Stream`によって代入を使わずに状態変化を管理するアプローチは面白いと思いました。ここでは詳細に扱えませんが先ほどのSICPの章は一読の価値があるかと思います。


## 応用例（データベース（MySQL）関連）
Laravelの`LazyCollection`でよく登場する利用例は、DBから大量データを検索した際に使われるケースでしょう。

以前からLaravelのQuery Builderには`cursor`というメソッドがあり、これはPHPの`Generator`を返すようになってました。これが6.xで`LazyCollection`を返すように変わりました。

試してみます。まずは大量データを作らないといけないのでこんな感じで100万件レコードのテーブルを用意します。データ内容もそこそこボリュームを持つように適当に詰め込みます。

```php
>>> \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件取得してみます。

```php
>>> 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`で試してみます。

```php
>>> 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⏎
```
かなり使用メモリ節約できているのが分かります。もちろん通常は

```php
>>> 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`のカラムだけ取得してましたが、全カラム取ってみて比較してみます。

```php
>>> echo (memory_get_usage(true) / 1024 / 1024).'MB';
46.25MB⏎
>>> \DB::table('test.test')->get()->take(10)->pluck('id');
```

これは私のPC上では結果出る前にtinkerが落ちてしまいました。では`cursor`ではどうでしょうか。

```php
>>> 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](https://github.com/laravel/framework/issues/14919)

通常はクエリ発行時にデフォルトで`PDO::MYSQL_ATTR_USE_BUFFERED_QUERY`が`true`になっている、つまりクエリ結果がPHPプロセスのメモリ上に即座に載ってしまう、そういう設定になってます。

[バッファクエリと非バッファクエリ](https://www.php.net/manual/ja/mysqlinfo.concepts.buffering.php)

なので、もしこのモードをOFFにしたければ明示的にそう設定しないといけません。

```php
>>> 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に記載](https://github.com/laravel/framework/issues/14919#issuecomment-472911414)があるように、デフォルトのバッファクエリなコネクション定義とは別に、非バッファクエリ用のコネクション定義を追加して、処理の中で用途別に使い分ける、という形になりそうです。

`cursor`というメソッド名からしてDBのカーソル、つまりデータの位置情報だけが返ってきて、レコード取得のiterationによって順次実データがメモリ上に持ってこられるように思い込んでしまいかねない（私は最初そう思ってました）ので、注意が必要かと思います。

### cursorは評価時にSQLが発行される
通常の`Collection`型のクエリ結果はBuilderの`get`を呼び出した時に取得され、そこでSQLが発行されますが、`cursor`を呼び出したときにはSQLは発行されてません。

発行されたSQLを表示させるようにして見てみます。

```php
>>> \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},
   }
```
クエリは実行されてません。この後に評価をしてみると、

```php
>>> $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`とは違った評価順になるなど最初慣れが必要かとは思いますが、積極的に使っていきたい機能だと思いました。