Laravelのコレクション(その2:要素取得編)

0
1009

Laravelコレクション掘り下げシリーズ第二弾。今回から何回かに分けてコレクションの各メソッドの用法を細かく見ていきたいと思います。
確認にあたっては「Laravelのコレクション(その1:概要編)」での機能分類に準じて見て行きますが、まずは詳細編初回として最も基本的な「要素の取得」に分類したメソッドに関して確認していきます。

なお、今後も含めて各メソッドの確認を行っていく上で重要と思われる点を最初にまとめておきます。

留意点

コレクションを一言で言えば「配列に関するラッパーオブジェクト」ですが、単に「配列」と表現した場合、往々にして「配列」と「連想配列」の両方の意味を含んでいます。
私は20年ほど前にはC言語での開発に携わっていましたが、PHPの「配列」はCの「配列」、PHPの「連想配列」はCの「構造体」に対応づけて理解しています。この分類に準じて「配列」と「連想配列(構造体)」の特徴を上げると以下のようになります。

配列連想配列(構造体)
要素の位置指定インデックス(0から始まる連番)キー(任意の文字列)
要素の型同種の型のみ異なる型が混在
要素の順序考慮あり考慮なし

上記のような特徴から用途も違ってきます。「連想配列(構造体)」は関連する複数の情報をまとめたもので、例えば氏名(文字列)や年齢(数値)をまとめて「ユーザー情報」と言う「連想配列(構造体)」を構成すると言った使い方をします。一方「配列」は同種の複数のデータを並びを含めて管理するもので、例えば前述の「ユーザー情報」で「配列」を構成し、1ユーザーずつ順番に処理して行くと言ったような使い方をします。

と言うのが私のイメージなのですが、実のところこれはかなりC言語寄りの考え方であり、PHPにおけるデータ構造面から見ると少し事情が違ってきます。
ぶっちゃけ、PHPの「配列」は「キーを0から始まる数字の並びで表現した連想配列」であり、つまりは「配列」は「連想配列」の部分集合(特殊ケース)に過ぎません。よって、PHPの「配列」では異なる型の要素が混在できますし、要素の削除などを行えばキーが連番になっていない配列が出来上がります。

ただ、このように要素の型が異なっていたり、キーが連番になっていない「配列」を使いたい動機があるかと言うと、個人的にはあまり思いつきません。PHPの言語仕様としてどのようなデータ構造が生成可能かと言うことはともかく、プログラム上で扱いたいデータを意味的に捉えた場合、C言語的な「配列」か「構造体(連想配列)」に落ち着くのではないでしょうか。

よって、本記事においても「配列」と「連想配列」は区別し、それぞれ先に示したC言語的特徴を持つことを前提とします。
また、この前提に則って本記事では以下のような特徴を持つ本体配列を持つコレクションに関して動作を確認して行きます。

  • 要素の型としては「数値」「文字列」「配列」「連想配列」「オブジェクト」の5種類を考えます。
  • 「オブジェクト」はEloquentとします。
  • 「配列」に関しては上記5種類のいずれか1つの型で構成されるものとし、型が混在するパターンは考慮しません。
  • 「連想配列」に関しては上記5種類の要素が全部含まれるものとします。

「配列」や「連想配列」の要素が「配列」や「連想配列」となるケースも含まれますが、これは言い換えれば多次元配列となるケースを考慮していると言うことです。
「オブジェクト」をEloquentに限定したのはコレクションの利用頻度として最も高い(と個人的に思っている)DBからのデータ取得結果を想定したためですが、実際の処理結果を見ていくとオブジェクトがEloquentであることで成立していると思われるようなケースも見受けられます。よって、その他のオブジェクトでは振る舞いが違ってくる可能性があります。

