PHPのコレクションライブラリ Knapsack を使ってみる

PHPのコレクションライブラリ Knapsack を使ってみる

今日は僕が普段中規模以上のPJで使っている、PHPのコレクションライブラリ Knapsack の紹介をします。

特徴

Knapsackの主な特徴は、

  • コレクション操作用の高階関数が豊富
  • イミュータブル
  • 全てが遅延評価
  • iterable、またはこれを返すcallableは全て受け入れ可能

の2点だと思います。
他にも些細な事ですが、主要な機能を提供するメソッドは全てTraitで提供されていて、更に実装の実態自体は関数として提供されているので、使いたい機能だけを切り出して自分のクラスやロジックに組み込める点と言った点が面白いです。

さて、上記に挙げた4つの特徴について、昨今の開発シーンにおいては今更特筆すべき事ではないかもしれませんが、1つ1つ紹介していきたいと思います。

コレクション操作用の高階関数が豊富

Knapsackのコレクションは、通常であれば \DusanKasan\Knapsack\Collection クラスを使います。
以下のようにコンストラクタか、::from() メソッドにソースとなるiterable(かiterableを返すcallable)な値を入れる事で使用が可能です。

$collectionA = new DusanKasan\Knapsack\Collection(range(0, 100));
$collectionB = DusanKasan\Knapsack\Collection::from(range(0, 100)); // 同じ

// 実は range と言うヘルパーメソッドも存在する
$collectionC = DusanKasan\Knapsack\Collection::range(0, 100); // これも👆2つと同じ

コレクションを初期化した後は、map() や filter() などの高階関数(メソッド)を使ってコレクションの要素に対して関数を適用する事ができます。
これらのメソッドは関数を適用後のコレクションを返すので、メソッドチェーンが可能となります。

$collection = DusanKasan\Knapsack\Collection::range(0, 100);

// 0,4,10,24,40,64,90,C4,100,144,190,1E4,240,2A4,310,384,400,484,510,5A4,640,6E4,790,844,900,9C4,A90,B64,C40,D24,E10,F04,1000,1104,1210,1324,1440,1564,1690,17C4,1900,1A44,1B90,1CE4,1E40,1FA4,2110,2284,2400,2584,2710
echo $collection
    ->map(function ($v) { return pow($v, 2); }) // 値を2乗
    ->filter(function ($v) { return $v % 2 === 0; }) // 奇数を除外
    ->map(function ($v) { return dechex($v); }) // 数値を16進数に
    ->map(function (string $v) { return strtoupper($v); }) // 英字を大文字に
    ->reduce(function ($acc, string $v) { return $acc . ($acc === '' ? '' : ',') . $v; }, ''); // ,区切りで連結

配列に地道に array_maparray_filter をネストしていくより可読性が高いですね。

メソッドチェーンについては大抵のコレクションライブラリ(例えばdoctrine/collectionsとか)にも備わっている機能なので特段珍しくはないですが、Knapsackは高階関数メソッドを豊富に備えています。

公式ドキュメントに全てのメソッドが書かれていますが、上で使った map()filter()reduce() はもちろん、 zip()groupBy()flattern() 等が全部で86種類もあります。
Scalaのコレクションライブラリ並に多機能ですね。

また、reduce() 等の畳込み処理系以外は関数を適用後のコレクションを返すので、全てメソッドチェーンが可能となっています。

イミュータブル

Knapsackのコレクションはイミュータブルです。先程、コレクションに存在する多数の高階関数はほぼメソッドチェーンが可能と紹介しましたが、実はこれらは全てイミュータブルとなっています。
コレクションはインスタンス自身が保持している値に関数を適用する代わりに、関数を適用した値により新しくコレクションを生成し、返します。

$collection = DusanKasan\Knapsack\Collection::range(0, 100);
$squared = $collection->map(function ($v) { return pow($v, 2); }); // 値を2乗
$onlyEven = $squared->filter(function ($v) { return $v % 2 === 0; }); // 奇数を除外
$hexed = $onlyEven->map(function ($v) { return dechex($v); }); // 数値を16進数に

// 100, 10000, 10000, "2710"
var_dump($collection->last(), $squared->last(), $onlyEven->last(), $hexed->last());

このように都度結果を別の変数にバインドしてみると、1つ前のコレクションの値が計算の影響を受けていない事が分かります。
これにより、どのような関数の引数にも安心してコレクションを渡す事ができます。

全てが遅延評価

Knapsackのコレクションは適用した関数は全てイテレーション時まで遅延します。
例えば、最初の例から最後の畳み込みである reduce() を省略してみます。

