PHP 內核:foreach 是如何作業的(一)
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 在許多情況下被迫復制它正在迭代的陣列, 具體條件是:
- 陣列不是參考(is_ref = 0), 如果它是一個參考,那么對它的更改將被傳播,因此不應該復制它,
- 陣列的 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 解決方案),
位置發展順序
要正確理解下面的代碼示例,你必須了解最后一個實作細節,在偽代碼中,回圈遍歷某些資料結構的 “正常” 方法是這樣的:
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架構師教程目錄大全,只要你看完保證薪資上升一個臺階(持續更新)?
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/36354.html
標籤:PHP
下一篇:php實作支付寶支付(沙箱測驗)
