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)
那么$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-foreach中HashPointer
。循环主体运行后,如果IAP仍然存在,则将其设置回该元素。但是,如果该元素已删除,我们将仅使用IAP当前所在的位置。这个方案主要是某种类型的作品,但是您可以摆脱很多奇怪的行为,下面将对其中的一些进行演示。
阵列复制
IAP是数组的可见功能(通过current
函数族公开),因此,IAP计数的更改是写时复制语义下的修改。不幸的是,这意味着foreach
在很多情况下被迫复制要迭代的数组。精确的条件是:
- 该数组不是引用(is_ref = 0)。如果是参考,则应该传播对其的更改,因此不应重复。
- 数组的引用计数> 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
此处递增听起来很无害,但违反了写时复制(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函数(实际上是:referr-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的情况,因此不复制数组(就像上面一样)。但是,既然它是一个引用,则在传递给by-ref current()
函数时,不再需要复制该数组。因此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 */
在$array
开始循环时,此处的refcount 是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
已被删除。出乎意料的是,外循环在第一个元素之后停止。这是为什么?
其背后的原因是上述的嵌套循环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);
}
// 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
循环都会对结果毫无影响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;
}
/* 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保留相同的输出:按值数组迭代始终对原始元素起作用。(在这种情况下,refcounting
PHP 5和PHP 7之间的偶数和重复行为完全相同)。
测试用例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还原机制直接跳转到新元素,因为它“看起来”与删除的元素相同(由于哈希和指针冲突)。由于我们不再依赖元素哈希进行任何操作,因此这不再是问题。