🕳️

PHP の array_filter() の落とし穴

2024/12/24に公開

こんにちは。 SAW です。
最近、 森永製菓のビスケット にハマってます。
MARIE と CHOICE が特にお気に入りです🍪

PHP で配列を操作するための標準関数の中に、 array_filter() という関数があります。
この関数は、特定の条件に合致した要素のみを取り出した配列を新たに生成します。

array_filter() の利用例
$array = [1, 2, 3, 4, 5];
$filtered = array_filter($array, fn ($x) => $x % 2 === 0);

JavaScript にも、同様のメソッドとして、 Array.filter() が存在します。
こちらも、条件に合致する要素のみを取り出した配列を新たに生成します。

Array.filter() の利用例
const arr = [1, 2, 3, 4, 5];
const filtered = arr.filter((x) => x % 2 === 0);

どちらも 配列の要素の絞り込み という機能は同じですが、少しだけ挙動が異なります。

本記事では、 PHP の array_filter() の落とし穴 と、その 回避方法 を紹介します。

対象読者

本記事で想定する読者層は次の通りです。

  • PHP の基礎的な知識を有している
  • 一般的な配列操作のメソッド (filter, map) について基本的な知識を有している

array_filter() の落とし穴

エラーが起きる例

array_filter() で条件に合致する要素に絞り込んだ配列について、各要素ごとに操作するような場合は注意が必要です。

例えば、以下のコード例のように、 nameage をキーにもつ連想配列の配列から、年齢 (age) が 18 歳より大きい要素を絞り込んだあと、 array_map()[1] を利用して名前 (name) のリストを生成する場合を考えます。

array_filter() で絞り込んだ要素を順に表示しようとするコード例 (失敗)
$array = [
    ['name' => 'John', 'age' => 23],
    ['name' => 'Lisa', 'age' => 15],
    ['name' => 'Karen', 'age' => 24],
    ['name' => 'Fred', 'age' => 19],
];

// 年齢が 18 歳より大きいデータのみに絞り込み
$filtered = array_filter($array, fn ($x) => $x['age'] > 18);

// 年齢が 18 歳より大きい名前の配列を作成
$mapped = array_map(fn($x) => $x['name'], $filtered);

for ($i = 0; $i < count($mapped); $i++) {
  echo $mapped[$i] . PHP_EOL;
}

一見、実行してもエラーが発生しなさそうなコード例ですが、上記のコードを実行すると以下のエラーメッセージが表示されます。

エラーメッセージ
PHP Warning:  Undefined array key 1

どうやら、未定義の配列のキーにアクセスしているようですが、一体どこで未定義のキーにアクセスしているのでしょうか?

原因調査

前節のコード例の $mapped の構造を確認してみましょう。
以下は var_dump() で出力した $mapped の内容です。

$mapped に格納されているデータ
array(3) {
  [0]=>
  string(4) "John"
  [2]=>
  string(5) "Karen"
  [3]=>
  string(4) "Fred"
}

なんということでしょう! 配列の添字が連番になってないではありませんか!

前節のコード例では、 for 文で $mapped[0], $mapped[1], $mapped[2], ... のように、0 から順に添字で配列の要素にアクセス していました。
しかし、 $mapped には 1 の添字の要素はない ため、未定義の配列のキーにアクセスしているというエラーが発生しました。

$filtered の中身も確認してみると、同様に配列の添字が連番になっていないことがわかります。

$filtered に格納されているデータ
array(3) {
  [0]=>
  array(2) {
    ["name"]=>
    string(4) "John"
    ["age"]=>
    int(23)
  }
  [2]=>
  array(2) {
    ["name"]=>
    string(5) "Karen"
    ["age"]=>
    int(24)
  }
  [3]=>
  array(2) {
    ["name"]=>
    string(4) "Fred"
    ["age"]=>
    int(19)
  }
}

array_filter() のドキュメントを確認すると、以下のように、 要素のキーは保存される ことが書かれています。
連想配列だけでなく通常の配列も同じため、添字が歯抜けの配列が生成されます。

Array keys are preserved, and may result in gaps if the array was indexed.

落とし穴の回避方法

array_values() を用いる例

array_filter() のドキュメントには、以下のように array_values() を使うことで配列の添字が振り直されると記述されています。

The result array can be reindexed using the array_values() function.

前章のコード例の array_filter() の結果を array_values() に渡すように書き換えてみます。

array_values() を利用して書き直した例
  // 年齢が 18 歳より大きいデータのみに絞り込み
