【PHP】Laravel collectionの進化の軌跡を辿る

本投稿は TECOTEC Advent Calendar 2023 の3日目の記事です。

こんにちは、証券フロンティア事業部の西永です。
一昨年昨年に続き今年も執筆することになりました。

今回は業務でもよく使用しているPHPフレームワークのLaravelより、collectionの進化の軌跡を新規関数より振り返りたいと思います。
参考:https://readouble.com/

各関数に参考としてソースも載せています。
参照:collections/Collection.php at master · illuminate/collections · GitHubcollections/Traits/EnumeratesValues.php at master · illuminate/collections · GitHub

Laravel 5.8 -> 6.x

計4つ。

  • collect()
  • skip()
  • whereNotNull()
  • whereNull()

collect()

collectionをコピーして新しいインスタンスを作る関数。
Illuminate\Support\LazyCollectionのときに特に活用できるようですが私はそもそもLazyCollectionを使ったことがありません…

    /**
     * Collect the values into a collection.
     *
     * @return \Illuminate\Support\Collection<TKey, TValue>
     */
    public function collect()
    {
        return new Collection($this->all());
    }

skip()

先頭から指定した分だけデータを飛ばして取得する関数。
Illuminate\Support\CollectionよりはIlluminate\Database\Eloquent\Collectionで使うことの方が多そう。

    /**
     * Skip the first {$count} items.
     *
     * @param  int  $count
     * @return static
     */
    public function skip($count)
    {
        return $this->slice($count);
    }

whereNotNull()

指定したキーがnullのものを省いて抽出する関数。
これ無かった以前ってどうしてたんだろうと思ったんですが、本体ソース通りにやってたのでしょうか。

    /**
     * Filter items where the value for the given key is not null.
     *
     * @param  string|null  $key
     * @return static
     */
    public function whereNotNull($key = null)
    {
        return $this->where($key, '!==', null);
    }

whereNull()

whereNotNull()の逆。

whereStrict()の中身は$this->where($key, '===', $value)

    /**
     * Filter items where the value for the given key is null.
     *
     * @param  string|null  $key
     * @return static
     */
    public function whereNull($key = null)
    {
        return $this->whereStrict($key, null);
    }

Laravel 6.x -> 7.x

計5つ。

  • skipUntil()
  • skipWhile()
  • sortDesc()
  • takeUntil()
  • takeWhile()

skipUntil()

指定したプロパティがtrueになるまでスキップし、それ以降のアイテムを取得する関数。
アイテムが欲しいときはwhere()filter()でtrueのものだけ欲しい時が多いから使う場面少なそう。

    /**
     * Skip items in the collection until the given condition is met.
     *
     * @param  TValue|callable(TValue,TKey): bool  $value
     * @return static
     */
    public function skipUntil($value)
    {
        return new static($this->lazy()->skipUntil($value)->all());
    }

skipWhile()

skipUntil()の引数がcallbackになったバージョン。

大体skipUntil()と同じ。

    /**
     * Skip items in the collection while the given condition is met.
     *
     * @param  TValue|callable(TValue,TKey): bool  $value
     * @return static
     */
    public function skipWhile($value)
    {
        return new static($this->lazy()->skipWhile($value)->all());
    }

sortDesc()

値を降順にソート。
以前はsort()にcallbackで渡してやっていたものがこちらで出来るように。

sort()の方は引数がcallbackならuasort($items, $callback)、そうでないならasort($items, $callback ?? SORT_REGULAR)でやってます。

    /**
     * Sort items in descending order.
     *
     * @param  int  $options
     * @return static
     */
    public function sortDesc($options = SORT_REGULAR)
    {
        $items = $this->items;

        arsort($items, $options);

        return new static($items);
    }

takeUntil()

skipUntil()の逆。
指定したプロパティがtrueになるまでアイテムを取得する。
skipがあるならtakeもある。

    /**
     * Take items in the collection until the given condition is met.
     *
     * @param  TValue|callable(TValue,TKey): bool  $value
     * @return static
     */
    public function takeUntil($value)
    {
        return new static($this->lazy()->takeUntil($value)->all());
    }

takeWhile()

takeUntil()の引数がcallbackになったバージョン。
こちらも上と同様。

    /**
     * Take items in the collection while the given condition is met.
     *
     * @param  TValue|callable(TValue,TKey): bool  $value
     * @return static
     */
    public function takeWhile($value)
    {
        return new static($this->lazy()->takeWhile($value)->all());
    }

