PHP 理清 foreach 潛規則

原文地址:https://www.hongweipeng.com/i...php

起步

在至關長的一段時間裏,我認爲 foreach 在循環期間使用的是原數組的副本。但最近通過一些實驗後發現這並非百分百正確。數組

好比副本的說法說得通的:php7

$array = array(1, 2, 3, 4, 5);
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 */

這個例子在循環體中修改數組不影響循環過程,副本的說法說得通。數據結構

然而函數

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

$ref = &$arr;
foreach ($ref as $val) {
    echo $val;
    if ($val == 3) {
        $ref = $obj;
    }
}
// output in php5.x: 123678910
// output in php7.x: 12345

對於不一樣的PHP版本輸出會有差別,php7 說起 foreach 的改變有三點:oop

  1. foreach 再也不改變內部數組指針;
  2. foreach 經過值遍歷時,操做的值爲數組的副本;
  3. foreach 經過引用遍歷時,有更好的迭代特性。

所以,在討論 foreach 裏的數組副本問題,得分開版原本說明。在此,https://stackoverflow.com/que... 有了比較詳細的說明,並舉例了大多數狀況。本文就進行一些整理與總結。spa

寫時複製

形成運行差別和與預期不一樣的緣由一部分就是由於觸發了寫時複製,另外一部分是 foreach 自己的機制。指針

php底層有兩個屬性來處理引用計數(refcount)與徹底引用計數(is_ref)。code

當相似 $a = [1, 2, 3]; 建立並初始化後,該對象 is_ref 會設爲 0, refcount 會設爲 1; 當進行引用傳遞相似 $b = &$a 時,is_ref 和 refcount 都會 +1 ; 當相似 $c = $a 時,refcount 會 +1。視頻

什麼狀況下會觸發寫時複製?

當變量被從新賦值 $a = 1; 時,若是此時的 $a 的 is_ref=0 且 refcount>1,那麼就會觸發複製;不然在原對象上進行修改。

$a = [1, 2, 3]
$b = $a;
$a[] = 5;  // $a 的 is_ref=0,refcount>1 觸發了寫時複製,以後$a與$b是兩個不一樣數組

什麼狀況下能夠跳過寫時複製而能夠直接對原數組進行操做呢?

根據寫時複製的觸發規則,一個簡單的跳過改機制就是進行引用複製使得 is_ref > 0

那麼能夠在迭代期間進行修改:

<?php
$arr = [0, 1, 2, 3, 4, 5];
$ref = &$arr;
foreach ($arr as $v) {
    if ($v === 0) {
        unset($arr[3]);
    }
    echo $v;
}
// output in 5.x: 01245
// output in 7.x: 012345  // 7.x版本下,經過值遍歷時,底層操做的始終是數組的副本

7.x 版本好像仍是寫時複製的對吧。這是由於 7.x 版本對foreach 的改變 "foreach 經過值遍歷時,操做的值爲數組的副本"

另一種能夠在迭代中修改的是依靠 foreach 的機制的,即經過引用來進行迭代 foreach ($arr as &$v)

<?php
$arr = [0, 1, 2, 3, 4, 5];
foreach ($arr as &$v) {
    if ($v === 0) {
        unset($arr[3]);
    }
    echo $v;
}
// output in 5.x: 01245
// output in 7.x: 01245

數組副本

數組內部指針(IAP)咱們能夠經過且只能 current($arr) 函數觀察它的移動,由於修改IAP也是在寫時複製的語義下進行了。這也就意味着大多數狀況下,foreach 都會被迫拷貝它正在迭代的數組。在此強調:寫時複製條件是操做對象的計數爲 isref = 0refcount > 1

foreach 對 current 的影響

7.x 的foreach已經不會修改內部指針了,因此討論 current 影響的這部分都指 5.x 版本。

current 的例子1

<?php
$arr = [0, 1, 2, 3, 4, 5];
foreach ($arr as $v) {
    echo current($arr);
}
// output in 5.x: 11111

這裏有兩個問題,一個是爲何第一次循環時 current 指向是第二個元素;另外一個問題就是爲何都是指向第二個元素。

先來解釋第一個問題,爲何第一次循環時 current 指向是第二個元素?

foreach 啓動前,此時 $arr (is_ref=0, refcount=1),達不到寫時複製的條件,所以用的是$arr自己。
這裏有個細節,循環遍歷某個數據結構的「正常」方式經常看起來像這樣:

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

而 PHP 的 foreach 作的事情有些不一樣:

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

