foreach
支持对三种不同类型的值进行迭代:
接下来,我将尝试精确地解释在不同情况下迭代如何工作。到目前为止,最简单的情况是 Traversable
对象,因为对于这些对象,foreach
实际上只是以下代码的语法糖:
foreach ($it as $k => $v) { }
if ($it instanceof IteratorAggregate) {
$it = $it->getIterator();
}
for ($it->rewind(); $it->valid(); $it->next()) {
$v = $it->current();
$k = $it->key();
}
对于内部类,为避免实际方法调用,使用了一个内部API,它在C级别上基本上只是镜像了Iterator
接口。
数组和普通对象的迭代要复杂得多。首先,应该注意的是,在PHP中,“数组”实际上是有序字典,并且将按照此顺序进行遍历(只要您没有使用类似于sort
的东西)。这与按键的自然顺序迭代(其他语言中列表的工作方式)或根本没有定义顺序(其他语言中字典的工作方式)相反。
对象也是如此,因为对象属性可以被视为另一个(有序)字典,将属性名称映射到其值,加上一些可见性处理。在大多数情况下,对象属性实际上并没有以这种相当低效的方式存储。但是,如果您开始迭代对象,则通常使用的紧凑表示形式将被转换为真正的字典。此时,普通对象的迭代变得非常类似于数组的迭代(这就是为什么我没有在这里讨论普通对象迭代的原因)。
到目前为止还好。迭代字典不会太难,对吧?问题在于您意识到在迭代期间,数组/对象可以发生更改。这可以通过多种方式实现:
- 如果使用
foreach ($arr as &$v)
进行引用迭代,则$arr
将被转换为引用,并且您可以在迭代期间更改它。
- 在PHP 5中,即使按值迭代,但数组先前是引用也适用:
$ref =& $arr; foreach ($ref as $v)
- 对象具有通过句柄传递语义,对于大多数实际目的来说,这意味着它们的行为类似于引用。因此,在迭代期间始终可以更改对象。
允许在迭代期间进行修改的问题在于当前正在处理的元素被删除的情况。假设您使用指针来跟踪当前的数组元素,如果现在释放了该元素,则会留下一个悬空指针(通常导致段错误)。
有不同的方法来解决此问题。PHP 5和PHP 7在这方面有很大的区别,我将在以下描述两种不同的行为。总结一下,PHP 5的方法相当愚蠢,会导致各种奇怪的边缘情况问题,而PHP 7更加深入的方法会导致更可预测和一致的行为。
最后需要注意的是,PHP使用引用计数和写时复制来管理内存。这意味着如果您“复制”一个值,实际上只是重用旧值并增加其引用计数(refcount)。只有当您执行某种修改操作时,才会进行真正的复制(称为“复制”)。请参见You're being lied to以获取有关此主题的更全面介绍。
PHP 5
内部数组指针和哈希指针
PHP 5中的数组有一个专门的“内部数组指针”(IAP),它能够正确支持修改操作:每当删除一个元素时,会检查IAP是否指向该元素。如果是,则将其移动到下一个元素。
虽然foreach
语句使用IAP,但还存在一个额外的复杂性:只有一个IAP,但一个数组可以是多个foreach
循环的一部分:
foreach ($arr as &$v1) {
foreach ($arr as &$v) {
}
}
为了支持只使用一个内部数组指针进行两个同时循环的操作,
foreach
执行以下策略:在执行循环体之前,
foreach
将备份当前元素和其哈希值的指针到每个
HashPointer
中。在循环体运行之后,如果该元素仍然存在,则IAP将被设置回此元素。然而,如果该元素已被删除,我们将只使用IAP当前所在的位置。这个方案大多数情况下基本上有点奇怪的行为,下面我会举一些例子。
数组复制
IAP是数组的可见特性(通过
current
函数系列公开),因此对IAP的更改在写时复制语义下算作修改。不幸的是,这意味着在许多情况下,
foreach
被迫复制它正在迭代的数组。具体条件如下:
1.数组不是引用(is_ref=0)。如果它是一个引用,那么对它的更改应该传播,因此不应该复制它。
2.数组具有refcount>1。如果
refcount
为1,则数组没有共享,我们可以直接修改它。
如果数组没有被复制(is_ref=0,refcount=1),则只会增加其
refcount
(*)。此外,如果使用引用的
foreach
,则(可能复制的)数组将变成一个引用。
请考虑以下代码作为复制发生的示例:
function iterate($arr) {
foreach ($arr as $v) {}
}
$outerArr = [0, 1, 2, 3, 4];
iterate($outerArr);
在这里,$arr
将被复制,以防止IAP更改泄漏到$outerArr
。 根据上述条件,该数组不是引用(is_ref = 0),并且在两个位置使用(refcount = 2)。这个要求是不幸的,并且是子优化实现的产物(在这里没有迭代期间修改的担忧,因此我们实际上根本不需要使用IAP)。
(*)在这里递增refcount
听起来无害,但违反了写时复制(COW)语义:这意味着我们将修改refcount = 2数组的IAP,而COW指定只能对refcount = 1的值执行修改。这种违规行为会导致用户可见的行为变化(虽然COW通常是透明的),因为迭代的数组上的IAP更改将是可观察的 - 但仅限于数组上的第一个非IAP修改之前。相反,三个“有效”选项应该是a)始终复制,b)不递增refcount
,从而允许在循环中任意修改迭代的数组,或c)根本不使用IAP(PHP 7解决方案)。
位置推进顺序
最后还有一个实现细节,您必须了解才能正确理解以下代码示例的含义。通过某个数据结构循环的“正常”方法在伪代码中看起来像这样:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
code();
move_forward(arr);
}
然而foreach
作为一个相当特殊的例子,选择以稍微不同的方式进行操作:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
move_forward(arr);
code();
}
即,循环体运行之前,数组指针已经向前移动。这意味着当循环体在处理元素$i时,IAP已经位于元素$i+1。这就是为什么展示迭代期间修改的代码示例总是会unset
下一个元素而不是当前元素的原因。
上述三个方面应该让你对foreach
实现的特点有了一个基本的了解,我们可以继续讨论一些例子。
现在可以简单地解释你的测试用例的行为:
在测试用例1和2中,$array
的refcount
初始值为1,因此它不会被foreach
复制:只会增加refcount
。随后,当循环体修改数组(此时$array
的refcount
为2)时,复制将在此时发生。Foreach将继续在未修改的$array
副本上操作。
在测试用例3中,数组再次没有被复制,因此foreach
将修改$array
变量的IAP。在迭代结束时,IAP为NULL(表示迭代已完成),each
通过返回false
来表示这一点。
在测试用例4和5中,each
和reset
都是按引用传递的函数。将$array
传递给它们时,$array
的refcount
为2,因此必须复制它。因此,foreach
将再次在单独的数组上操作。
展示各种复制行为的好方法是观察foreach
循环中current()
函数的行为。考虑以下示例:
foreach ($array as $val) {
var_dump(current($array));
}
在这里你需要知道,current()
是一个按引用传递的函数(实际上是prefer-ref),尽管它不会修改数组。 它必须这样做才能与所有其他按引用传递的函数一起使用,如 next
。 按引用传递意味着数组必须被拆分,因此 $array
和 foreach-array
将不同。 之前提到获取到 2
而非 1
的原因是:在运行用户代码之前,foreach
会先移动数组指针 一次 ,而不是之后。 因此,即使代码在第一个元素处,foreach
已经将指针移动到第二个元素。
现在让我们进行一小部分修改:
$ref = &$array;
foreach ($array as $val) {
var_dump(current($array));
}
这里我们有一个is_ref=1的情况,因此数组没有被复制(就像上面一样)。但是现在它是一个引用,当传递给按引用传递的 current()
函数时,数组不再需要被复制。因此,current()
和 foreach
在同一个数组上工作。由于foreach
推进指针的方式,仍然会看到偏移一个位置的行为。
通过按引用迭代时,您将获得相同的行为:
foreach ($array as &$val) {
var_dump(current($array));
}
这里重要的部分是当使用引用进行迭代时,foreach会使$array
变成is_ref=1,因此你基本上会面临与上述情况相同的情况。
另一个小变化,这次我们将数组赋值给另一个变量:
$foo = $array;
foreach ($array as $val) {
var_dump(current($array));
}
循环开始时,$array
的引用计数为2,因此我们实际上需要提前复制。因此,$array
和foreach使用的数组将从一开始完全分开。这就是为什么在循环之前您会得到IAP的位置,无论它在哪里(在这种情况下,它位于第一个位置)。
示例:迭代期间的修改
尝试在迭代过程中考虑修改是所有foreach问题的根源,因此可以考虑一些此类示例。
考虑对同一数组执行的这些嵌套循环(使用按引用迭代以确保实际上是相同的数组):
foreach ($array as &$v1) {
foreach ($array as &$v2) {
if ($v1 == 1 && $v2 == 1) {
unset($array[1]);
}
echo "($v1, $v2)\n";
}
}
这里预期的部分是,输出结果中缺失了 (1, 2)
,因为元素 1
已被删除。可能意想不到的是,外层循环在第一个元素后停止了。为什么会这样呢?
原因是上面描述的嵌套循环 hack:在循环体运行之前,当前 IAP 的位置和哈希会被备份到一个 HashPointer
中。在循环体之后,它将被恢复,但仅在元素仍然存在的情况下才会恢复,否则将使用当前的 IAP 位置(无论它是什么)。在上面的示例中,情况正好如此:外循环的当前元素已被删除,所以它将使用 IAP,而这个 IAP 已经被内循环标记为已完成!
HashPointer
备份+恢复机制的另一个结果是,通过 reset()
等对 IAP 进行的更改通常不影响 foreach
。例如,以下代码的执行就好像根本没有出现 reset()
一样:
$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
var_dump($value);
reset($array);
}
原因是,虽然`reset()`暂时修改了IAP,但循环体后它将被恢复到当前的foreach元素。要强制`reset()`对循环产生影响,您必须另外删除当前元素,这样备份/恢复机制就会失败:
$array = [1, 2, 3, 4, 5];
$ref =& $array;
foreach ($array as $value) {
var_dump($value);
unset($array[1]);
reset($array);
}
但是,这些例子仍然很正常。如果您记得HashPointer
恢复使用指向元素及其哈希的指针来确定它是否仍然存在,则真正有趣的事情开始了。但是:哈希会有冲突,指针可以被重用!这意味着,通过精心选择数组键,我们可以让foreach
相信已删除的元素仍然存在,因此它将直接跳转到该元素。一个例子:
$array = ['EzEz' => 1, 'EzFY' => 2, 'FYEz' => 3];
$ref =& $array;
foreach ($array as $value) {
unset($array['EzFY']);
$array['FYFY'] = 4;
reset($array);
var_dump($value);
}
根据之前的规则,我们通常会期望输出结果为1, 1, 3, 4
。但现在发生的情况是,'FYFY'
和被删除的元素'EzFY'
具有相同的哈希值,并且分配器恰好重用同一内存位置来存储该元素。因此,foreach循环直接跳转到新插入的元素,从而捷径了循环。
在循环中替换迭代实体
最后一个我想提到的奇怪情况是,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 */
如您所见,在这种情况下,PHP在替换完成后将从头开始迭代另一个实体。
PHP 7
哈希表迭代器
如果您还记得,数组迭代的主要问题是如何处理迭代中间删除元素的情况。 PHP 5使用单个内部数组指针(IAP)来解决此问题,这种方法有些次优,因为一个数组指针必须被拉伸以支持多个同时使用的 foreach 循环和 reset()等函数的交互。
PHP 7采用了不同的方法,即它支持创建任意数量的外部安全哈希表迭代器。这些迭代器必须在数组中注册,从此它们具有与 IAP 相同的语义: 如果删除一个数组元素,则指向该元素的所有哈希表迭代器都将前进到下一个元素。
这意味着foreach将不再使用IAP at all。foreach 循环对 current() 等函数返回结果没有任何影响,并且其行为永远不会受到reset() 等函数的影响。
数组复制
PHP 5和PHP 7之间的另一个重要变化涉及数组复制。现在,由于不再使用IAP,按值的数组迭代将仅在所有情况下进行refcount增量(而不是复制数组)。如果在foreach循环期间修改了数组,则此时将发生复制(根据写入时复制)并且 foreach 将继续处理旧数组。
在大多数情况下,这种更改是透明的,并且没有其他影响,只带来更好的性能。然而,有一种情况会导致不同的行为,即数组之前是一个引用的情况:
$array = [1, 2, 3, 4, 5];
$ref = &$array;
foreach ($array as $val) {
var_dump($val);
$array[2] = 0;
}
在PHP7之前,基于引用数组的按值迭代是一种特殊情况。在这种情况下,不会发生复制,因此迭代期间数组的所有修改都将反映在循环中。但在PHP 7中,这种特殊情况已经不存在了:按值迭代数组将始终在原始元素上工作,而忽略循环期间的任何修改。
当然,这并不适用于基于引用的迭代。如果您按引用迭代,则所有修改都将反映在循环中。有趣的是,对于普通对象的按值迭代也是如此:
$obj = new stdClass;
$obj->foo = 1;
$obj->bar = 2;
foreach ($obj as $val) {
var_dump($val);
$obj->bar = 42;
}
/* Old and new output: 1, 42 */
这反映了对象按句柄语义的行为(即它们在按值上下文中表现得像引用)。
示例
让我们考虑几个例子,从你的测试用例开始:
测试用例1和2保留相同的输出:按值数组迭代始终继续处理原始元素。(在这种情况下,甚至refcounting
和复制行为在PHP 5和PHP 7之间也完全相同。)
测试用例3更改:Foreach
不再使用IAP,因此each()
受循环影响。 它的输出将在之前和之后保持相同。
测试用例4和5保持不变:each()
和reset()
在更改IAP之前将复制数组,而foreach
仍使用原始数组。(请注意,即使数组是共享的,IAP的更改也无关紧要。)
第二组示例涉及不同reference/refcounting
配置下current()
的行为。由于current()
完全不受循环影响,因此这已不再有意义,因此其返回值始终保持不变。
但是,在迭代期间进行修改时,我们会得到一些有趣的变化。我希望您会发现新行为更加合理。第一个例子:
$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";
}
}
正如您所看到的,外循环在第一次迭代后不再中止。原因是现在两个循环具有完全独立的哈希表迭代器,并且不再通过共享IAP引起两个循环之间的任何交叉污染。
现在已经修复了另一个奇怪的边缘情况,即当您删除并添加具有相同哈希的元素时会产生奇怪的效果:
$array = ['EzEz' => 1, 'EzFY' => 2, 'FYEz' => 3];
foreach ($array as &$value) {
unset($array['EzFY']);
$array['FYFY'] = 4;
var_dump($value);
}
以前哈希指针恢复机制会直接跳到新元素,因为它“看起来”像被删除的元素一样(由于哈希和指针冲突)。由于我们不再依赖元素哈希值,所以这不再是一个问题。
foreach ($array as &$value)
)——PHP需要知道原始数组中的当前位置,即使实际上正在遍历副本。 - Niko