$collection = DusanKasan\Knapsack\Collection::range(0, 100);
$collection
    ->map(function ($v) { return pow($v, 2); }) // 値を2乗
    ->filter(function ($v) { return $v % 2 === 0; }) // 奇数を除外
    ->map(function ($v) { return dechex($v); }) // 数値を16進数に
    ->map(function (string $v) { return strtoupper($v); }); // 英字を大文字に
; 

この時点では、3つの map() と 1つの filter() の計算はまだされません。それどころか、最初の range() による生成処理自体も遅延されるのでこの時点では本当に何も実行されていません。

畳み込みである reduce() や、foreach によるイテレーション、あるいは toArray() による配列への変換を行った時に一気に処理が行われます。
つまり、以下の記述は全く一緒である事になります:

foreach ($collection->map($funcA)->map($funcB)->filter($funcC)->map($funcD) as $v) {
    echo $v;
}

foreach ($collection as $v) {
    if ($funcC($tmp = $funcB($funcA($v)))) {
        echo $funcD($tmp);
    }
}

iterable、またはこれを返すcallableは全て受け入れ可能

実は、これが僕が一番Knapsackの気に入ってる点です。
一番最初にコレクションの初期化方法として、Collectionクラスのコンストラクタと ::from() を挙げましたが、これらには表題の通りiterableか、iterableを返すcallableなら何でも渡す事が可能です。
Generatorも当然渡す事が可能ですし、先程の遅延処理も相まってDoctrine ORMのような、1エンティティのメモリコストが非常に大きい物を扱うのに適しています。

例えば、以下は全ユーザーのIDと名前、またそのユーザーのコメント数を集計した物をCSVの行として表した物を取得します。

/** @var Acme\User[] $users */
$users = DusanKasan\Knapsack\Collection::from(function () use ($em) {
    $usersQuery = $em->createQuery('SELECT u FROM Acme\User u');
    
    foreach ($usersQuery->iterate() as $counter => [$user]) { // 渡ってくる値はarrayでラップされているのでアンラップする必要がある
        assert($user instanceof Acme\User);
    
        yield $user;
    
        if (($counter + 1) % 1000 === 0) {
            $em->clear(); // 1000回トラバースする毎にEntityManagerのオブジェクトを開放する
        }
    }
});

// [ [1, "Ariana Grande", 10], [2, "Taylor Swift", 125], ...]
$csvRows = $users
    // 1000ユーザー毎のチャンクにする。コレクションはは次のような物になる
    // [ [$u1, $u2, ..., $u1000], [$u1001, $u1002, ..., ], ...]
    ->partition(1000) 
    ->map(function (iterable $chunkedUsers) {
        // 1000ユーザー分のidの配列を得る
        $userIds = DusanKasan\Knapsack\Collection::from($chunkedUsers)->map(function ($u) { return $u->getId(); })->toArray();

        // idの配列から、ユーザー毎のコメント数をSQLで集計し、idの配列順で返す物を想定
        $userNumComments = getUserNumCommentsFor($userIds);

        // ユーザーと当該ユーザーの書き込み数をzipする。コレクションは次のような物になる
        // [ [$u1, $u1Comments], [$u2, $u2Comments], ..., [$u1000, $u1000Comments] ]
        return DusanKasan\Knapsack\Collection::from($chunkedUsers)
            ->zip($userNumComments);
    })
    // 1000件毎のチャンクを平坦化する。コレクションは次のように変化する
    // before: [ [ [$u1, $u1Comments], [$u2, $u2Comments], ..., [$u1000, $u1000Comments] ], [ [$u1001, $u1001Comments, ...] ], ...]
    // after:  [ [$u1, $u1Comments], [$u2, $u2Comments], ..., [$uN, $uNComments]]
    ->flatten(1)
    ->map(function ($zipped) {
        // $zippedは [$uN, $uComments] となっている
    
        $zippedCollection = \DusanKasan\Knapsack\Collection::from($zipped);
        $user = $zippedCollection->first();
        $numComments = $zippedCollection->last();
        assert($user instanceof Acme\User);

        return [$user->getId(), $user->getName(), $numComments];
    })
    ->values()
    ->toArray()
;

上記では、全体のユーザー数が何人であれ一度にメモリに乗るUserオブジェクトは最大で1000個までなので、メモリを節約する事が可能となっています。

注意点

PHPは7から配列がかなり高速化されたので、ジェネレータをふんだんに使うKnapsackコレクションは標準の配列処理に比べ、かなりパフォーマンスの面では劣ります。
WebサーバーがHTTPレスポンスを作るのに扱うデータ量ぐらいであれば気にならないですが、バッチサーバー等で大規模なデータを処理する際は注意が必要な場面があります。
しかし、配列に落とし込むには相応のメモリが必要なので、パフォーマンスとのトレードオフとなります。