PHP の「foreach」は実際どのように動作するのでしょうか? 質問する

PHP の「foreach」は実際どのように動作するのでしょうか? 質問する

まず、それが何であるか、何をするか、どのように使用するかを知っているということを述べておきますforeach。この質問は、それが内部でどのように動作するかに関するものであり、「配列をループするにはこうしますforeach」というような回答は求めていません。


長い間、foreach配列自体では動作すると思っていました。その後、配列のコピーでも動作するという多くの言及を見つけ、それ以来、これで話は終わりだと思っていました。しかし、最近この件について議論することになり、少し実験してみたところ、実際にはこれが 100% 真実ではないことがわかりました。

どういうことか説明しましょう。次のテストケースでは、次の配列を扱います。

$array = array(1, 2, 3, 4, 5);

テストケース1:

foreach ($array as $item) {
  echo "$item\n";
  $array[] = $item;
}
print_r($array);

/* Output in loop:    1 2 3 4 5
   $array after loop: 1 2 3 4 5 1 2 3 4 5 */

これは、ソース配列を直接操作していないことを明確に示しています。そうでない場合、ループ中に配列にアイテムを常にプッシュするため、ループは永久に継続します。ただし、念のため、次のようになります。

テストケース2:

foreach ($array as $key => $item) {
  $array[$key + 1] = $item + 2;
  echo "$item\n";
}

print_r($array);

/* Output in loop:    1 2 3 4 5
   $array after loop: 1 3 4 5 6 7 */

これは当初の結論を裏付けるもので、ループ中にソース配列のコピーを操作していることになります。そうでなければ、ループ中に変更された値が表示されることになります。しかし...

見てみるとマニュアル、次のような記述があります。

foreach が最初に実行を開始すると、内部配列ポインターは配列の最初の要素に自動的にリセットされます。

foreachそうです...これは、ソース配列の配列ポインターに依存していることを示唆しているようです。しかし、ソース配列を操作していないことが証明されたばかりですよね? まあ、完全にはそうではありませんが。

テストケース3:

// Move the array pointer on one to make sure it doesn't affect the loop
var_dump(each($array));

foreach ($array as $item) {
  echo "$item\n";
}

var_dump(each($array));

/* Output
  array(4) {
    [1]=>
    int(1)
    ["value"]=>
    int(1)
    [0]=>
    int(0)
    ["key"]=>
    int(0)
  }
  1
  2
  3
  4
  5
  bool(false)
*/

つまり、ソース配列を直接操作していないにもかかわらず、ソース配列ポインタを直接操作していることになります。ループの最後にポインタが配列の末尾にあるという事実がこれを示しています。ただし、これは真実ではありません。もし真実だったとしたら、テストケース1永遠にループしてしまいます。

PHP マニュアルには次のようにも記載されています。

foreach は内部配列ポインターに依存しているため、ループ内でそれを変更すると予期しない動作が発生する可能性があります。

さて、その「予期しない動作」が何であるかを調べてみましょう (技術的には、何を期待すればよいか分からなくなるため、すべての動作が予期しないものになります)。

テストケース4:

foreach ($array as $key => $item) {
  echo "$item\n";
  each($array);
}

/* Output: 1 2 3 4 5 */

テストケース5:

foreach ($array as $key => $item) {
  echo "$item\n";
  reset($array);
}

/* Output: 1 2 3 4 5 */

...そこには予想外のことは何もなく、実際、「ソースのコピー」理論を支持しているようです。


質問

ここで何が起こっているのでしょうか? 私の C スキルは、PHP ソース コードを見ただけで適切な結論を導き出せるほど十分ではありません。どなたか英語に翻訳していただけるとありがたいです。

これは配列のコピーforeachで動作するように見えますが、ループ後にソース配列の配列ポインターを配列の末尾に設定します。

  • これは正しいですか?そして全体の話ですか?
  • そうでないなら、それは実際に何をしているのでしょうか?
  • 中に配列ポインターを調整する関数 ( など) を使用すると、ループの結果に影響が出るような状況はありeach()ますreset()foreach?

