IteratorAggregateとCountableで自作コレクションを作る

IteratorAggregateは、getIterator()でTraversableを返すだけでそのオブジェクトをforeach可能にするインターフェースです。Countableを併せて実装すると、count()も使えるようになります。

配列をそのまま持ち回す設計をやめて、コレクションクラスに包む。中級者になると一度は通る道だと思います。ただ、包んだ瞬間にforeachが効かなくなって、慌ててIteratorを実装して、そこから沼にはまる。私も昔やりました。

結論から言うと、たいていの場合はIteratorではなくIteratorAggregateで足ります。むしろそちらのほうが安全です。

なぜIteratorを直接実装しないほうがいいのか

Iteratorインターフェースはcurrent(), key(), next(), rewind(), valid()の5つを実装する必要があります。数が多いのも面倒ですが、本当の問題は「反復の位置をオブジェクト自身が持ってしまう」ことです。

<?php
// Iterator を自前実装したコレクション(よくない例)
foreach ($collection as $a) {
    foreach ($collection as $b) {
        // 内側のループが rewind() して位置を巻き戻すので
        // 外側のループが壊れる
    }
}

同じオブジェクトを二重ループで回すと内部ポインタを共有してしまい、外側のループが意図しない終わり方をします。実務だと「配列同士の突き合わせ」でうっかりやりがちなパターンですね。

getIteratorは何を返せばいいのか

IteratorAggregateが要求するのはgetIterator()ひとつだけです。シグネチャはgetIterator(): Traversableで、戻り値はTraversableを実装したオブジェクトである必要があります。一番手軽なのはArrayIteratorです。

<?php
declare(strict_types=1);

final class UserCollection implements IteratorAggregate, Countable
{
    /** @param list<User> $users */
    public function __construct(private readonly array $users)
    {
    }

    public function getIterator(): Traversable
    {
        return new ArrayIterator($this->users);
    }

    public function count(): int
    {
        return count($this->users);
    }
}

これだけでforeach ($collection as $user)count($collection)も通ります。反復のたびにArrayIteratorが新しく作られるので、さっきの二重ループ問題も起きません。

ちなみにコンストラクタのprivate readonlyプロパティ昇格はPHP 8.1以降の書き方です。8.0で動かすなら普通のプロパティ宣言に戻してください。

getIteratorが配列を返すとどうなるか

ここが最初のハマりどころです。素直にreturn $this->users;と書きたくなりますが、これはTypeErrorになります。配列はTraversableではないからです。

<?php
public function getIterator(): Traversable
{
    return $this->users; // TypeError: 戻り値は Traversable でなければならない
}

「foreachできるもの=Traversable」ではありません。配列はforeachできますが、Traversableは実装していません。両方を受け入れたいときの型はiterable(=Traversable|array)です。この区別はけっこう混同されている気がします。

もうひとつ、古いコードを触るときの注意。PHP 8.1以降、getIterator()に戻り値型を書いていないと「Return type should either be compatible with IteratorAggregate::getIterator(): Traversable」という非推奨の通知が出ます。素直に: Traversableを足すのが正解で、どうしても直せない事情がある場合だけ#[\ReturnTypeWillChange]で一時的に黙らせる、という順番です。

ジェネレータを返すと何が嬉しいのか

GeneratorはIteratorを実装していて、つまりTraversableです。戻り値型はTraversableのままでもいいですし、Generatorに狭めても問題ありません。これで「絞り込みながら返す」がメモリを食わずに書けます。

<?php
public function getIterator(): Generator
{
    foreach ($this->users as $user) {
        if ($user->isActive()) {
            yield $user->id() => $user;
        }
    }
}

ジェネレータは一度回すと使い切りですが、foreachのたびにgetIterator()が呼ばれて新しいGeneratorが作られるので、同じオブジェクトを何度foreachしても平気です。ここがIteratorAggregateの地味に効いてくるところだと思います。

ただし、この形にすると要素数が事前にわかりません。Countable::count()を元の配列の件数で実装したままだと、foreachで回る件数とcount()の返す件数がズレます。絞り込みを入れるなら、count()の実装も合わせて見直す必要があります。

Countableを付けないとcount()は落ちる

PHP 8.0以降、配列でもCountableでもない値をcount()に渡すとTypeErrorになります。7系では警告のうえで1が返っていたので、移行時にここで死ぬコードをそこそこ見ました。

<?php
// Countable を実装していないコレクション
count($collection);
// TypeError: count(): Argument #1 ($value) must be of type Countable|array

要素数を出したいなら素直にCountableを実装します。ジェネレータ版で件数を数えたいだけなら、iterator_count()という手もあります。

<?php
$n = iterator_count($collection); // 中身を最後まで消費して数える

名前のとおり全部回してから数えるので、大量データで気軽に呼ぶ関数ではありません。件数が頻繁に要るなら、やはりCountableを実装して安いカウントを返すほうがいいです。

iterator_to_arrayのキー衝突に注意

コレクションを配列に戻したい場面は普通にあります。そこで使うのがiterator_to_array()ですが、第2引数のpreserve_keysがデフォルトでtrueである点は覚えておいたほうがいいです。

<?php
// getIterator() が「部署ID => ユーザー」を yield している場合
$rows = iterator_to_array($collection);
// 同じ部署IDが複数あると、後勝ちで上書きされて件数が減る

$rows = iterator_to_array($collection, preserve_keys: false);
// 0 から振り直されるので、全件そのまま残る

エラーも警告も出ずに静かに件数が減るので、原因を探すのに時間を溶かしやすい類のバグです。キーに意味がないなら、最初からfalseを渡しておくくらいでちょうどいいと思います。

なお、iterator_to_array()とiterator_count()は、PHP 8.2から第1引数がiterableに広がって配列も直接渡せるようになりました。8.1以前では配列を渡すとTypeErrorなので、「引数が配列かTraversableか分からない」コードでは自分で分岐しておく必要があります。

まとめ

自作コレクションを作るときは、まずIteratorAggregateとCountableの二枚だけ実装する。getIteratorはArrayIteratorかジェネレータを返す。Iteratorの5メソッドを自前で書くのは、本当に特殊な反復ロジックが必要になったときだけでいい、というのが私の落としどころです。

配列をそのまま渡し回していた頃に比べると、型が効いて補完も効いて、なにより「このコレクションで何ができるか」がクラスを見れば分かるようになります。手間の割に見返りは大きいほうだと思います。

よくある質問

Q. IteratorAggregateとIteratorはどちらを使うべきですか?
A. 迷ったらIteratorAggregateです。実装するメソッドが1つで済み、反復状態をオブジェクトが持たないため二重ループでも壊れません。Iteratorが必要なのは、外部から手動でnext()やrewind()を制御したいような特殊なケースだけです。

Q. getIterator()で配列を返せないのはなぜですか?
A. 戻り値型がTraversableで、配列はTraversableを実装していないためです。ArrayIteratorに包むか、foreachでyieldしてジェネレータを返してください。

Q. count($collection)がTypeErrorになります。
A. PHP 8.0以降、配列でもCountableでもない値をcount()に渡すとTypeErrorになります。コレクションクラスにCountableを実装してcount(): intを定義するか、iterator_count()を使ってください。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です