☠
Generator の yield from を do-while ループの中で使ったら死んだ
問題
$response = $this->client->getMembers();
において,結果は以下のような応答になるとします。一覧取得系の Web API でよくある形です。
// 1ページ目
/* $response->members() #=> */ [new Member(id: 1), new Member(id: 2)];
/* $response->nextCursor() #=> */ 'xxx';
// 2ページ目
/* $response->members() #=> */ [new Member(id: 3), new Member(id: 4)];
/* $response->nextCursor() #=> */ null;
このとき,以下のコードは何が問題でしょうか?どこをどう直すべきでしょうか?
/**
* ページネーションしながら全てのメンバーを遅延処理で取得します。
*
* @return Generator<int, Member>
*/
public function members(): Generator
{
// カーソルの初期値は NULL
$cursor = null;
do {
// 現在のカーソルにおけるページを取得
$response = $this->client->getMembers($cursor);
// 現在のページのメンバー一覧を返す
yield from $response->members();
} while ($cursor = $response->nextCursor()); // 次のカーソルが取れる間は継続
}
回答
/**
* ページネーションしながら全てのメンバーを遅延処理で取得します。
*
* @return Generator<int, Member>
*/
public function members(): Generator
{
// カーソルの初期値は NULL
$cursor = null;
do {
// 現在のカーソルにおけるページを取得
$response = $this->client->getMembers($cursor);
// 現在のページのメンバー一覧を返す
- yield from $response->members();
+ foreach ($response->members() as $member) {
+ yield $member;
+ }
} while ($cursor = $response->nextCursor()); // 次のカーソルが取れる間は継続
}
解説
実はPHP 公式マニュアルでも触れられてはいます[1]。ここではこれを噛み砕き,今回の問題に合わせて説明してみます。
array
は全て言語仕様上は連想配列
PHP の 他の多くの言語では区別されている部分ですが, PHP ではリストと連想配列をどちらも array
として扱っています。PHP の最大の特徴の 1 つですね。
yield from
に配列を与えると連想配列としてのキーも列挙される
yield from [new Member(id: 1), new Member(id: 2)];
yield from [new Member(id: 3), new Member(id: 4)];
これを分解するとどうなるでしょうか?こうでしょうか?
yield new Member(id: 1);
yield new Member(id: 2);
yield new Member(id: 3);
yield new Member(id: 4);
…いいえ,こうなります。
yield 0 => new Member(id: 1);
yield 1 => new Member(id: 2);
yield 0 => new Member(id: 3);
yield 1 => new Member(id: 4);
もし PHP がリストと連想配列を区別する言語であれば,以下のようにジェネレータの型も(言語レベルで)区別して 2 つ用意されたかもしれません。
正格評価データ型 | 遅延評価データ型 |
---|---|
リスト | Generator<TValue> |
連想配列 | Generator<TKey, TValue> |
ところが,実際は Generator<TKey, TValue>
(ジェネリクスは言語レベルでは存在しない)の 1 つのみです。 リストとして使っているつもりの配列を yield from
に食わせたらキーつきで列挙されてしまいます。
一方で,修正後のようにキーを省略して yield
を書くと,複数回呼ばれても連番として一貫性のあるキーを割り振ってくれるようになります。
foreach ($response->members() as $member) {
yield $member;
}
yield 0 => new Member(id: 1);
yield 1 => new Member(id: 2);
yield 2 => new Member(id: 3);
yield 3 => new Member(id: 4);
修正前のコードで引き起こされる問題
もし修正前のコードを書いたとしても,途中で一回も配列変換せずに Generator
として最後まで使う場合は問題ありません。
foreach ($this->members() as $i => $member) {
echo "{$i}: Member({$member->id})\n";
}
0: Member(1)
1: Member(2)
0: Member(3)
1: Member(4)
一度でも配列に変換しようものなら,結果は後勝ちで上書きされてしまいます。
$items = iterator_to_array($this->members());
$items = [...$this->members()]; // シンタックスシュガー
0: Member(3)
1: Member(4)
1 つのジェネレータ関数の中で yield from
が繰り返し呼ばれる場合,キーの重複に気を配りましょう。
Discussion