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

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

查看所有标签

猜你喜欢:

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

scikit learn机器学习

scikit learn机器学习

黄永昌 / 机械工业出版社 / 2018-3-1 / CNY 59.00

本书通过通俗易懂的语言、丰富的图示和生动的实例,拨开了笼罩在机器学习上方复杂的数学“乌云”,让读者以较低的代价和门槛轻松入门机器学习。本书共分为11章,主要介绍了在Python环境下学习scikit-learn机器学习框架的相关知识。本书涵盖的主要内容有机器学习概述、Python机器学习软件包、机器学习理论基础、k-近邻算法、线性回归算法、逻辑回归算法、决策树、支持向量机、朴素贝叶斯算法、PCA ......一起来看看 《scikit learn机器学习》 这本书的介绍吧!

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具

RGB CMYK 转换工具
RGB CMYK 转换工具

RGB CMYK 互转工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具