Laravel 7.x -> 8.x

計11つ。 色々ある。

  • chunkWhile()
  • doesntContain()
  • pipeInto()
  • pipeThrough()
  • range()
  • reduceSpread()
  • sliding()
  • sole()
  • sortKeysUsing()
  • splitIn()
  • undot()

chunkWhile()

同じデータが連続している配列を分割するときに使える?
ソートした配列が渡されたときに使えるのかもしれない。

    /**
     * Chunk the collection into chunks with a callback.
     *
     * @param  callable(TValue, TKey, static<int, TValue>): bool  $callback
     * @return static<int, static<int, TValue>>
     */
    public function chunkWhile(callable $callback)
    {
        return new static(
            $this->lazy()->chunkWhile($callback)->mapInto(static::class)
        );
    }

doesntContain()

contains()の逆。

    /**
     * Determine if an item is not contained in the collection.
     *
     * @param  mixed  $key
     * @param  mixed  $operator
     * @param  mixed  $value
     * @return bool
     */
    public function doesntContain($key, $operator = null, $value = null)
    {
        return ! $this->contains(...func_get_args());
    }

pipeInto()

引数クラスからインスタンスを生成する関数。
Javaのメソッド参照みたいですね。

    /**
     * Pass the collection into a new class.
     *
     * @template TPipeIntoValue
     *
     * @param  class-string<TPipeIntoValue>  $class
     * @return TPipeIntoValue
     */
    public function pipeInto($class)
    {
        return new $class($this);
    }

pipeThrough()

pipeThroughメソッドは、指定するクロージャの配列へコレクションを渡し、クロージャの実行結果を返します。

参考ページの実行結果見てるだけだとmerge()してからsum()すれば良いだけなのではという気がしなくもないが、collectionがmutableなことを考えるとこちらは元配列の情報を保てたりするのだろうか。
実用性はいまいち分かってない…

    /**
     * Pass the collection through a series of callable pipes and return the result.
     *
     * @param  array<callable>  $pipes
     * @return mixed
     */
    public function pipeThrough($pipes)
    {
        return static::make($pipes)->reduce(
            function ($carry, $pipe) {
                return $pipe($carry);
            },
            $this,
        );
    }

range()

python等ではお馴染み?なrange()
配列(array)の方では元々存在していますが、collectionからそのままメソッドチェーンで繋げられるのは良いですね。

    /**
     * Create a collection with the given range.
     *
     * @param  int  $from
     * @param  int  $to
     * @return static<int, int>
     */
    public static function range($from, $to)
    {
        return new static(range($from, $to));
    }

reduceSpread()

carryが複数個用意できるreduce()
あと元々reduceMany()だったようですが、Laravel 9にて名前がreduceSpread()に変わり、元々の関数名reduceMany()は非推奨になりました。 https://readouble.com/laravel/9.x/ja/upgrade.html

reduceManyメソッドは、他の同様のメソッドと名前の一貫性を保つため、reduceSpreadへ名前を変更しました。

    /**
     * Reduce the collection to multiple aggregate values.
     *
     * @param  callable  $callback
     * @param  mixed  ...$initial
     * @return array
     *
     * @throws \UnexpectedValueException
     */
    public function reduceSpread(callable $callback, ...$initial)
    {
        $result = $initial;

        foreach ($this as $key => $value) {
            $result = call_user_func_array($callback, array_merge($result, [$value, $key]));

            if (! is_array($result)) {
                throw new UnexpectedValueException(sprintf(
                    "%s::reduceMany expects reducer to return an array, but got a '%s' instead.",
                    class_basename(static::class), gettype($result)
                ));
            }
        }

        return $result;
    }

sliding()

slidingメソッドは、コレクション中のアイテムの「スライディングウィンドウ」ビューを表す新しいチャンクコレクションを返します。

スライディングウィンドウがそもそも分かってないので使い道も分からない…

    /**
     * Create chunks representing a "sliding window" view of the items in the collection.
     *
     * @param  int  $size
     * @param  int  $step
     * @return static<int, static>
     */
    public function sliding($size = 2, $step = 1)
    {
        $chunks = floor(($this->count() - $size) / $step) + 1;

        return static::times($chunks, fn ($number) => $this->slice(($number - 1) * $step, $size));
    }

