PHP 中的 foreach 工作原理

姚志博發表於2018-07-31

foreach 支援三種不同值的迭代:

  • 陣列
  • 普通物件
  • 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();
    /* ... */
}

對於內部類,通過使用基本上只映象 IteratorC 級介面的內部 API 來避免實際的方法呼叫。

陣列和普通物件的迭代要複雜得多。首先,應該注意的是,PHP 中的“陣列”實際上是有序的字典,它們將按照這個順序遍歷(只要你沒有使用類似的東西就匹配插入順序 sort)。這與通過鍵的自然順序(其他語言中的列表通常如何工作)或根本沒有定義的順序(其他語言中的字典如何工作)相反。

這同樣適用於物件,因為物件屬性可以看作是將屬性名稱對映到其值的另一個(有序)字典,以及一些可見性處理。在大多數情況下,物件屬性實際上並不是以這種相當低效的方式儲存的。但是,如果您開始迭代物件,則通常使用的壓縮表示將轉換為實際字典。那時,普通物件的迭代變得非常類似於陣列的迭代(這就是為什麼我不在這裡討論普通物件迭代)。

到現在為止還挺好。迭代字典不算太難,對吧?當您意識到在迭代期間陣列/物件可以更改時,問題就開始了。有多種方法可以實現:

如果您通過引用迭代使用foreach ($arr as &$v)然後 $ar r轉換為引用,您可以在迭代期間更改它。
在PHP 5中,即使按值迭代,同樣適用,但陣列事先是引用: $ref =& $arr; foreach ($ref as $v)
物件具有通過處理傳遞語義,這必須實際意味著它們的行為類似於引用。因此,在迭代期間總是可以更改物件。
在迭代期間允許修改的問題是刪除當前所在元素的情況。假設您使用指標來跟蹤您當前所在的陣列元素。如果現在釋放此元素,則會留下懸空指標(通常會導致段錯誤)。

有不同的方法來解決這個問題。PHP 5 和 PHP 7 在這方面有很大不同,我將在下面描述這兩種行為。總結是 PHP 5 的方法相當愚蠢並導致各種奇怪的邊緣情況問題,而 PHP 7 更復雜的方法導致更可預測和一致的行為。

作為最後的初始化,應該注意 PHP 使用引用計數和寫時複製來管理記憶體。這意味著如果您“複製”一個值,實際上只是複用舊值並增加其引用計數(refcount)。只有在執行某種修改後,才會執行真正的副本(稱為“複製”)。

PHP 5
內部陣列指標和 HashPointer
PHP 5中的陣列有一個專用的“內部陣列指標”(IAP),它適當地支援修改:每當刪除一個元素時,都會檢查 IAP 是否指向該元素。如果是,則轉發到下一個元素。

雖然 foreach 確實使用了 IAP,但還有一個複雜的問題:只有一個 IAP,但是一個陣列可以是多個 foreach 迴圈的一部分:

// 在這裡使用 by-ref 迭代確保它的真實性
// 在兩個巢狀迴圈中使用的都是同一個陣列,而不是副本
foreach ($arr as &$v1) {
    foreach ($arr as &$v) {
        // ...
    }
}

為了支援只有一個內部陣列指標的兩個同時迴圈,foreach 執行以下魔術方法:在執行迴圈體之前,foreach會將指向當前元素及其雜湊的指標備份到 per-foreach 中 HashPointer。迴圈體執行後,如果 IAP 仍然存在,IAP 將被設定回該元素。但是,如果元素已被刪除,我們將只使用 IAP 當前所在的位置。這個計劃大多有點型別,但是你可以從中獲得許多奇怪的行為,其中一些我將在下面演示。

陣列重複
IAP是陣列的可見特徵(通過 current 函式族公開),因為 IAP 計數的這種更改是在寫時複製語義下的修改。不幸的是,這意味著 foreach 在很多情況下被迫複製它迭代的陣列。確切的條件是:

該陣列不是引用(is_ref = 0)。如果它是一個引用,那麼對它的更改應該傳播,因此不應該重複。
該陣列的refcount> 1。如果refcount為1,則不共享該陣列,我們可以直接修改它。
如果陣列沒有重複(is_ref = 0,refcount = 1),那麼只有它的引用計數會遞增(*)。此外,如果使用foreach by reference,則(可能重複的)陣列將變為引用。

將此程式碼視為發生重複的示例:

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

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

