foreach
支持对三种不同值的迭代:
在下文中,我将尝试准确解释迭代在不同情况下的工作原理。到目前为止,最简单的情况是Traversable
对象,因为这些foreach
基本上只是这些代码的语法糖:
foreach ($it as $k => $v) { /* ... */ }
/* translates to: */
if ($it instanceof IteratorAggregate) {
$it = $it->getIterator();
}
for ($it->rewind(); $it->valid(); $it->next()) {
$v = $it->current();
$k = $it->key();
/* ... */
}
Iterator
对于内部类,通过使用基本上只是在 C 级别上镜像接口的内部 API 来避免实际的方法调用。
数组和普通对象的迭代要复杂得多。首先,应该注意的是,在 PHP 中,“数组”实际上是有序字典,它们将按照这个顺序进行遍历(只要你不使用类似的东西,它就匹配插入顺序sort
)。这与按键的自然顺序(其他语言中的列表通常如何工作)或根本没有定义的顺序(其他语言中的字典通常如何工作)进行迭代相反。
这同样适用于对象,因为对象属性可以看作是另一个(有序)字典,将属性名称映射到它们的值,以及一些可见性处理。在大多数情况下,对象属性实际上并没有以这种相当低效的方式存储。但是,如果您开始迭代一个对象,通常使用的打包表示将被转换为一个真正的字典。在这一点上,普通对象的迭代变得非常类似于数组的迭代(这就是为什么我在这里不多讨论普通对象迭代的原因)。
到现在为止还挺好。遍历字典不会太难,对吧?当您意识到数组/对象可以在迭代期间发生变化时,问题就开始了。发生这种情况的方式有多种:
- 如果您通过引用进行迭代,则使用
foreach ($arr as &$v)
then$arr
将变为引用,您可以在迭代期间更改它。
- 在 PHP 5 中,即使按值进行迭代也是如此,但该数组事先是一个引用:
$ref =& $arr; foreach ($ref as $v)
- 对象具有通过句柄传递的语义,这对于大多数实际目的而言意味着它们的行为类似于引用。所以对象总是可以在迭代过程中改变。
在迭代期间允许修改的问题是您当前所在的元素被删除的情况。假设您使用指针来跟踪您当前所在的数组元素。如果这个元素现在被释放,你会留下一个悬空指针(通常会导致段错误)。
有不同的方法来解决这个问题。PHP 5 和 PHP 7 在这方面有很大的不同,我将在下面描述这两种行为。总结是 PHP 5 的方法相当愚蠢并导致各种奇怪的边缘情况问题,而 PHP 7 更多涉及的方法导致更可预测和一致的行为。
作为最后的准备,应该注意 PHP 使用引用计数和写时复制来管理内存。这意味着如果你“复制”一个值,你实际上只是重用旧值并增加它的引用计数(refcount)。只有在您执行某种修改后,才会完成真正的副本(称为“复制”)。有关此主题的更广泛介绍,请参阅您被骗了。
PHP 5
内部数组指针和HashPointer
PHP 5 中的数组有一个专用的“内部数组指针”(IAP),它正确地支持修改:每当删除一个元素时,都会检查 IAP 是否指向该元素。如果是,则改为前进到下一个元素。
虽然foreach
确实使用了 IAP,但还有一个复杂的问题:只有一个 IAP,但一个数组可以是多个foreach
循环的一部分:
// 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) {
// ...
}
}
要支持只有一个内部数组指针的两个同时循环,foreach
请执行以下恶作剧:在执行循环体之前,foreach
将一个指向当前元素的指针及其哈希备份到 per-foreachHashPointer
中。循环体运行后,如果该元素仍然存在,IAP 将被设置回该元素。但是,如果该元素已被删除,我们将只使用 IAP 当前所在的任何位置。这个方案主要是有点效果,但是你可以摆脱很多奇怪的行为,我将在下面展示其中的一些。
数组复制
IAP 是数组的一个可见特征(通过current
函数族公开),因为对 IAP 的更改算作写时复制语义下的修改。不幸的是,这意味着foreach
在许多情况下被迫复制它正在迭代的数组。具体条件如下:
- 该数组不是参考 (is_ref=0)。如果它是一个引用,那么对它的更改应该传播,所以它不应该被复制。
- 数组的 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 更改$arr
泄漏到$outerArr
. 根据上述条件,数组不是引用(is_ref=0),在两个地方使用(refcount=2)。这个要求是不幸的,并且是次优实现的产物(这里不需要在迭代期间进行修改,所以我们一开始就不需要使用 IAP)。
(*) 增加refcount
here 听起来无害,但违反了写时复制 (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();
}
即,数组指针在循环体运行之前已经向前移动。这意味着当循环体在 element 上工作时$i
,IAP 已经在 element 上$i+1
。这就是为什么在迭代期间显示修改的代码示例将始终unset
是下一个元素,而不是当前元素。
示例:您的测试用例
上面描述的三个方面应该让您对实现的特性有一个大致完整的印象,foreach
我们可以继续讨论一些示例。
此时,您的测试用例的行为很容易解释:
在测试用例 1 和 2$array
中以 refcount=1 开始,因此它不会被 复制foreach
:只有refcount
增加。当循环体随后修改数组(此时 refcount=2)时,将在该点发生重复。Foreach 将继续处理未修改的$array
.
在测试用例 3 中,数组再次没有重复,因此foreach
将修改$array
变量的 IAP。在迭代结束时,IAP 为 NULL(表示迭代已完成),each
通过返回 表示false
。
在测试用例 4 和 5 中,each
和reset
都是引用函数。当它被传递给他们时,它有$array
一个refcount=2
,所以它必须被复制。因此foreach
将再次在一个单独的阵列上工作。
示例:current
in foreach的效果
显示各种重复行为的一个好方法是观察 循环current()
内函数的行为。foreach
考虑这个例子:
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 2 2 2 2 */
在这里你应该知道这current()
是一个 by-ref 函数(实际上是:prefer-ref),即使它不修改数组。它必须是为了与所有其他功能(例如next
都是 by-ref)配合得很好。引用传递意味着数组必须分开,因此$array
会foreach-array
有所不同。上面也提到了你得到2
而不是的原因:在运行用户代码之前推进数组指针,而不是之后。因此,即使代码位于第一个元素,也已经将指针推进到第二个元素。1
foreach
foreach
现在让我们尝试一个小的修改:
$ref = &$array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
这里我们有 is_ref=1 的情况,所以数组没有被复制(就像上面一样)。current()
但是现在它是一个引用,当传递给 by-ref函数时,不再需要复制数组。因此current()
并foreach
在同一个数组上工作。但是,由于foreach
指针前进的方式,您仍然会看到非一行为。
在进行 by-ref 迭代时,您会得到相同的行为:
foreach ($array as &$val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
这里重要的部分是foreach$array
在引用迭代的时候会做一个is_ref=1,所以基本上你的情况和上面一样。
另一个小的变化,这次我们将数组分配给另一个变量:
$foo = $array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 1 1 1 1 1 */
当循环开始时,这里的 refcount$array
为 2,因此我们实际上必须预先进行复制。因此$array
,foreach 使用的数组将从一开始就完全分开。这就是为什么您可以在循环之前的任何位置获得 IAP 的位置(在本例中,它位于第一个位置)。
示例:迭代期间的修改
尝试考虑迭代期间的修改是我们所有 foreach 问题的根源,因此它可以考虑这种情况下的一些示例。
考虑在同一个数组上的这些嵌套循环(其中使用 by-ref 迭代来确保它确实是同一个):
foreach ($array as &$v1) {
foreach ($array as &$v2) {
if ($v1 == 1 && $v2 == 1) {
unset($array[1]);
}
echo "($v1, $v2)\n";
}
}
// Output: (1, 1) (1, 3) (1, 4) (1, 5)
这里的预期部分是(1, 2)
输出中缺少的部分,因为元素1
已被删除。可能出乎意料的是外循环在第一个元素之后停止。这是为什么?
这背后的原因是上面描述的嵌套循环黑客:在循环体运行之前,当前 IAP 位置和哈希被备份到一个HashPointer
. 在循环体之后,它将被恢复,但前提是元素仍然存在,否则将使用当前 IAP 位置(无论它可能是什么)。在上面的示例中,情况正是如此:外循环的当前元素已被删除,因此它将使用已被内循环标记为已完成的 IAP!
备份+恢复机制的另一个后果HashPointer
是通过reset()
etc. 对 IAP 的更改通常不会影响foreach
. 例如,以下代码的执行就像reset()
根本不存在一样:
$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
var_dump($value);
reset($array);
}
// output: 1, 2, 3, 4, 5
原因是,虽然reset()
临时修改了IAP,但是会在循环体之后恢复到当前的foreach元素。要强制reset()
对循环产生影响,您必须另外删除当前元素,以便备份/恢复机制失败:
$array = [1, 2, 3, 4, 5];
$ref =& $array;
foreach ($array as $value) {
var_dump($value);
unset($array[1]);
reset($array);
}
// output: 1, 1, 3, 4, 5
但是,这些例子仍然是理智的。如果您记得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);
}
// output: 1, 4
在这里,我们通常应该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 。foreach
循环绝对不会影响 etc. 的结果,并且current()
它自己的行为永远不会受到 reset()
etc 等函数的影响。
数组复制
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;
}
/* Old output: 1, 2, 0, 4, 5 */
/* New output: 1, 2, 3, 4, 5 */
以前引用数组的按值迭代是特殊情况。在这种情况下,没有重复发生,因此迭代期间对数组的所有修改都将反映在循环中。在 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 保留相同的输出:按值数组迭代始终对原始元素进行处理。(在这种情况下,PHP 5 和 PHP 7 之间的偶数refcounting
和重复行为完全相同)。
测试用例 3 更改:Foreach
不再使用 IAP,因此each()
不受循环影响。它将在之前和之后具有相同的输出。
测试用例 4 和 5 保持不变:each()
将reset()
在更改 IAP 之前复制数组,同时foreach
仍使用原始数组。(即使阵列是共享的,IAP 的变化也并不重要。)
第二组示例与current()
不同reference/refcounting
配置下的行为有关。这不再有意义,因为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";
}
}
// Old output: (1, 1) (1, 3) (1, 4) (1, 5)
// New output: (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)
如您所见,外循环在第一次迭代后不再中止。原因是两个循环现在都有完全独立的哈希表迭代器,两个循环不再通过共享 IAP 交叉污染。
现在修复的另一个奇怪的边缘情况是当您删除和添加碰巧具有相同哈希的元素时得到的奇怪效果:
$array = ['EzEz' => 1, 'EzFY' => 2, 'FYEz' => 3];
foreach ($array as &$value) {
unset($array['EzFY']);
$array['FYFY'] = 4;
var_dump($value);
}
// Old output: 1, 4
// New output: 1, 3, 4
以前 HashPointer 恢复机制会直接跳转到新元素,因为它“看起来”与被移除的元素相同(由于哈希和指针的冲突)。由于我们不再依赖元素哈希来处理任何事情,这不再是一个问题。