sole()

指定したパラメーターのアイテムが1個だけしかない場合にはそのアイテムを返し、0個または複数個ある場合にはエラーを返す。
first()find()とも違う厳密さ。

    /**
     * Get the first item in the collection, but only if exactly one item exists. Otherwise, throw an exception.
     *
     * @param  (callable(TValue, TKey): bool)|string  $key
     * @param  mixed  $operator
     * @param  mixed  $value
     * @return TValue
     *
     * @throws \Illuminate\Support\ItemNotFoundException
     * @throws \Illuminate\Support\MultipleItemsFoundException
     */
    public function sole($key = null, $operator = null, $value = null)
    {
        $filter = func_num_args() > 1
            ? $this->operatorForWhere(...func_get_args())
            : $key;

        $items = $this->unless($filter == null)->filter($filter);

        $count = $items->count();

        if ($count === 0) {
            throw new ItemNotFoundException;
        }

        if ($count > 1) {
            throw new MultipleItemsFoundException($count);
        }

        return $items->first();
    }

sortKeysUsing()

連想配列のキーでソートする。
中身はuksort()

    /**
     * Sort the collection keys using a callback.
     *
     * @param  callable(TKey, TKey): int  $callback
     * @return static
     */
    public function sortKeysUsing(callable $callback)
    {
        $items = $this->items;

        uksort($items, $callback);

        return new static($items);
    }

splitIn()

引数で指定した数の分だけ配列を分割、分割で余ったアイテムは最終グループの配列に入れる。
例えばrange(1, 10)のcollectionでsplitIn(4)を実行したら、[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]のcollectionが出来る。
(10/3=3.33... を切り上げた4個ずつで分割される)

    /**
     * Split a collection into a certain number of groups, and fill the first groups completely.
     *
     * @param  int  $numberOfGroups
     * @return static<int, static>
     */
    public function splitIn($numberOfGroups)
    {
        return $this->chunk(ceil($this->count() / $numberOfGroups));
    }

undot()

undot メソッドは、「ドット」記法を用いた一次元のコレクションを多次元のコレクションへ展開します。

便利系関数だが、使う場面少ないような気もする。
ヘルパー関数としてArr::dot()もあるのでそちらと組み合わせる?
collectionのdot()はLaravel 10で登場します。

    /**
     * Convert a flatten "dot" notation array into an expanded array.
     *
     * @return static
     */
    public function undot()
    {
        return new static(Arr::undot($this->all()));
    }

Laravel 8.x -> 9.x

計5つ。

  • containsOneItem()
  • firstOrFail()
  • hasAny()
  • lazy()
  • value()

containsOneItem()

collectionの中身が1つだけか。
ただそれだけ。

    /**
     * Determine if the collection contains a single item.
     *
     * @return bool
     */
    public function containsOneItem()
    {
        return $this->count() === 1;
    }

firstOrFail()

first()は取得できなかったらnullを返していたが、この関数は代わりにエラーItemNotFoundExceptionを返す。
Illuminate/Database/Eloquent/Builderの方には古くから存在しているのでお馴染み?

    /**
     * Get the first item in the collection but throw an exception if no matching items exist.
     *
     * @param  (callable(TValue, TKey): bool)|string  $key
     * @param  mixed  $operator
     * @param  mixed  $value
     * @return TValue
     *
     * @throws \Illuminate\Support\ItemNotFoundException
     */
    public function firstOrFail($key = null, $operator = null, $value = null)
    {
        $filter = func_num_args() > 1
            ? $this->operatorForWhere(...func_get_args())
            : $key;

        $placeholder = new stdClass();

        $item = $this->first($filter, $placeholder);

        if ($item === $placeholder) {
            throw new ItemNotFoundException;
        }

        return $item;
    }

hasAny()

指定したキーのいずれかがcollection内にあるか。
引数はキー単体でもキー配列でも渡せる、寛容。

    /**
     * Determine if any of the keys exist in the collection.
     *
     * @param  mixed  $key
     * @return bool
     */
    public function hasAny($key)
    {
        if ($this->isEmpty()) {
            return false;
        }

        $keys = is_array($key) ? $key : func_get_args();

        foreach ($keys as $value) {
            if ($this->has($value)) {
                return true;
            }
        }

        return false;
    }

lazy()

