PHP 內核:foreach 是如何工做的(二)

PHP 內核:foreach 是如何工做的(一)
node

PHP 5

內部數組指針和散列指針數組

PHP 5 中的數組有一個專用的 「內部數組指針」(IAP),它適當地支持修改:每當刪除一個元素時,都會檢查 IAP 是否指向該元素。 若是是,則轉發到下一個元素。安全

 

雖然 foreach 確實使用了 IAP,但還有一個複雜因素:只有一個 IAP,可是一個數組能夠是多個 foreach 循環的一部分:數據結構

 

// 在這裏使用by-ref迭代來確保它真的
// 兩個循環中的相同數組而不是副本
foreach ($arr as &$v1) {
    foreach ($arr as &$v) {
        // ...
    }
}

爲了支持只有一個內部數組指針的兩個同時循環,foreach 執行如下 shenanigans:在執行循環體以前,foreach 將備份指向當前元素及其散列的指針到每一個 foreachHashPointer。循環體運行後,若是 IAP 仍然存在,IAP 將被設置回該元素。 可是,若是元素已被刪除,咱們將只在 IAP 當前所在的位置使用。這個計劃基本上是可行的,可是你能夠從中得到不少奇怪的狀況,其中一些我將在下面演示。架構

數組複製

 

IAP 是數組的一個可見特性 (經過 current 系列函數公開),所以 IAP 計數的更改是在寫時複製語義下的修改。不幸的是,這意味着 foreach 在許多狀況下被迫複製它正在迭代的數組。 具體條件是:svg

  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 將被複制以防止 $arr 上的 IAP 更改泄漏到 $outerArr。 就上述條件而言,數組不是引用(is_ref = 0),而且在兩個地方使用(refcount = 2)。 這個要求是不幸的,也是次優實現的工件(這裏不須要修改迭代,所以咱們不須要首先使用 IAP)。測試

 

(*)增長 refcount 聽起來無害,但違反了寫時複製(COW)語義:這意味着咱們要修改 refcount = 2 數組的 IAP,而 COW 則要求只能執行修改 on refcount = 1 值。這種違反會致使用戶可見的行爲更改 (而 COW 一般是透明的),由於迭代數組上的 IAP 更改將是可見的 -- 但只有在數組上的第一個非 IAP 修改以前。相反,這三個 「有效」 選項是:a) 始終複製,b) 不增長 refcount,從而容許在循環中任意修改迭代數組,c) 徹底不使用 IAP (PHP 7 解決方案)。spa

 

位置發展順序

要正確理解下面的代碼示例,你必須瞭解最後一個實現細節。在僞代碼中,循環遍歷某些數據結構的 「正常」 方法是這樣的:

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

然而,foreach,做爲一個至關特殊的 snowflake,選擇作的事情略有不一樣:

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

也就是說,數組指針 在循環體運行以前已經向前移動了。這意味着,當循環體處理元素 $i 時,IAP 已經位於元素 $i+1。這就是爲何在迭代期間顯示修改的代碼示例老是 unset下一個元素,而不是當前元素的緣由。

 

例子:你的測試用例

上面描述的三個方面應該可讓你大體瞭解 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 中的做用

顯示各類複製行爲的一個好方法是觀察 foreach 循環中 current() 函數的行爲。看以下這個例子:

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

在這裏,你應該知道 current() 是一個 by-ref 函數 (其實是:preferences-ref),即便它沒有修改數組。它必須很好地處理全部其餘函數,如 next,它們都是 by-ref。經過引用傳遞意味着數組必須是分開的,所以 $array 和 foreach-array 將是不一樣的。你獲得是 2 而不是 1 的緣由也在上面提到過:foreach在運行用戶代碼以前指向數組指針,而不是以後。所以,即便代碼位於第一個元素,foreach 已經將指針指向第二個元素。

 

如今讓咱們嘗試一下小修改:

 

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

這裏咱們有 is_ref=1 的狀況,因此數組沒有被複制 (就像上面那樣)。可是如今它是一個引用,當傳遞給 by-ref current() 函數時再也不須要複製數組。所以,current() 和 foreach 工做在同一個數組上。不過,因爲 foreach 指向指針的方式,你仍能夠看到 off-by-one 行爲。

當執行 by-ref 迭代時,你會獲得相同的行爲:

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

 

這裏重要的部分是,當經過引用迭代 $array 時,foreach 會將 $array 設置爲 is_ref=1,因此基本上狀況與上面相同。

另外一個小變化,此次咱們將數組分配給另外一個變量:

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

