1. ホーム
  2. php

[解決済み】PHPの'foreach'は実際どのように動作するのですか?

2022-03-18 14:50:08

質問

前置きが長くなりましたが、私はこのような 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 */

...特に予想外のことはなく、実際、quot;コピー・オブ・ソース"説を支持しているようです。


質問事項

どうなっているのでしょうか?私のC-fuでは、PHPのソースコードを見ただけで、適切な結論を導き出すことはできませんので、どなたか英語に翻訳していただけると幸いです。

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

  • これは正しく、全体の話なのでしょうか?
  • そうでない場合、実際はどうなのか?
  • 配列ポインタを調整するような関数( each() , reset() など)中に foreach は、ループの結果に影響を与える可能性がありますか?

どのように解決するのですか?

foreach は、3種類の値に対する反復処理をサポートしています。

  • 配列
  • 通常のオブジェクト
  • 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();
    /* ... */
}

内部クラスでは、実際のメソッド呼び出しは、本質的に単なるミラーリングである内部APIを使用することで回避されます。 Iterator インターフェイスを C レベルで提供します。

配列とプレーンオブジェクトのイテレーションは、かなり複雑です。まず最初に、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) を増加させるだけだということです。何らかの変更を行った場合にのみ、本当のコピー("duplication"と呼ばれます)が行われます。参照 あなたは騙されている は、このトピックについてのより広範な紹介です。

PHP 5

内部配列ポインタとHashPointer

PHP 5 の配列は、専用の "内部配列ポインタ" (IAP) を持ち、変更を適切にサポートします。要素が削除されるたびに、IAP がこの要素を指しているかどうかがチェックされます。要素が削除されるたびに、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 は、現在の要素へのポインタとそのハッシュをフォアグラウンドごとの HashPointer . ループ本体が実行された後、IAPがまだ存在する場合は、この要素に設定し直されます。しかし、要素が削除された場合は、IAPが現在ある場所を使用します。この方式は、大まかには動作しますが、奇妙な動作も多く、以下にそのいくつかを紹介します。

配列の重複

IAPは、配列の可視的な機能( current そのため、IAPの変更はcopy-on-writeセマンティクスのもとでは変更としてカウントされます。このことは、残念ながら 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);

ここです。 $arr のIAP変更を防ぐため、重複することになります。 $arr に漏れてしまうので $outerArr . 上記の条件からすると,配列は参照ではなく(is_ref=0),2箇所で使用されている(refcount=2)ことになります.この条件は不幸なことで、最適とは言えない実装の産物です(ここでは反復中の修正の心配はないので、そもそもIAPを使う必要はないのです)。

(*)インクリメントする refcount は無害に聞こえますが、COW (copy-on-write) のセマンティクスに違反しています。これは refcount=2 の配列の IAP を変更しようとしていることを意味しますが、COW では refcount=1 の値に対してのみ変更が可能であると規定されています。この違反は、反復配列のIAPの変更が観察可能であるため、(COWは通常透明ですが)ユーザーから見える動作の変化をもたらします - ただし、配列の最初の非IAPの変更までです。代わりに、3つの有効なオプションは、a)常に複製する、b)配列の refcount C) IAP を一切使用しない (PHP 7 の解決法)。

位置の繰り上げ順序

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

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において $array は refcount=1 で始まるので、この値は foreach : のみです。 refcount がインクリメントされます。ループ本体で配列が変更されると(その時点で refcount=2 になっている),その時点で複製が行われます.Foreach は、変更されていない $array .

  • テストケース3では、再び配列が重複しないように、このように foreach のIAPを変更することになります。 $array という変数があります。反復の終わりには、IAPはNULL(反復が終了したことを意味する)であり、これは each を返すことで false .

  • テストケース4と5では、両方とも eachreset はバイリファレンス関数である。また $array には refcount=2 を渡すと、重複することになります。そのため foreach は、再び別の配列で動作することになります。

例 の効果 current をforeachで実行します。

様々な重複の動作を示す良い方法は、その動作を観察することです。 current() 関数の内部で foreach のループになります。この例で考えてみましょう。

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

ここで、以下のことを知る必要があります。 current() は、配列を変更しないにもかかわらず、バイリファレンス関数(実際にはプリファードリファレンス)であることを示します。のような他のすべての関数とうまくやるためには、そうする必要があります。 next はすべてby-refです。参照渡しは、配列を分離しなければならないことを意味し、したがって $array であり foreach-array が異なることになります。あなたが得る理由は 2 ではなく 1 も前述しています。 foreach は配列ポインタを進めます。 前に を実行した後ではなく、ユーザーコードを実行した後になります。そのため、コードが最初の要素にあるにもかかわらず foreach はすでに2番目へのポインタを進めています。

では、ちょっとだけ手を加えてみましょう。

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

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

バイリファレンスの繰り返しでも同じ動作になります。

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

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