- $filtered = array_filter($array, fn ($x) => $x['age'] > 18);
+ $filtered = array_values(array_filter($array, fn ($x) => $x['age'] > 18));

  // 年齢が 18 歳より大きい名前の配列を作成
  $mapped = array_map(fn($x) => $x['name'], $filtered);

  for ($i = 0; $i < count($mapped); $i++) {
    echo $mapped[$i] . PHP_EOL;
  }

array_values() で添字が振り直されているため、前章のエラーが解消されました。
修正したプログラムの実行結果は以下の通りです。

実行結果
John
Karen
Fred

foreach 文を用いる例

別の方法として、 for 文を foreach 文に書き直してみます。
foreach 文を利用することで、 配列の添字を利用することなく 各要素を取得するため、添字が歯抜けになっているかを意識する必要がありません。

for 文を foreach 文に書き換えた例は以下の通りです。

foreach 文で書き直した例
  // 年齢が 18 歳より大きいデータのみに絞り込み
  $filtered = array_filter($array, fn ($x) => $x['age'] > 18);

  // 年齢が 18 歳より大きい名前の配列を作成
  $mapped = array_map(fn($x) => $x['name'], $filtered);

- for ($i = 0; $i < count($mapped); $i++) {
-   echo $mapped[$i] . PHP_EOL;
- }
+ foreach ($mapped as $element) {
+   echo $element . PHP_EOL;
+ }

こちらもエラーが発生することなく、 array_values() を利用した例と同様の実行結果が得られます。

添字を使った配列の要素へのアクセスの是非について

本記事のコード例では、 for 文で添字を使って配列の要素にアクセスをしていました。
読者の方の中には、「そもそも for 文で書くのがよろしくない」と思われた方もいると思います。
実際に、 array_filter() で配列の添字が歯抜けになった影響で、未定義のキーを使ったアクセスとしてエラーが発生していました。

言語の文法や機能にもよりますが、 PHP の foreach 文のように、 添字を使わずに配列の要素にアクセス する書き方ができるのであれば、そちらを使った方が良さそうです。
以下の記事で扱っている言語は TypeScript ですが、同様のことが「おわりに」で述べられています。

あるいは、for...of を使うなどなるべくインデックスアクセスに頼らないコードを意識してみるとよいでしょう。

https://azukiazusa.dev/blog/typescript-no-unchecked-indexed-access/

今回のコード例のように、配列の要素を 1つずつ取り出して操作するような場合については、 for 文で添字を使ってアクセスするのではなく、 foreach 文を使って要素の値を変数に取り出すほうが良いでしょう。

添字を使いたいケース

以下のコード例のように、 PHPUnit などで入り組んだ構造のデータについてテストする際には、 アクセスする要素の位置を指定 するために添字を使って配列の要素にアクセスすることがあります。

PHPUnit で入り組んだ構造のデータをテストする例
public function testGetLessons(): void
{
    $lessons = Lesson::where('author_id', 1);
    $this->assertEquals('lesson title', $lessons[0]->title);
    $this->assertCount(2, $lessons[0]->chapters);
    $this->assertEquals('chapter title 1', $lessons[0]->chapters[0]->title);
    $this->assertEquals('chapter title 2', $lessons[0]->chapters[1]->title);
}

PHP の Array destructuring を利用すれば、要素を添字を利用せずに、先頭から要素を取得できますが、少しコードが長くなってしまいます。
特定の位置の要素のみ取り出すといった制御もできないので、テストコードでは添字を使う方が便利な場合もあります。

Array destructuring を使って記述する場合の例
public function testGetLessons(): void
{
    $lessons = Lesson::where('author_id', 1);
    [$lesson] = $lessons;
    [$chapter1, $chapter2] = $lesson->chapters;
    $this->assertEquals('lesson title', $lesson->title);
    $this->assertCount(2, $lesson->chapters);
    $this->assertEquals('chapter title 1', $chapter1->title);
    $this->assertEquals('chapter title 2', $chapter2->title);
}

まとめ

本記事のまとめは次の通りです。

  • array_filter() の落とし穴について説明
  • 落とし穴の回避手段を紹介

PHP の配列は少し独特な仕様をしていると、改めて感じました(笑)

参考文献

https://www.php.net/manual/en/function.array-filter.php

脚注
  1. array_filter()array_map() では、どちらも操作対象の配列とコールバック関数を指定しますが、 引数の順序が異なる 点に注意してください。 ↩︎

Discussion