ベストアンサー1

foreach3 種類の異なる値の反復処理をサポートします。

  • 配列
  • 通常のオブジェクト
  • Traversableオブジェクト

以下では、さまざまなケースで反復がどのように機能するかを正確に説明します。最も単純なケースはTraversableオブジェクトです。これはforeach基本的に、次のようなコードの構文糖にすぎません。

foreach ($it as $k => $v) { /* ... */ }

/* translates to: */

if ($it instanceof IteratorAggregate) {
    $it = $it->getIterator();
}
for ($it->rewind(); $it->valid(); $it->next()) {
    $v = $it->current();
    $k = $it->key();
    /* ... */
}

Iterator内部クラスの場合、基本的にC レベルのインターフェースをミラーリングするだけの内部 API を使用することで、実際のメソッド呼び出しが回避されます。

配列とプレーン オブジェクトの反復処理は、はるかに複雑です。まず、PHP では「配列」は実際には順序付けられた辞書であり、この順序に従ってトラバースされることに注意してください ( などを使用しない限り、挿入順序と一致しますsort)。これは、キーの自然な順序による反復処理 (他の言語のリストでよく行われる動作) や、順序がまったく定義されていない反復処理 (他の言語の辞書でよく行われる動作) とは対照的です。

同じことがオブジェクトにも当てはまります。オブジェクトのプロパティは、プロパティ名をその値にマッピングし、さらに可視性の処理を加えた別の (順序付けされた) 辞書と見なすことができます。ほとんどの場合、オブジェクトのプロパティは実際にはこの非効率的な方法で保存されることはありません。ただし、オブジェクトの反復処理を開始すると、通常使用されるパックされた表現が実際の辞書に変換されます。その時点で、プレーン オブジェクトの反復処理は配列の反復処理と非常に似たものになります (そのため、ここではプレーン オブジェクトの反復処理についてはあまり説明しません)。

ここまでは順調です。辞書を反復処理するのはそれほど難しくないはずです。問題は、反復処理中に配列/オブジェクトが変化する可能性があることに気付いたときに発生します。これが発生する方法は複数あります。

  • 参照を使用して反復処理するとforeach ($arr as &$v)$arr参照に変換され、反復処理中に変更できるようになります。
  • PHP 5 では、値で反復処理する場合でも同じことが適用されますが、配列は以前は参照でした。$ref =& $arr; foreach ($ref as $v)
  • オブジェクトにはハンドル渡しのセマンティクスがあり、ほとんどの実用上は参照のように動作します。そのため、反復処理中にオブジェクトを常に変更できます。

反復処理中に変更を許可すると、現在位置している要素が削除される場合に問題が発生します。現在位置している配列要素を追跡するためにポインタを使用するとします。この要素が解放されると、ぶら下がったポインタが残ります (通常はセグメント違反になります)。

この問題を解決するにはさまざまな方法があります。この点で PHP 5 と PHP 7 は大きく異なります。以下では両方の動作について説明します。要約すると、PHP 5 のアプローチはかなり愚かで、あらゆる種類の奇妙なエッジケースの問題を引き起こしましたが、PHP 7 のより複雑なアプローチでは、より予測可能で一貫性のある動作が実現されます。

最後に、PHP はメモリ管理に参照カウントとコピーオンライトを使用していることに注意してください。つまり、値を「コピー」すると、実際には古い値を再利用して参照カウント (refcount) を増やすだけです。何らかの変更を行った場合にのみ、実際のコピー (「複製」と呼ばれる) が行われます。あなたは嘘をつかれているこのトピックに関するより詳しい紹介については、こちらをご覧ください。

PHP5 について

内部配列ポインタとHashPointer