也就是說,在執行 foreach 的循環體以前,數組指針已經向前移動了。這意味着當循環體正在處理 $i 是,IAP 已經處於元素 $i+1 了。這也就是爲何第一次循環 current 獲得的是第二個元素了。

那麼,爲何下一個循環裏 current 仍是第二個元素呢?

這是由於底層會在 foreach 啓動後對 refcount 進行 +1 ,所以在第一次循環後第二次循環啓動時,foreach 又要修改內部指針了,但此時 $arr 爲 is_ref=0 refcount=2,修改內部指針又在寫時複製的語義下,所以觸發了寫時複製,因此從第二次循環開始,底層用的都是另外的一份副本,再也不對原數組進行修改,因此 current($arr) 就一直停留在第二個元素上了。

current 的例子2

<?php
$arr = [0, 1, 2, 3, 4, 5];
foreach ($arr as &$v) {
    echo current($arr);
}
// output in 5.x: 12345

這是foreach的運行機制致使的,只要是用引用進行迭代,foreach 操做的始終是原數組。這規則在 7.x 版本也適用。

current 的例子3

<?php
$arr = [0, 1, 2, 3, 4, 5];
$foo = $arr;
foreach ($arr as $v) {
    echo current($arr);
}
// output in 5.x: 000000

這個比 例子 1 就是多個一個將數組分配給另外一個變量。這裏,循環啓動時 refcount=2,而且內部數組指針的移動又發生在循環體以前,因此一開始就觸發了寫時複製,foreach 始終都是在副本上操做。所以 current($arr) 總仍是指向第一個元素。

關於 foreach 對 current 的影響鳥哥彷佛有分享:

3649802502-572708cb15c1c.png

說是在Think 2015 PHP技術峯會,但我沒找到視頻,十分遺憾。

在迭代過程當中修改原數組

爲了確保咱們對數組的修改可以實時生效,咱們就要避免寫時複製的狀況,讓foreach始終都操做原數組。這裏最方便的就是用引用來迭代即 foreach ($arr as &$v) 的形式,但儘管如此,對於操做後的數組,5和7的版本在處理上也有差別:

$array = 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";
    }
}

// output in 5.x: (1, 1) (1, 3) (1, 4) (1, 5)
// output in 7.x: (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)

此處的 (1, 2) 是缺乏的部分,由於元素 $array[1] 已經被刪除。但對於刪除後的處理,5和7不一樣,5 在外循環第一次迭代後就停止了,這是所以 5.x 的循環中,當前的IAP位置會被備份到 HashPointer 中(這點在額外章節中有具體說明),循環體結束後當且僅當元素仍然存在時進行恢復,不然使用當前的IAP位置。而7.x的兩個循環都具備徹底獨立的散列表迭代器,再也不經過共享IAP進行交叉污染。

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

對於 5.x 版本,原數組有被引用,所以不會觸發寫時複製,foreach 操做始終是原數組。

對於 7.x 版本,foreach 經過值遍歷時,操做的都是數組的副本,這點在升級文檔有說起。

如今有一個比較奇怪的邊緣問題:

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

在5.x版本中,因爲 HashPointer恢復機制會直接跳到新元素(這應該算是bug)。而版本 7.x 再也不依賴元素哈希,因此感受 7.x 的運行結果更爲正確。

在循環期間替換迭代的實體

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 in 5.x: 1 2 3 6 7 8 9 10
// output in 7.x: 1, 2, 3, 4, 5  值傳遞,始終操做的是副本,替換實體不起做用

儘管操做上是容許的,但我想沒有人會這麼作。

額外

內部指針與 HashPointer

爲了引出指針恢復的概念,咱們能夠先從一個問題來入手,只有一個內部數組指針的要怎麼同時知足兩個循環:

// 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) {
        // ...
    }
}

解決的辦法是,在循環體執行以前,將當前元素的指針和指向的元素保存起來,在循環體運行後,若是元素仍然存在,就把IAP恢復爲以前保存的指針;若是元素已被刪除,則IAP就使用當前的位置。這個保存的指針和元素地方就是 HashPointer

HashPointer 備份恢復機制帶來的方便就是咱們能夠臨時修改數組的指針:

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

若是要干涉這個機制,就要讓他恢復失敗:

$array = [1, 2, 3, 4, 5];
$ref =& $array;
foreach ($array as $value) {
    var_dump($value);
    unset($array[1]);
    reset($array);
}
// output in 5.x: 1, 1, 3, 4, 5
// output in 7.x: 1, 2, 3, 4, 5  值傳遞,始終操做的是副本
相關文章
相關標籤/搜索