這裏 $array 的 refcount 在循環開始時是 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";
    }
}

// 輸出: (1, 1) (1, 3) (1, 4) (1, 5)

這裏的預期部分是輸出中缺乏 (1,2),由於元素 1 被刪除了。可能出乎意料的是,外部循環在第一個元素以後中止。這是爲何呢?

 

這背後的緣由是上面描述的嵌套循環攻擊:在循環體運行以前,當前 IAP 位置和散列被備份到一個 HashPointer 中。在循環體以後,它將被恢復,可是隻有當元素仍然存在時,不然將使用當前 IAP 位置 (不管它是什麼)。在上面的例子中,狀況正是這樣:外部循環的當前元素已經被刪除,因此它將使用 IAP,而內部循環已經將 IAP 標記爲 finished !

 

HashPointer 備份 + 恢復機制的另外一個結果是,經過 reset() 等方法更改 IAP。一般不會影響 foreach。例如,下面的代碼執行起來就像根本不存在 reset() 同樣:

 

$array = [1, 2, 3, 4, 5];

foreach ($array as &$value) {

var_dump($value);

reset($array);

}

// 輸出: 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);
}
// 輸出: 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);
}
// 輸出: 1, 4

 

這裏根據前面的規則,咱們一般指望輸出 1,1,3,4。實際狀況上'FYFY' 具備與刪除的元素'FYFY' 相同的散列,而分配器剛好重用相同的內存位置來存儲元素。所以,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;
    }
}
/* 輸出: 1 2 3 6 7 8 9 10 */

正如你在本例中所看到的,一旦替換髮生,PHP 將從頭開始迭代另外一個實體。

PHP 7

散列表迭代器

 

若是你還記得,數組迭代的主要問題是如何處理迭代過程當中元素的刪除。PHP 5 爲此使用了一個內部數組指針 (IAP),這有點不太理想,由於一個數組指針必須被拉伸以支持多個同時進行的 foreach 循環和與 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;
}
/* 舊輸出: 1, 2, 0, 4, 5 */
/* 新輸出: 1, 2, 3, 4, 5 */

 

之前,引用數組的按值迭代是一種特殊狀況。在本例中,沒有發生重複,所以在迭代期間對數組的全部修改都將由循環反映出來。在 PHP 7 中,這種特殊狀況消失了:數組的按值迭代將始終繼續處理原始元素,而不考慮循環期間的任何修改。

 

固然,這不適用於 by-reference 迭代。若是你經過引用進行迭代,那麼全部的修改都將被循環所反映。有趣的是,對於普通對象的按值迭代也是如此:

 

$obj = new stdClass;
$obj->foo = 1;
$obj->bar = 2;
foreach ($obj as $val) {
    var_dump($val);
    $obj->bar = 42;
}
/* 新舊輸出: 1, 42 */

 

這反映了對象的按句柄語義 (即,即便在按值上下文中,它們的行爲也相似於引用)。

例子

 

讓咱們考慮幾個例子,從你的測試用例開始:

 

測試用例 1 和 2 輸出相同:按值數組迭代始終在原始元素上工做。(在本例中,甚至 refcounting 和複製行爲在 PHP 5 和 PHP 7 之間也是徹底相同的)。

 

測試用例 3 的變化:Foreach 再也不使用 IAP,所以 each() 不受循環影響。先後輸出同樣。

 

測試用例 4 和 5 保持不變:each() 和 reset() 將在更改 IAP 以前複製數組,而 foreach 仍然使用原始數組。(即便數組是共享的,IAP 的更改也可有可無。)

 

第二組示例與 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";
    }
}

// 舊輸出: (1, 1) (1, 3) (1, 4) (1, 5)
// 新輸出: (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)

 

如你所見,外部循環在第一次迭代以後再也不停止。緣由是如今兩個循環都有徹底獨立的 hashtable 散列表迭代器,而且再也不經過共享的 IAP 對兩個循環進行交叉污染。

 

如今修復的另一個奇怪的邊緣現象是,當刪除而且添加剛好具備相同的哈希元素時,會獲得奇怪的結果:

 

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

 

以前的 HashPointer 恢復機制直接跳轉到新元素,由於它 「看起來」 和刪除的元素相同(因爲哈希和指針衝突)。因爲咱們再也不依賴於哈希元素,所以再也不是一個問題。

 


騰訊T3-T4標準精品PHP架構師教程目錄大全,只要你看完保證薪資上升一個臺階(持續更新)

相關文章
相關標籤/搜索