PHP 5 の配列には、変更を適切にサポートする専用の「内部配列ポインタ」(IAP) が 1 つあります。要素が削除されるたびに、IAP がこの要素を指しているかどうかがチェックされます。指している場合は、次の要素に進みます。

foreachは IAP を利用しますが、さらに複雑な点があります。IAP は 1 つだけですが、1 つの配列が複数のforeachループの一部になることがあります。

// Using by-ref iteration here to make sure that it's really
// the same array in both loops and not a copy
foreach ($arr as &$v1) {
    foreach ($arr as &$v) {
        // ...
    }
}

内部配列ポインターを 1 つだけ使用して 2 つのループを同時に実行できるようにするためにforeach、次の処理を実行します。ループ本体が実行される前に、foreach現在の要素へのポインターとそのハッシュを per-foreach にバックアップしますHashPointer。ループ本体の実行後、この要素がまだ存在する場合は、IAP がこの要素に設定されます。ただし、要素が削除されている場合は、IAP が現在ある場所を使用します。このスキームは、大体、何となく機能しますが、奇妙な動作が多数発生する可能性があります。そのいくつかを以下で説明します。

配列の複製

IAP は配列の目に見える機能です (current関数ファミリーを通じて公開されます)。そのため、IAP への変更は、コピーオンライト セマンティクスでは変更としてカウントされます。残念ながら、これは、foreach反復処理する配列を複製することが多くの場合に強制されることを意味します。正確な条件は次のとおりです。

  1. 配列は参照ではありません (is_ref=0)。参照の場合、変更は伝播されるはずなので、重複してはなりません。
  2. 配列の refcount>1 です。refcountが 1 の場合、配列は共有されず、直接変更できます。

配列が重複していない場合 (is_ref=0、refcount=1)、配列のみrefcountが増分されます (*)。さらに、foreach参照が使用されている場合、(重複している可能性のある) 配列は参照に変換されます。

重複が発生する例として次のコードを検討してください。

function iterate($arr) {
    foreach ($arr as $v) {}
}

$outerArr = [0, 1, 2, 3, 4];
iterate($outerArr);

ここで、の IAP の変更がに漏れるのを$arr防ぐために、 が複製されます。上記の条件では、配列は参照ではなく (is_ref=0)、2 か所で使用されています (refcount=2)。この要件は残念なことであり、最適ではない実装の副産物です (ここでは反復処理中に変更される心配がないため、そもそも IAP を使用する必要はありません)。$arr$outerArr

(*)refcountここで を増分することは無害に思えますが、コピーオンライト (COW) のセマンティクスに違反します。つまり、refcount=2 配列の IAP を変更することになりますが、COW では変更は refcount=1 値に対してのみ実行できると規定されています。この違反により、反復配列の IAP の変更は観察可能になりますが、配列の最初の非 IAP 変更までしか観察できないため、ユーザーに見える動作の変更が発生します (COW は通常は透過的です)。代わりに、3 つの「有効な」オプションは、a) 常に複製する、b) を増分せずrefcount、反復配列をループ内で任意に変更できるようにする、c) IAP をまったく使用しない (PHP 7 のソリューション) です。

ポジション昇格順

以下のコード サンプルを正しく理解するために、最後に知っておく必要がある実装の詳細が 1 つあります。データ構造をループする「通常の」方法は、疑似コードで次のようになります。

reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
    code();
    move_forward(arr);
}

しかしforeach、かなり特別なスノーフレークなので、少し違うやり方を選びます。

reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
    move_forward(arr);
    code();
}

つまり、ループ本体が実行される前に、配列ポインターはすでに前方に移動されています。つまり、ループ本体が要素 で動作している間$i、IAP はすでに要素 にあります。これが、反復中の変更を示すコード サンプルが常に現在の要素ではなく次の要素に$i+1なる理由です。unset

例: テストケース

上記の 3 つの側面により、実装の特異性についてほぼ完全な印象が得られるはずですforeach。次に、いくつかの例について説明します。