また、各機能説明はネット情報をベースに実際に動作検証した結果をまとめた物ですが、この中には「実装」的内容が含まれます。
メソッドのインタフェースや機能概要に関しては、本来であれば公式な「仕様」を確認するのが正しいです。ただ、公式サイトやその日本語訳と思われるサイトを見る限り、あまり詳しい記述は発見できません。よって、疑問点に関しては下記のように実際に動作確認してみたり、頑張ってソースの内容を追ってみたりと言うことが必要になりますが、それで確認できることはあくまで「実装」(現在はこのように作られている)であって、「仕様」ではありません。「仕様」に関してはマイナーバージョンアップの範囲であれば保証されると思っていますが、「仕様」で明記されていない「実装」内容は変更されても文句が言えません。この点に大きな違いがあります。
とは言うものの実際にコレクションの機能を使っていく中で仕様上確認できない「こんなことができたら良いな」がままあり、実際に試して見ると期待通り使えたりします。このような用法を切り捨ててしまうのはやはりもったいない気がしてしまいます。

加えて、動作確認の中で不可思議な振る舞いもいくつか見受けられましたが、それが実際に実装面に存在する問題・課題・制約なのか、私の確認方法に何か問題があってのことなのかは個人的には判断できません。

上記のような前提をご理解いただきつつ、本記事および以降の記事の内容をどこまで採用するかについては自己責任で。

では、以下に各メソッドの内容を見ていきたいと思います。

first

$result = $collection->first();
$result = $collection->first(function ($value, $key) {
    条件
});

firstは条件に合致した最初の要素を返します。

引数なしで指定した場合はコレクションの最初の要素を返します。
本体配列が「連想配列」でも機能しますが、連想配列において要素の順番を意識した処理はしないと思うので、使う機会はなさそうに思いますが。

引数にコールバックを指定することもできます。この場合はコールバックがtrueを返した最初の要素を返します。コールバックに渡される第一引数は本体配列の一次元目の要素であり、第二引数は一次元目のキーです。
こちらも本体配列が「連想配列」でも機能しますが、やはり使い所がイメージし難いです。

firstWhere

$result = $collection->firstWhere(<キー>, <比較演算子>, <値>);
$result = $collection->firstWhere(<キー>, <値>);
$result = $collection->firstWhere(<キー>);

firstWhereは本体配列が二次元以上の階層を持つ場合のみ有効なようで、二次元以下の階層に対して指定した「キー」に対応する値が引数で指定した「値」に対して比較条件を満たす最初の要素を返します。
第二引数として比較演算子を指定できますが、省略した場合は等号とみなされるようです。
さらに「値」も省略可能で、その場合は指定した「キー」の値がtrueと見なせる最初の要素を返すようです。ただ、ここまで省略してしまうと意図が分かり辛くなるような印象がありますので、等号省略くらいまでが妥当なところかと。

なお、キーの判定に関してはなかなかに頑張ってくれるようです。
例えば以下のように多次元連想配列の配列が本体配列となっている場合を考えます。

[
    [
        ...
    ],
    [ // 取得したい要素
        'key-n' => 21,
        'key-s' => 'string2',
        'key-a-c' => [
            [
                'key-n' => 100,
                'key-s' => 'string1',
                'key-a' => [
                    11,
                    12,
                    13,
                ],
                'key-c' => [
                    'key-n' => 1,
                    'key-s' => 'here', // 判定対象
                ],
                'key-o' => $obj1,
            ],
            [
                ...
            ],
        ],
    ],
    [   
        ... 
    ],          
] 

上記のような構造を持つ多次元連想配列の配列において、「key-a-c」内の最初(0番目)の要素の「key-c」内「key-s」の値が「here」である最初の要素を取得したい場合、以下の書式で可能です。

$result = $collection->firstWhere('key-a-c.0.key-c.key-s', 'here');

上記のように要素を「.」(ピリオド)で連結して階層を表現します。配列に関してはインデックス(数字)を指定可能です。

なお配列に関してワイルドカード(*)での指定が可能なケースも見受けられますが、上記において「key-a-c.*.key-c.key-s」では期待通りに機能しませんでした。

