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  值传递,始终操做的是副本