この時点で、テスト ケースの動作を説明するのは簡単です。

  • テストケース 1 と 2 では、$arrayrefcount=1 から始まるため、 によって複製されることはありません。foreachのみがrefcount増分されます。ループ本体がその後配列 (その時点では refcount=2) を変更すると、その時点で複製が発生します。Foreach は、 の変更されていないコピーに対して作業を続行します$array

  • テスト ケース 3 では、配列は再び複製されないため、変数foreachの IAP が変更されます$array。反復処理の最後に、IAP は NULL (反復処理が完了したことを意味します) になり、eachを返すことによってそれが示されますfalse

  • テストケース 4 と 5 では、eachと は両方ともreset参照関数です。 は渡されるときに$arrayを持つため、複製する必要があります。そのため、は再び別の配列で動作します。refcount=2foreach

例: currentin foreachの効果

current()さまざまな複製動作を示す良い方法は、ループ内の関数の動作を観察することですforeach。次の例を考えてみましょう。

foreach ($array as $val) {
    var_dump(current($array));
}
/* Output: 2 2 2 2 2 */

ここで、current()は配列を変更しないにもかかわらず、by-ref 関数 (実際は、prefer-ref) であることを知っておく必要があります。 などの他のすべての関数 (nextすべて by-ref である) とうまく連携するためには、そうする必要があります。 参照渡しでは、配列を分離する必要があるため、 と$arrayforeach-array異なります。2の代わりに を取得する理由1も上で説明しました。は、ユーザー コードの実行後ではなく、実行前にforeach配列ポインタを進めます。そのため、コードが最初の要素にある場合でも、ポインタはすでに 2 番目の要素に進んでいます。foreach

ここで、ちょっとした変更を試してみましょう。

$ref = &$array;
foreach ($array as $val) {
    var_dump(current($array));
}
/* Output: 2 3 4 5 false */

ここでは is_ref=1 の場合があり、配列はコピーされません (上記と同様)。ただし、参照になったため、by-ref 関数に渡すときに配列を複製する必要がなくなりましたcurrent()。したがってcurrent()、同じ配列で動作します。ただし、ポインタを進めるforeach方法により、off-by-one の動作がまだ見られます。foreach

by-ref 反復を実行する場合も同じ動作になります。

foreach ($array as &$val) {
    var_dump(current($array));
}
/* Output: 2 3 4 5 false */

ここで重要なのは、 foreach が$array参照によって反復処理されるときに is_ref=1 を作成することです。つまり、基本的には上記と同じ状況になります。

もう 1 つの小さなバリエーションとして、今回は配列を別の変数に割り当てます。

$foo = $array;
foreach ($array as $val) {
    var_dump(current($array));
}
/* Output: 1 1 1 1 1 */

ここで、ループが開始されたときの の参照カウントは$array2 なので、実際に最初に複製を行う必要があります。したがって$array、 と foreach で使用される配列は最初から完全に分離されます。そのため、ループ前の IAP の位置 (この場合は最初の位置) が取得されます。

例: 反復中の変更

反復中の変更を考慮しようとすると、すべての foreach の問題が発生するため、このケースの例をいくつか検討すると役立ちます。

同じ配列に対する次のネストされたループを検討してください (by-ref 反復を使用して、実際に同じ配列であることを確認します)。

foreach ($array as &$v1) {
    foreach ($array as &$v2) {
        if ($v1 == 1 && $v2 == 1) {
            unset($array[1]);
        }
        echo "($v1, $v2)\n";
    }
}

// Output: (1, 1) (1, 3) (1, 4) (1, 5)

ここで予想される部分は、(1, 2)要素が削除されたために出力に含まれていないことです1。おそらく予想外なのは、外側のループが最初の要素の後で停止することです。なぜでしょうか?