また、要素がオブジェクト(Eloquent)の配列の場合、同オブジェクトのプロパティを「キー」として指定可能です。
加えて言うと、Eloquentはリレーション先のレコードを自身のプロパティのように扱える機能を有していますので、そこまでも条件に指定できます。
例えばテーブル「t_sample」が「t_sub」と1対1に対応しており、「t_sample」のインスタンスにおいてプロパティ名「t_sub」で対応するレコードの情報が参照可能であるように関連づけらていたとします。また、「t_sub」にはプロパティ「value」(数値)が存在するとします。
この状態で、「t_sample」のオブジェクト(Eloquent)を要素とするコレクションにおいて、対応する「t_sub」の「value」が30である最初の要素を取得したい場合、以下の書式で可能です。

$result = $collection->firstWhere('t_sub.value', 30);

蛇足ながら、最初に触れたように本メソッドは二次元以上の階層構造を持つ場合に適用できるため、多次元連想配列に対しても適用可能です。
例えば以下の構造の本体配列を考えます。

[           
    'key-n' => 1,
    'key-s' => 'string',
    'key-c' => [
        'key1' => 1,
        'key2' => 2,
        'key3' => 3,
    ],
]

上記に対して以下の書式で「key-c」を取得することができます。

$result = $collection->firstWhere('key2', 2)

理屈で言えばそうなんでしょうけどね。ただ、このような使い方は多分しないでしょう。
やはり連想配列もしくはオブジェクト(Eloquent)を要素とする配列において使用する機能と考えるのが良いかと思います。

get

$result = $collection->get(<キーまたはインデックス>);
$result = $collection->get(<キーまたはインデックス>, <default値>);
$result = $collection->get(<キーまたはインデックス>, function () {
    return <default値>;
});

getは指定したキーまたはインデックスに該当する要素を返します。最も基本的な要素取得方法と言って良いでしょう。

ただし、確認した限りでは、firstWhereのように階層的な要素の指定はできない模様です。あくまで対象となる連想配列もしくは配列の一次元目のキーもしくはインデックスを指定して該当する要素を取得すると言う機能のようです。
firstWhereのように階層を考慮できるようにしても良かったと思うのですが。

一方で、指定したキーもしくはインデックスで要素が発見できなかった場合に返すデフォルト値の指定には熱心で、単純に第二引数で指定できたり、コールバック内で指定できたりします。デフォルト値が指定されずに要素が発見できなかった場合はnullが返されるようです。
こちらに関しても、単にnullを返してもらって、nullの場合はデフォルト値になるよう三項演算子で処理すれば良いように思うので、何か力点がずれているようにも感じるのですが…

last

$result = $collection->last();
$result = $collection->last(function ($value, $key) {
    条件
});

lastは条件に合致した最後の要素を返します。firstの逆ですね。
その他の特徴に関してはfirstと同様なので説明は割愛します。

なお、firstWhereはありますが、lastWhereはないようです。lastWhere相当のことをしたかったらreverseしてfirstWhereしろと言うことですかね?
だとすると、lastとfirstの関係でも同じように思うのですが、先ほどのgetに続いてこの辺にもアンバランスさを感じてしまいます。
何か深い理由があるんでしょうかね?

pop

$result = $collection->pop();

popはコレクションの最後の要素を返します。この点はlastで引数を指定しなかった場合と同じですが、違いは実行時に元コレクションから当該要素を削除してしまう点です。つまりコレクションの状態が実行前後で変わる操作です。

array_popと同等の振る舞いになるため、その点では分かりやすいですね。
ただ、イミュータブル(作成時に内容を確定し、以降は変更不可とする性質)であるよう自身の内容は変更せず、必要であれば別コレクションを生成すると言った振る舞いをするメソッドが多い中で、稀に本ケースのように自身の変更を行うメソッドが存在すると言う点では逆に分かり辛さがあるようにも思います。

pull

$result = $collection->pull(<キーまたはインデックス>);

pullは指定したキーまたはインデックスに該当する要素を返します。例によってgetとの違いは実行時に元コレクションから当該要素を削除してしまう点です。

キーやインデックスの指定ですが…getと異なり階層的な指定が可能です。なぜだ?
getと比較して要素を削除する分だけこちらの方が複雑なんですけどね。

となると、気になるのは当然ながら階層による振る舞いです。
以下、元となる本体配列構造、処理内容、実行後の本体配列構造の3点セットで示して行きます。