もうひとつの小さなバリエーションとして、今度は配列を別の変数に代入してみます。

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

ここでは $array はループ開始時に2であるため、今回だけは実際に前もって複製を行う必要があります。したがって $array とforeachで使用する配列は最初から完全に分離されます。そのため、IAPの位置がループ前のどこにあったとしても(この場合は最初の位置にあった)、その位置が取得されるのです。

例 イテレーション中の修正

反復中の変更を考慮することは、私たちのforeachの問題のすべての起源であり、このケースのいくつかの例を検討するのに役立ちます。

同じ配列に対するネストされたループを考えてみましょう(本当に同じ配列であることを確認するためにバイリファレンス反復が使用されます)。

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を使用します!

もうひとつの結果である 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 restoreは、要素へのポインタとそのハッシュを使用して、その要素がまだ存在するかどうかを判断します。しかし ハッシュには衝突があり、ポインタは再利用できるのです。つまり、配列のキーを注意深く選択することで 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 は新しく挿入された要素に直接ジャンプすることになり、ループをショートカットすることができます。

ループ中の反復処理された実体の置換

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

$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 は置換が行われた後、もう一方のエンティティを最初から繰り返し始めます。

PHP 7

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

もしまだ覚えているなら、配列の反復処理の主な問題は、反復処理の途中で要素を削除することをどう処理するかということでした。PHP 5 では、この目的のために単一の内部配列ポインタ (IAP) を使用していましたが、 これは最適とは言えませんでした。なぜなら、複数の foreach ループを同時にサポートするために 1 つの配列ポインタを伸ばさなければならなかったからです。 との相互作用 reset() などを上乗せしています。

PHP 7 では別のアプローチを採用しています。つまり、任意の数の安全な外部ハッシュテーブル型イテレータを作成することをサポートしています。これらのイテレータは配列に登録する必要があり、それ以降は IAP と同じセマンティクスを持つようになります。配列の要素が削除されると、その要素を指しているすべてのhashtableイテレータは次の要素に進みます。

これは、以下のことを意味します。 foreach は、もはやIAPを使用しません。 まったく . その foreach のループの結果には全く影響を与えません。 current() などの関数によって自身の動作が影響を受けることはありません。 reset() などがあります。

配列の重複

PHP 5 と PHP 7 の間のもうひとつの重要な変更点は、配列の複製に関連するものです。IAP が使用されなくなったので、値ごとの配列の反復処理では refcount をインクリメントします (配列を複製するのではありません)。の間に配列が変更された場合は foreach のループでは、その時点で(コピーオンライトにしたがって)複製が発生し foreach は、古い配列で作業を続けます。

ほとんどの場合、この変更は透過的であり、パフォーマンスが向上する以外の効果はありません。しかし、一つだけ異なる挙動を示す場合があります。それは、配列があらかじめ参照であった場合です。

$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 */

以前は、参照配列の値による反復は特殊なケースでした。この場合、重複は発生しないので、反復処理中の配列のすべての変更がループに反映されます。PHP 7 では、この特別なケースはなくなりました。配列の値による反復処理は、以下のようになります。 常に は、ループ中のいかなる変更も無視して、元の要素で動作し続けます。

もちろん、これは参照による反復には適用されません。もし参照による反復処理を行えば、すべての変更はループに反映される。興味深いことに、プレーンなオブジェクトの値による反復処理にも同じことが言える。

$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 */

これは、オブジェクトのバイハンドルセマンティクスを反映している(つまり、バイバリューのコンテキストでも参照のようにふるまう)。

テストケースから、いくつかの例を考えてみましょう。

  • テストケース1と2は、同じ出力を保持します。値別配列の反復は、常に元の要素で動作し続けます。(この場合 refcounting と重複の挙動はPHP 5とPHP 7で全く同じです)。

  • テストケース3の変更点。 Foreach はIAPを使用しなくなったので each() はループの影響を受けません。ループの前後で同じ出力になります。

  • テストケース4と5はそのままです。 each()reset() はIAPを変更する前に配列を複製します。 foreach はまだ元の配列を使用します。(たとえ配列が共有されていたとしても、IAPの変更は重要ではありません)。

2つ目の例は current() 異なる reference/refcounting のコンフィギュレーションを使用します。これはもはや意味がありません。 current() はループの影響を全く受けないので、その戻り値は常に同じままです。

しかし、反復中の修正を考慮すると、興味深い変化が得られます。新しい動作がより健全なものであることがお分かりいただけると思います。最初の例です。

$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による両ループの相互干渉がなくなったためです。

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

$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

以前は、HashPointer リストア機構は、削除された要素と同じように見えたので、新しい要素にすぐにジャンプしました(ハッシュとポインタが衝突したため)。もはや要素のハッシュには何も依存していないため、これはもはや問題ではありません。