この理由は、上で説明したネストされたループ ハックです。ループ本体が実行される前に、現在の IAP の位置とハッシュが にバックアップされますHashPointer。ループ本体の後に復元されますが、要素がまだ存在する場合のみです。そうでない場合は、現在の IAP の位置 (それが何であれ) が代わりに使用されます。上記の例では、まさにこれが当てはまります。外側のループの現在の要素は削除されているため、内側のループによって既に完了としてマークされている IAP が使用されます。

バックアップ + 復元メカニズムのもう 1 つの結果はHashPointer、 などを介した IAP の変更がreset()通常は に影響を与えないことですforeach。たとえば、次のコードは がreset()まったく存在しないかのように実行されます。

$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
    var_dump($value);
    reset($array);
}
// output: 1, 2, 3, 4, 5

その理由は、reset()IAP を一時的に変更しても、ループ本体の後に現在の foreach 要素に復元されるためです。reset()ループに強制的に効果を与えるには、現在の要素を追加で削除する必要があり、バックアップ/復元メカニズムが失敗します。

$array = [1, 2, 3, 4, 5];
$ref =& $array;
foreach ($array as $value) {
    var_dump($value);
    unset($array[1]);
    reset($array);
}
// output: 1, 1, 3, 4, 5

しかし、これらの例はまだまともです。復元HashPointerでは要素へのポインターとそのハッシュを使用して、要素がまだ存在するかどうかを判断していることを思い出すと、本当に楽しいことが始まります。ただし、ハッシュには衝突があり、ポインターは再利用できます。つまり、配列キーを慎重に選択することで、foreach削除された要素がまだ存在すると信じ込ませることができ、その要素に直接ジャンプします。例:

$array = ['EzEz' => 1, 'EzFY' => 2, 'FYEz' => 3];
$ref =& $array;
foreach ($array as $value) {
    unset($array['EzFY']);
    $array['FYFY'] = 4;
    reset($array);
    var_dump($value);
}
// output: 1, 4

1, 1, 3, 4ここでは、通常、前のルールに従った出力が期待されます。 は'FYFY'削除された要素と同じハッシュを持ち'EzFY'、アロケータは要素を格納するために同じメモリ位置を再利用します。 そのため、 foreach は新しく挿入された要素に直接ジャンプし、ループをショートカットします。

ループ中に反復エンティティを置換する

最後にもう 1 つ触れておきたい奇妙なケースは、PHP ではループ中に反復エンティティを置換できるということです。つまり、1 つの配列で反復処理を開始し、途中で別の配列に置き換えることができます。または、配列で反復処理を開始し、それをオブジェクトに置き換えることもできます。

$arr = [1, 2, 3, 4, 5];
$obj = (object) [6, 7, 8, 9, 10];

$ref =& $arr;
foreach ($ref as $val) {
    echo "$val\n";
    if ($val == 3) {
        $ref = $obj;
    }
}
/* Output: 1 2 3 6 7 8 9 10 */

ご覧のとおり、この場合、置換が行われると、PHP は最初から他のエンティティの反復処理を開始します。

PHP7 の場合

ハッシュテーブルイテレータ

覚えていると思いますが、配列の反復処理の主な問題は、反復処理の途中で要素を削除する処理方法でした。PHP 5 では、この目的のために単一の内部配列ポインター (IAP) を使用していましたが、複数の同時 foreach ループreset()その上での相互作用などをサポートするために 1 つの配列ポインターを拡張する必要があったため、いくぶん最適ではありませんでした。

PHP 7 uses a different approach, namely, it supports creating an arbitrary amount of external, safe hashtable iterators. These iterators have to be registered in the array, from which point on they have the same semantics as the IAP: If an array element is removed, all hashtable iterators pointing to that element will be advanced to the next element.

This means that foreach will no longer use the IAP at all. The foreach loop will be absolutely no effect on the results of current() etc. and its own behavior will never be influenced by functions like reset() etc.