まずは基本的な一次元配列から確認したいと思います。

[
    1,
    2,
    3, // 対象
    4,
    5,
]
$result = $collection->pull(2); // $result = 3
[
    1,
    2,
    4,
    5,
]

妥当ですね。

次は連想配列です。せっかくなので、階層が深い位置の要素に対して試してみましょう。

[
    'key-n' => 1,
    'key-s' => 'string',
    'key-a-c' => [
        [
            'key-n' => 1,
            'key-s' => 'string1',
            'key-c' => [
                'key-n' => 1,
                'key-s' => 'here', // 対象
            ],
        ],
        [
            ...
        ],
    ]
]
$result = $collection->pull('key-a-c.0.key-c.key-s'); // $result = 'here'
[
    'key-n' => 1,
    'key-s' => 'string',
    'key-a-c' => [
        [
            'key-n' => 1,
            'key-s' => 'string1',
            'key-c' => [
                'key-n' => 1,
            ],
        ],
        [
            ...
        ],
    ]
]

値を取得かつ削除できています。

オブジェクト(Eloquent)に関してはどうでしょう。
なお、下記では配列のように表記していますが、実体はオブジェクトです。

[
    [
        'id' => 1,
        'column1' => 1,
        't_sub' => [
            'id' => 1,
            'sample_id' => 1,
            'value' => 10, // 対象
        ],
    ],
    [
        'id' => 2,
        'column1' => 2,
        't_sub' => [
            'id' => 2,
            'sample_id' => 2,
            'value' => 20,
        ],
    ],
    [
        'id' => 3,
        'column1' => 3,
        't_sub' => [
            'id' => 3,
            'sample_id' => 3,
            'value' => 30,
        ],
    ],
]
$result = $collection->pull('0.t_sub.value'); // $result = 10
[
    [
        'id' => 1,
        'column1' => 1,
        't_sub' => [
            'id' => 1,
            'sample_id' => 1,
            'value' => 10, // 対象
        ],
    ],
    [
        'id' => 2,
        'column1' => 2,
        't_sub' => [
            'id' => 2,
            'sample_id' => 2,
            'value' => 20,
        ],
    ],
    [
        'id' => 3,
        'column1' => 3,
        't_sub' => [
            'id' => 3,
            'sample_id' => 3,
            'value' => 30,
        ],
    ],
]

対象要素が削除されていません!
オブジェクトは基本的には実体ではなく参照を持ち回るので、安易に内容を変更することが憚られると言うことなんでしょうかね(勝手な推論)。

最後に連想配列の配列で確認します。

[
    [
        ...
    ],
    [
        'key-n' => 21,
        'key-s' => 'string2',
        'key-a-c' => [
            [
                'key-n' => 100,
                'key-s' => 'string1',
                'key-c' => [
                    'key-n' => 1,
                    'key-s' => 'here', // 対象
                ],
            ],
            [
                ...
            ],
        ],
    ],
    [
        ...
    ],
]
$result = $collection->pull('1.key-a-c.0.key-c.key-s'); // $result = 'here'
[
    [
        ...
    ],
    [
        'key-n' => 21,
        'key-s' => 'string2',
        'key-a-c' => [
            [
                'key-n' => 100,
                'key-s' => 'string1',
                'key-c' => [
                    'key-n' => 1,
                    'key-s' => 'here', // 対象
                ],
            ],
            [
                ...
            ],
        ],
    ],
    [
        ...
    ],
]

何と、ここでも削除は行われていません!
百歩譲ってオブジェクトはまだ「そんなもんかもなぁ」と思わなくはなかったですが、上記に関しては削除されない理由が謎です。
構造的には先に確認した単純な連想配列(削除できていた)をさらに配列として並べただけなので大差ないように思うのですが、なぜ扱いに違いが生じたのでしょうね???

と色々確認してみて不思議な点もありますが、そもそもpullのように特定の要素を「取り出す(取得&削除)」機能を二次元以下の要素に対して用いること自体が不自然な発想なのかもしれません(と無理やり納得)。
ただ、そうなるとgetのようにそもそも階層的指定をさせなければ良いと思いますし、逆にいよいよgetで階層的指定ができないのはなぜだ?と言う気がしてきます。