在這裡,$arr將重複以防止IAP更改$arr洩漏到$outerArr。就上述條件而言,陣列不是引用(is_ref = 0),並且在兩個地方使用(refcount = 2)。這個要求是不幸的,並且是次優實現的工件(在迭代期間不需要修改,因此我們實際上並不需要首先使用IAP)。

(*)這裡增加 refcount 聽起來無害,但違反了寫時複製(COW)語義:這意味著我們要修改 refcount = 2 陣列的 IAP,而 COW 規定只能對 refcount 執行修改= 1個值。此違規導致使用者可見的行為更改(而 COW 通常是透明的),因為迭代陣列上的IAP更改將是可觀察的 - 但直到對陣列進行第一次非 IAP 修改。相反,三個“有效”選項將是 a)始終複製,b)不遞增引用計數,從而允許迭代陣列在迴圈中任意修改,或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。這就是為什麼在迭代期間顯示修改的程式碼示例將始終取消設定下一個元素而不是當前元素的原因。

示例:您的測試用例
上面描述的三個方面應該為您提供對 foreach 實現的特性的完全印象,我們可以繼續討論一些示例。

此時,您的測試用例的行為很容易解釋:

在測試用例1和2中 $array,refcount = 1開始,因此foreach不會複製:只有refcount會遞增。當迴圈體隨後修改陣列(在該點具有refcount = 2)時,將在該點處進行復制。Foreach將繼續處理未修改的副本$array。

在測試用例3中,陣列不再重複,因此foreach將修改$array變數的IAP 。在迭代結束時,IAP 為 NULL(意味著迭代完成),each通過返回指示false。

在測試例4和5兩者 each 和 reset 是通過引用功能。它 $array 有一個 refcount=2 傳遞給它們的時間,所以它必須重複。因此 foreach 將再次在單獨的陣列上工作。

例子:current 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 陣列將是不同的。上面提到2的1是你得到的原因:在執行使用者程式碼之前 foreach 推進陣列指標,而不是之後。因此,即使程式碼位於第一個元素,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 在同一陣列上工作。但是,由於 foreach 指標的推進方式,你仍然會看到一個一個一個的行為。

在進行 by-ref 迭代時,您會得到相同的行為:

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麻煩的起源,所以它可以考慮這種情況的一些例子。

考慮在同一個陣列上的這些巢狀迴圈(其中使用 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,它已被內部迴圈標記為已完成!

HashPointe r備份+恢復機制的另一個後果是 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 最終直接跳到新插入的元素,從而短路迴圈。

在迴圈期間替換迭代的實體
我想提到的最後一個奇怪的情況是,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
Hashtable 迭代器
如果您還記得,陣列迭代的主要問題是如何處理迭代中的元素刪除。為了這個目的,PHP 5 使用了單個內部陣列指標(IAP),這有點不是最理想的,因為必須拉伸一個陣列指標以支援多個同時的 foreach 迴圈和與之互動 reset() 等。

PHP 7 使用不同的方法,即它支援建立任意數量的外部,安全的雜湊表迭代器。這些迭代器必須在陣列中註冊,從那時起它們具有與IAP相同的語義:如果刪除了一個陣列元素,則指向該元素的所有雜湊表迭代器將被提前到下一個元素。

這意味著將的 foreach 不再使用 IAP 可言。foreach 迴圈對結果 current() 等絕對沒有影響,並且它自己的行為永遠不會受到諸如此類函式的影響 reset()。

陣列重複
PHP 5 和 PHP 7 之間的另一個重要變化涉及陣列複製。現在不再使用 IAP,在所有情況下,按值陣列迭代只會執行引用計數增量(而不是重複陣列)。如果在 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保留相同的輸出:按值陣列迭代始終保持對原始元素的處理。(在這種情況下,甚至引用和重複行為在 PHP 5 和 PHP 7 之間完全相同)。

測試用例3更改:Foreach 不再使用 IAP ,因此 each() 不受迴圈影響。它之前和之後將具有相同的輸出。

測試用例 4 和 5 保持不變:each() 並且 reset() 在更改IAP之前將複製陣列,而 foreach 仍然使用原始陣列。(即使陣列已共享,IAP更改也不重要。)

第二組示例與 current() 不同引用/引用計數配置下的行為有關。這不再有意義,因為 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 恢復機制直接跳轉到新元素,因為它“看起來”像是與 remove 元素相同(由於衝突的雜湊和指標)。由於我們不再依賴元素雜湊來解決任何問題,因此這不再是一個問題。

參考:
[1]http://blog.golemon.com/2007/01/youre-being-lied-to.html
[2]https://stackoverflow.com/questions/10057671/how-does-php-foreach-actually-work

相關文章