Array duplication

Another important change between PHP 5 and PHP 7 relates to array duplication. Now that the IAP is no longer used, by-value array iteration will only do a refcount increment (instead of duplication the array) in all cases. If the array is modified during the foreach loop, at that point a duplication will occur (according to copy-on-write) and foreach will keep working on the old array.

In most cases, this change is transparent and has no other effect than better performance. However, there is one occasion where it results in different behavior, namely the case where the array was a reference beforehand:

$array = [1, 2, 3, 4, 5];
$ref = &$array;
foreach ($array as $val) {
    var_dump($val);
    $array[2] = 0;
}
/* Old output: 1, 2, 0, 4, 5 */
/* New output: 1, 2, 3, 4, 5 */

Previously by-value iteration of reference-arrays was special cases. In this case, no duplication occurred, so all modifications of the array during iteration would be reflected by the loop. In PHP 7 this special case is gone: A by-value iteration of an array will always keep working on the original elements, disregarding any modifications during the loop.

This, of course, does not apply to by-reference iteration. If you iterate by-reference all modifications will be reflected by the loop. Interestingly, the same is true for by-value iteration of plain objects:

$obj = new stdClass;
$obj->foo = 1;
$obj->bar = 2;
foreach ($obj as $val) {
    var_dump($val);
    $obj->bar = 42;
}
/* Old and new output: 1, 42 */

This reflects the by-handle semantics of objects (i.e. they behave reference-like even in by-value contexts).

Examples

Let's consider a few examples, starting with your test cases:

  • Test cases 1 and 2 retain the same output: By-value array iteration always keep working on the original elements. (In this case, even refcounting and duplication behavior is exactly the same between PHP 5 and PHP 7).

  • Test case 3 changes: Foreach no longer uses the IAP, so each() is not affected by the loop. It will have the same output before and after.

  • Test cases 4 and 5 stay the same: each() and reset() will duplicate the array before changing the IAP, while foreach still uses the original array. (Not that the IAP change would have mattered, even if the array was shared.)

The second set of examples was related to the behavior of current() under different reference/refcounting configurations. This no longer makes sense, as current() is completely unaffected by the loop, so its return value always stays the same.

ただし、反復中に変更を考慮すると、興味深い変化がいくつか得られます。新しい動作がより理にかなっていると感じていただければ幸いです。最初の例:

$array = [1, 2, 3, 4, 5];
foreach ($array as &$v1) {
    foreach ($array as &$v2) {
        if ($v1 == 1 && $v2 == 1) {
            unset($array[1]);
        }
        echo "($v1, $v2)\n";
    }
}

// Old output: (1, 1) (1, 3) (1, 4) (1, 5)
// New output: (1, 1) (1, 3) (1, 4) (1, 5)
//             (3, 1) (3, 3) (3, 4) (3, 5)
//             (4, 1) (4, 3) (4, 4) (4, 5)
//             (5, 1) (5, 3) (5, 4) (5, 5) 

ご覧のとおり、外側のループは最初の反復後に中止されなくなりました。その理由は、両方のループに完全に独立したハッシュテーブル反復子があり、共有 IAP を介した両方のループの相互汚染がなくなったためです。

現在修正されているもう 1 つの奇妙なエッジ ケースは、同じハッシュを持つ要素を削除して追加したときに発生する奇妙な効果です。

$array = ['EzEz' => 1, 'EzFY' => 2, 'FYEz' => 3];
foreach ($array as &$value) {
    unset($array['EzFY']);
    $array['FYFY'] = 4;
    var_dump($value);
}
// Old output: 1, 4
// New output: 1, 3, 4

以前は、ハッシュ ポインターの復元メカニズムは、削除された要素と同じように見えたため (ハッシュとポインターの衝突のため)、新しい要素に直接ジャンプしていました。要素ハッシュに何にも依存しなくなったため、これはもう問題ではありません。

おすすめ記事