なお、pullにより配列内の途中の要素を削除可能ですが、インデックスの振り直しは行われません。つまりそのままfor文などで処理を行っても正しく動作しない形になってしまいます。
インデックスが0からの連番になるようにするにはvaluesを使用します。

random

$result = $collection->random();
$result = $collection->random(<数字>);

randomはコレクション内の要素を文字通りランダムに返します。

引数を指定しなければ1要素のみ、引数で数字を指定すればその数の要素を取得できます。
なお、1要素のみの取得であれば取得された要素は本来のその要素の型になりますが、複数要素の取得においてはコレクションになっており、各要素を取得するためには改めて取得メソッドを使用することになります。

バリエーションテストなどにおいて、様々なパターンを予め想定してテストを行うことも必要ですが、所詮人間が行うことですので見落としを考慮しておく必要もあるかと思います。よって、本メソッドを用いてテストデータがランダムに変化するような形で繰り返しテストを行うことで、盲点だったパターンを発生させることができたりします。
なかなかに重宝な機能です。

search

$result = $collection->search(<値>);
$result = $collection->search(<値>, true);
$result = $collection->search(function ($item, $key) {
    条件
});

searchは引数で指定した条件に該当する最初の要素のキーもしくはインデックスを返します。「取得」に分類したメソッドの中で本メソッドのみ要素本体ではなくキー・インデックスを返す点に要注意です。

第一引数が値の場合はその値と合致する要素のキーもしくはインデックスを返します。
あくまで一次元目の要素を指定する必要がありますが、一次元目の要素であれば単純な数字や文字列だけでなく多次元化された連想配列やオブジェクトでも動作するようです。

第一引数に値、第二引数にtrueを指定した場合は型を含めて厳密な比較を行います。
他のメソッドなどでは緩い比較のメソッド「xxxx」に対して厳密な比較を行うメソッドとして「xxxxStrict」のようなメソッドが用意されているケースも多いですが、searchに関しては第二引数で区別するようです。この辺が不統一な点も少々謎ですが。

なお、緩い比較と厳密な比較の違いで興味深かったのはオブジェクトに関する比較です。
本体配列に含まれるオブジェクトの参照を比較対象として指定すれば厳密な比較でも問題ありませんが、クローン(の参照)を指定した場合は厳密比較では一致と認められませんでした。緩い比較ではクローンでも一致とみなされましたので、緩い比較では頑張って中身の比較を行っているんでしょうかね?

なお、引数にコールバックを指定することも可能で、この場合は各要素に対してコールバックが実行され、コールバックの返り値がtrueだった要素のキーもしくはインデックスが返されます。
この方式を用いれば階層の深い位置にある要素を条件とすることも可能です。

shift

$result = $collection->shift();

shiftはコレクションの最初の要素を返しつつ削除します。最初か最後かと言う違いを除けばpopと同じです。

例によって不可解な点ですが、本体配列の途中(末尾ではない位置)の要素を削除すると言う点に関してはpullと同じであるにも関わらず、こちらはインデックスが0からの連番に振り直されています。
インデックスの振り直しを自動で行ってくれるか改めて独自に実施する必要があるのかに関してはどちらでも良いですが(率直に言えば前者の方がありがたいですが)、統一しておいてもらわないと紛らわしいと思うのですが…

総括

「留意点」でも書きましたが、今回確認した内容には多分に「実装」的な面がありますし、そもそも確認方法が適切であったと言う保証もありません。そのような状況ではありますが、いくつかの機能において不可解な点がありました。
Laravelのコレクションは多機能・高機能でかなり重宝するのですが、細かく見ていくとこのように謎の部分も出てきます。
その辺も理解して使う必要があると思えば、このように一度各メソッドの振る舞いをしっかり確認しておくことは重要であると再認識した次第です。

しかし、コレクションのメソッドが全部で100以上ある中で現在確認したのは9個のみ。
先は長そうです…