PHP 理清 foreach 潜规则

PHP 2019-05-20 1205 次浏览 次点赞

起步

在相当长的一段时间里,我认为 foreach 在循环期间使用的是原数组的副本。但最近经过一些实验后发现这并不是百分百正确。

比如副本的说法说得通的:

$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 的改变有三点:

  1. foreach 不再改变内部数组指针;
  2. foreach 通过值遍历时,操作的值为数组的副本;
  3. foreach 通过引用遍历时,有更好的迭代特性。

因此,在讨论 foreach 里的数组副本问题,得分开版本来说明。在此,https://stackoverflow.com/questions/10057671/how-does-php-foreach-actually-work 有了比较详细的说明,并举例了大多数情况。本文就进行一些整理。

写时复制

造成运行差异和与预期不同的原因一部分就是因为触发了写时复制,另一部分是 foreach 本身的机制。

php底层有两个属性来处理引用计数(refcount)与完全引用计数(is_ref)。

当类似 $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 and 7.x: 1 2 3 6 7 8 9 10 */

尽管操作上是允许的,但我想没有人会这么做。

额外

内部指针与 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  值传递,始终操作的是副本

本文由 hongweipeng 创作,采用 署名-非商业性使用-相同方式共享 3.0,可自由转载、引用,但需署名作者且注明文章出处。

如果对您有用,您的支持将鼓励我继续创作!