原文地址:https://www.hongweipeng.com/i…
起步
在相当长的一段时间里,我认为 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 的改变有三点:
- foreach 不再改变内部数组指针;
- foreach 通过值遍历时,操作的值为数组的副本;
- foreach 通过引用遍历时,有更好的迭代特性。
因此,在讨论 foreach
里的数组副本问题,得分开版本来说明。在此,https://stackoverflow.com/que… 有了比较详细的说明,并举例了大多数情况。本文就进行一些整理与总结。
写时复制
造成运行差异和与预期不同的原因一部分就是因为触发了写时复制,另一部分是 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 = 0
且 refcount > 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 的影响鸟哥似乎有分享:
说是在 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 值传递,始终操作的是副本