元のcollectionからlazy collectionを作る関数。
データがでかくないと出番はない。

    /**
     * Get a lazy collection for the items in this collection.
     *
     * @return \Illuminate\Support\LazyCollection<TKey, TValue>
     */
    public function lazy()
    {
        return new LazyCollection($this->items);
    }

value()

valueメソッドは、コレクション内の最初の要素から、指定した値を取得します。

純粋にfirst()してからプロパティの値を取り出すのではなく、firstWhere()で指定したキーがあるアイテムを取得してからプロパティの値を取得するのがポイント。

    /**
     * Get a single key's value from the first matching item in the collection.
     *
     * @template TValueDefault
     *
     * @param  string  $key
     * @param  TValueDefault|(\Closure(): TValueDefault)  $default
     * @return TValue|TValueDefault
     */
    public function value($key, $default = null)
    {
        if ($value = $this->firstWhere($key)) {
            return data_get($value, $key, $default);
        }

        return value($default);
    }

Laravel 9.x -> 10.x

計5つ。

  • diffAssocUsing()
  • dot()
  • ensure()
  • intersectAssoc()
  • percentage()

diffAssocUsing()

diffAssoc()では配列だけ渡していたが、こちらはインデックスを比較するためにcallbackも受け取る。
文字のキーなのに順番も保持しているPHPならでは。

    /**
     * Get the items in the collection whose keys and values are not present in the given items, using the callback.
     *
     * @param  \Illuminate\Contracts\Support\Arrayable<TKey, TValue>|iterable<TKey, TValue>  $items
     * @param  callable(TKey, TKey): int  $callback
     * @return static
     */
    public function diffAssocUsing($items, callable $callback)
    {
        return new static(array_diff_uassoc($this->items, $this->getArrayableItems($items), $callback));
    }

dot()

undot()から2世代遅れてようやく登場。

    /**
     * Flatten a multi-dimensional associative array with dots.
     *
     * @return static
     */
    public function dot()
    {
        return new static(Arr::dot($this->all()));
    }

ensure()

アイテム全部引数の型かチェックできる。
でも違ったらエラーUnexpectedValueExceptionを返すのでちょっと使いづらいかも。

    /**
     * Ensure that every item in the collection is of the expected type.
     *
     * @template TEnsureOfType
     *
     * @param  class-string<TEnsureOfType>  $type
     * @return static<TKey, TEnsureOfType>
     *
     * @throws \UnexpectedValueException
     */
    public function ensure($type)
    {
        return $this->each(function ($item) use ($type) {
            $itemType = get_debug_type($item);

            if ($itemType !== $type && ! $item instanceof $type) {
                throw new UnexpectedValueException(
                    sprintf("Collection should only include '%s' items, but '%s' found.", $type, $itemType)
                );
            }
        });
    }

intersectAssoc()

diffAssoc()の逆。
一致しているkey-valueだけ取得できる。
ドキュメントの方には今のところ載ってないが、intersectUsing()intersectAssocUsing()intersectByKeys()等も存在している。

    /**
     * Intersect the collection with the given items with additional index check.
     *
     * @param  \Illuminate\Contracts\Support\Arrayable<TKey, TValue>|iterable<TKey, TValue>  $items
     * @return static
     */
    public function intersectAssoc($items)
    {
        return new static(array_intersect_assoc($this->items, $this->getArrayableItems($items)));
    }

percentage()

普通に計算すれば良いのではとの思いは過るが、腱鞘炎防止には役立つかもしれない。

    /**
     * Calculate the percentage of items that pass a given truth test.
     *
     * @param  (callable(TValue, TKey): bool)  $callback
     * @param  int  $precision
     * @return float|null
     */
    public function percentage(callable $callback, int $precision = 2)
    {
        if ($this->isEmpty()) {
            return null;
        }

        return round(
            $this->filter($callback)->count() / $this->count() * 100,
            $precision
        );
    }

おわりに

Laravelが出てからもう12年経っていますが、古くからあるcollectionでも様々な拡張が行われていることが今回分かりました。
これらを活かして良いコードを書けるようにしていきたいです。

テコテックの採用活動について

テコテックでは新卒採用、中途採用共に積極的に募集をしています。
採用サイトにて会社の雰囲気や福利厚生、募集内容をご確認いただけます。
ご興味を持っていただけましたら是非ご覧ください。 tecotec.co.jp