PHP 理清 foreach 潜规则

栏目: PHP · 发布时间: 5年前

内容简介:在相当长的一段时间里,我认为这个例子在循环体中修改数组不影响循环过程,副本的说法说得通。对于不同的PHP版本输出会有差异,php7 提及 foreach 的改变有三点:

起步

在相当长的一段时间里,我认为 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。

什么情况下可以跳过写时复制而可以直接对源数组进行操作呢?

如果时候通过引用来进行迭代 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

如果源数组事先被引用了 $ref = &$arr; foreach($arr as $v) ,对于在 5.x 下依然可以进行修改:

<?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

数组副本

数组内部指针(IAP)我们可以通过 current 观察它的移动,因为修改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 的影响鸟哥似乎有分享:

PHP 理清 foreach 潜规则

说是在Think 2015 PHP技术峰会,但我没找到视频,十分遗憾。

在迭代过程中修改原数组

为了确保我们对数组的修改能够事实生效,我们就要避免写时复制的情况,让foreach始终都操作原数组。这里最方便的就是用引用来迭代:

$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) 是缺少的部分,因为元素 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 不再依赖元素哈希,因为它的运行结果更为正确。

在循环期间替换迭代的实体

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: 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: 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

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

浪潮之巅(第三版)(上下册)

浪潮之巅(第三版)(上下册)

吴军 / 人民邮电出版社 / 2016-5 / 99.00元

一个企业的发展与崛起,绝非只是空有领导强人即可达成。任何的决策、同期的商业环境,都在都影响着企业的兴衰。《浪潮之巅》不只是一本历史书,除了讲述科技顶尖企业的发展规律,对于华尔街如何左右科技公司,以及金融风暴对科技产业的冲击,也多有着墨。此外,这本书也着力讲述很多尚在普及 或将要发生的,比如微博和云计算,以及对下一代互联网科技产业浪潮的判断和预测。因为在极度商业化的今天,科技的进步和商机是分不开的。......一起来看看 《浪潮之巅(第三版)(上下册)》 这本书的介绍吧!

URL 编码/解码
URL 编码/解码

URL 编码/解码

SHA 加密
SHA 加密

SHA 加密工具

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具