How does PHP 'foreach' actually work?
我先说一下,我知道
很长一段时间以来,我假设
让我展示一下我的意思。对于以下测试用例,我们将使用以下数组:
1 |
测试用例1:
1 2 3 4 5 6 7 8 9 | foreach ($array as $item) { echo"$item "; $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 */ |
这清楚地表明我们没有直接使用源数组——否则循环将永远持续下去,因为我们在循环期间不断地将项推送到数组中。但为了确保这一点:
测试用例2:
1 2 3 4 5 6 7 8 9 10 | foreach ($array as $key => $item) { $array[$key + 1] = $item + 2; echo"$item "; } print_r($array); /* Output in loop: 1 2 3 4 5 $array after loop: 1 3 4 5 6 7 */ |
这支持了我们的初始结论,我们在循环期间使用源数组的副本,否则我们将在循环期间看到修改后的值。但是…
如果我们查看手册,我们会发现以下声明:
When foreach first starts executing, the internal array pointer is automatically reset to the first element of the array.
正确的。。。这似乎表明
测试用例3:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
因此,尽管我们没有直接处理源数组,但是我们直接处理源数组指针——指针位于循环末尾数组的末尾,这一事实表明了这一点。但这不可能是真的-如果是,那么测试用例1将永远循环。
《PHP手册》还规定:
As foreach relies on the internal array pointer changing it within the loop may lead to unexpected behavior.
好吧,让我们看看"意外行为"是什么(从技术上讲,任何行为都是意外的,因为我不再知道该期待什么)。
测试用例4:
1 2 3 4 5 6 7 |
测试用例5:
1 2 3 4 5 6 7 |
…没有什么出乎意料的,事实上,它似乎支持"复制源"理论。
问题
这是怎么回事?我的c-fu不够好,我无法通过查看PHP源代码来提取正确的结论,如果有人能为我将其翻译成英语,我将不胜感激。
在我看来,
- 这是正确的吗?整个故事?
- 如果没有,它到底在做什么?
- 在
foreach 期间,使用调整数组指针的函数(each() 和reset() 等)是否会影响循环的结果?
- 数组
- 正常物体
Traversable 对象
在下面,我将尝试精确地解释迭代如何在不同的情况下工作。到目前为止,最简单的情况是
1 2 3 4 5 6 7 8 9 10 11 12 |
对于内部类,可以通过使用内部API来避免实际的方法调用,该内部API基本上只是在C级别上镜像
数组和平面对象的迭代要复杂得多。首先,应该注意的是,在PHP中,"数组"实际上是按顺序排列的字典,它们将按此顺序进行遍历(只要您没有使用像
同样也适用于对象,因为对象属性可以看作是另一个(有序的)字典,将属性名映射到它们的值,加上一些可见性处理。在大多数情况下,对象属性实际上不是以这种非常低效的方式存储的。但是,如果您开始迭代一个对象,那么通常使用的打包表示将被转换为一个真正的字典。在这一点上,平面对象的迭代变得非常类似于数组的迭代(这就是为什么我在这里不太讨论平面对象迭代的原因)。好的。
到目前为止,一切都很好。对字典进行迭代不会太难,对吧?当您意识到数组/对象可以在迭代期间更改时,问题就开始了。这种情况有多种发生方式:好的。
- 如果使用
foreach ($arr as &$v) 通过引用进行迭代,那么$arr 将变成引用,并且可以在迭代期间更改它。 - 在php 5中,即使按值迭代,也同样适用,但数组是预先引用的:
$ref =& $arr; foreach ($ref as $v) 。 - 对象具有按句柄传递语义,这对于大多数实际用途来说意味着它们的行为类似于引用。所以对象总是可以在迭代期间更改。
在迭代过程中允许修改的问题是,您当前所在的元素被删除。假设使用指针跟踪当前所在的数组元素。如果现在释放了此元素,则会留下悬空指针(通常会导致segfault)。好的。
解决这个问题有不同的方法。php 5和php 7在这方面有很大的不同,我将在下面描述这两种行为。总之,php 5的方法相当愚蠢,导致各种各样的奇怪的边缘情况问题,而php 7更复杂的方法导致更可预测和一致的行为。好的。
作为最后一个预备,应该注意PHP使用引用计数和写时复制来管理内存。这意味着,如果您"复制"一个值,实际上只需重用旧值并增加其引用计数(refcount)。只有在您执行某种修改之后,才会进行真正的复制(称为"复制")。关于这个话题的更广泛的介绍,请看你被欺骗了。好的。PHP 5内部数组指针和哈希指针
php 5中的数组有一个专用的"内部数组指针"(iap),它正确地支持修改:每当删除一个元素时,都会检查iap是否指向这个元素。如果是这样,它将被提升到下一个元素。好的。
虽然foreach确实使用了IAP,但还有一个额外的复杂之处:只有一个IAP,但一个数组可以是多个foreach循环的一部分:好的。
1 2 3 4 5 6 7 | // 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执行以下schenanigans:在执行循环体之前,foreach将向上备份一个指向当前元素的指针及其散列到每个foreach
IAP是数组的一个可见特性(通过
如果数组不重复(is_ref=0,refcount=1),则只有其refcount才会递增(*)。此外,如果使用了foreach by reference,则(可能重复的)数组将转换为引用。好的。
将此代码作为重复发生的示例:好的。
1 2 3 4 5 6 | function iterate($arr) { foreach ($arr as $v) {} } $outerArr = [0, 1, 2, 3, 4]; iterate($outerArr); |
这里,将复制
(*)在这里增加refcount听起来无害,但违反了copy-on-write(cow)语义:这意味着我们将修改refcount=2数组的IAP,而cow则指示只能对refcount=1值执行修改。这种违反会导致用户可见的行为更改(而COW通常是透明的),因为迭代数组上的IAP更改是可观察的——但只有在对数组进行第一次非IAP修改之前。相反,三个"有效"选项应该是a)始终重复,b)不增加refcount,从而允许在循环中任意修改迭代数组,或c)根本不使用iap(php 7解决方案)。好的。职位晋升令
为了正确理解下面的代码示例,您必须了解最后一个实现细节。在伪代码中,通过某些数据结构进行循环的"正常"方式如下所示:好的。
1 2 3 4 5 |
然而,作为一种相当特殊的雪花,
1 2 3 4 5 |
也就是说,数组指针在循环体运行之前已经向前移动了。这意味着,当循环体处理元素
上面描述的三个方面应该能让您对foreach实现的特性有一个大致完整的印象,我们可以继续讨论一些例子。好的。
此时,测试用例的行为很容易解释:好的。
在测试用例1和2中,
$array 以refcount=1开始,因此foreach不会复制它:只增加refcount。当循环体随后修改数组(此时refcount=2)时,复制将在该点发生.forEach将继续处理未修改的$array 副本。好的。在测试用例3中,数组再次不重复,因此foreach将修改
$array 变量的IAP。迭代结束时,iap为空(表示迭代已经完成),由each 返回false 表示。好的。在测试用例4和5中,
each 和reset 都是通过引用函数实现的。$array 在传递给它们时有一个refcount=2 ,因此必须复制。因此,foreach 将再次在单独的阵列上工作。好的。
示例:
显示各种复制行为的一个好方法是观察foreach循环中
在这里,您应该知道
现在让我们尝试一个小的修改:好的。
1 2 3 4 5 |
这里我们有一个is_Ref=1大小写,所以数组不会被复制(就像上面一样)。但既然它是一个引用,那么当传递到by-ref
当按引用迭代执行时,您会得到相同的行为:好的。
这里重要的部分是,当通过引用迭代时,foreach将使
另一个小的变化,这次我们将把数组赋给另一个变量:好的。
在这里,循环开始时,EDOCX1的refcount(0)是2,因此我们实际上必须提前进行复制。因此,foreach使用的
在迭代过程中考虑修改是我们所有foreach问题的起源,因此可以考虑这个例子。好的。
考虑相同数组上的这些嵌套循环(其中,by-ref迭代用于确保它确实是相同的循环):好的。
1 2 3 4 5 6 7 8 9 10 11 | foreach ($array as &$v1) { foreach ($array as &$v2) { if ($v1 == 1 && $v2 == 1) { unset($array[1]); } echo"($v1, $v2) "; } } // Output: (1, 1) (1, 3) (1, 4) (1, 5) |
这里的预期部分是由于元素
这背后的原因是上面描述的嵌套循环黑客:在循环体运行之前,当前IAP位置和哈希被备份到一个
1 2 3 4 5 6 |
原因是,虽然
1 2 3 4 5 6 7 8 |
但是,这些例子仍然是明智的。如果您记得
1 2 3 4 5 6 7 8 9 |
在这里,我们通常希望根据前面的规则输出
最后一个奇怪的例子是,PHP允许您在循环期间替换迭代的实体。所以您可以开始迭代一个数组,然后在中途用另一个数组替换它。或者开始迭代数组,然后用对象替换它:好的。
1 2 3 4 5 6 7 8 9 10 11 12 | $arr = [1, 2, 3, 4, 5]; $obj = (object) [6, 7, 8, 9, 10]; $ref =& $arr; foreach ($ref as $val) { echo"$val "; if ($val == 3) { $ref = $obj; } } /* Output: 1 2 3 6 7 8 9 10 */ |
正如您在本例中看到的,一旦发生替换,PHP将从一开始就开始迭代另一个实体。好的。PHP 7哈希表迭代器
如果您还记得的话,数组迭代的主要问题是如何在迭代期间处理元素的删除。php 5为此使用了一个内部数组指针(iap),这有点不太理想,因为一个数组指针必须被拉伸以支持多个同时的foreach循环,并在此基础上与
PHP7使用不同的方法,即,它支持创建任意数量的外部安全哈希表迭代器。这些迭代器必须在数组中注册,从这一点上,它们具有与IAP相同的语义:如果删除了数组元素,则指向该元素的所有哈希表迭代器都将高级到下一个元素。好的。
这意味着foreach将不再使用IAP.forEach循环对
php 5和php 7之间的另一个重要变化与数组重复有关。既然不再使用IAP,那么按值数组迭代在所有情况下只会执行refcount增量(而不是重复数组)。如果在foreach循环期间修改了数组,那么此时将发生重复(根据写入时的复制),foreach将继续处理旧数组。好的。
在大多数情况下,这种更改是透明的,除了性能更好之外,没有其他效果。但是,有一种情况下,它会导致不同的行为,即数组是预先引用的情况:好的。
1 2 3 4 5 6 7 8 | $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 */ |
以前引用数组的按值迭代是特殊情况。在这种情况下,不会发生重复,因此迭代期间对数组的所有修改都将由循环反映出来。在PHP7中,这种特殊情况已经不复存在:数组的逐值迭代将始终处理原始元素,而忽略循环期间的任何修改。好的。
当然,这不适用于引用迭代。如果您通过引用进行迭代,那么所有的修改都将反映在循环中。有趣的是,对于普通对象的按值迭代也是如此:好的。
1 2 3 4 5 6 7 8 | $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之间甚至重复和重复的行为也完全相同)。好的。
测试用例3的变化:foreach不再使用iap,所以
each() 不受循环的影响。前后输出相同。好的。测试用例4和5保持不变:
each() 和reset() 将在更改IAP之前复制数组,而foreach仍然使用原始数组。(即使数组是共享的,IAP更改也不重要。)好的。
第二组例子与
但是,在迭代期间考虑修改时,我们会得到一些有趣的变化。我希望你会发现新的行为更理智。第一个例子:好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | $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) "; } } // 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的两个循环的交叉污染。好的。
另一个现在已经修复的奇怪的边缘情况是,当您删除和添加恰好具有相同哈希值的元素时,会产生奇怪的效果:好的。
1 2 3 4 5 6 7 8 |
以前,hash pointer恢复机制直接跳转到新元素,因为它"看起来"与删除的元素相同(由于哈希和指针冲突)。因为我们不再依赖元素散列来处理任何事情,所以这不再是一个问题。好的。好啊。
在示例3中,不修改数组。在所有其他示例中,您可以修改内容或内部数组指针。这对于PHP数组很重要,因为赋值操作符的语义。
PHP中数组的赋值操作符的工作方式更像是一个懒惰的克隆。与大多数语言不同,将一个变量分配给包含数组的另一个变量将克隆该数组。但是,除非需要,否则不会进行实际的克隆。这意味着只有在修改任一变量(写时复制)时才会进行克隆。
下面是一个例子:
1 2 3 4 5 6 7 8 9 | $a = array(1,2,3); $b = $a; // This is lazy cloning of $a. For the time // being $a and $b point to the same internal // data structure. $a[] = 3; // Here $a changes, which triggers the actual // cloning. From now on, $a and $b are two // different data structures. The same would // happen if there were a change in $b. |
回到您的测试用例,您可以很容易地想象
这里有一篇很好的文章介绍了这种复制对写行为的另一个副作用:php三元运算符:fast或not?
使用
a)
b)是什么触发了预期副本?浏览副本是根据
c)原始数组和foreach()迭代器将有
堆栈溢出问题如何确保在PHP的"foreach"循环中重置值?解决问题的案例(3,4,5)。
下面的示例显示,每个()和reset()都不会影响
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | $array = array(1, 2, 3, 4, 5); list($key2, $val2) = each($array); echo"each() Original (outside): $key2 => $val2<br/>"; foreach($array as $key => $val){ echo"foreach: $key => $val<br/>"; list($key2,$val2) = each($array); echo"each() Original(inside): $key2 => $val2<br/>"; echo"--------Iteration--------<br/>"; if ($key == 3){ echo"Resetting original array pointer<br/>"; reset($array); } } list($key2, $val2) = each($array); echo"each() Original (outside): $key2 => $val2<br/>"; |
输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | each() Original (outside): 0 => 1 foreach: 0 => 1 each() Original(inside): 1 => 2 --------Iteration-------- foreach: 1 => 2 each() Original(inside): 2 => 3 --------Iteration-------- foreach: 2 => 3 each() Original(inside): 3 => 4 --------Iteration-------- foreach: 3 => 4 each() Original(inside): 4 => 5 --------Iteration-------- Resetting original array pointer foreach: 4 => 5 each() Original(inside): 0=>1 --------Iteration-------- each() Original (outside): 1 => 2 |
PHP 7注释
更新这个答案,因为它已经得到了一些流行:这个答案不再适用于php 7。如"向后不兼容的更改"中所述,在php 7中,foreach处理数组的副本,因此数组本身的任何更改都不会反映在foreach循环中。链接上有更多详细信息。
解释(引自php.net):
The first form loops over the array given by array_expression. On each
iteration, the value of the current element is assigned to $value and
the internal array pointer is advanced by one (so on the next
iteration, you'll be looking at the next element).
所以,在第一个示例中,数组中只有一个元素,当移动指针时,下一个元素不存在,所以在添加新元素foreach之后,它会结束,因为它已经"决定"将其作为最后一个元素。
在第二个示例中,您从两个元素开始,foreach循环不是最后一个元素,因此它在下一次迭代时评估数组,从而认识到数组中有新的元素。
我认为这都是文档中解释的每个迭代部分的结果,这可能意味着
测试用例
如果运行此命令:
1 2 3 4 5 6 7 8 9 10 11 |
您将得到这个输出:
1 2 3 4 5 6 |
这意味着它接受了修改,并通过了修改,因为它是"及时"修改的。但如果你这样做:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
你会得到:
1 2 3 4 5 6 |
这意味着数组被修改了,但是由于我们在
详细的解释可以通过php"foreach"的实际工作方式来阅读?这就解释了这种行为背后的本质。
根据PHP手册提供的文档。
On each iteration, the value of the current element is assigned to $v and the internal
array pointer is advanced by one (so on the next iteration, you'll be looking at the next element).
因此,根据您的第一个示例:
1 2 3 4 5 6 | $array = ['foo'=>1]; foreach($array as $k=>&$v) { $array['bar']=2; echo($v); } |
但在第二个例子中:
1 2 3 4 5 6 | $array = ['foo'=>1, 'bar'=>2]; foreach($array as $k=>&$v) { $array['baz']=3; echo($v); } |
这是一个很好的问题,因为许多开发人员,甚至是经验丰富的开发人员,都对PHP处理foreach循环中数组的方式感到困惑。在标准foreach循环中,php复制循环中使用的数组。循环结束后立即丢弃副本。这在一个简单的foreach循环的操作中是透明的。例如:
1 2 3 4 5 |
此输出:
1 2 3 | apple banana coconut |
因此创建了副本,但开发人员没有注意到,因为循环中或循环结束后没有引用原始数组。但是,当您尝试修改循环中的项时,您会发现在完成以下操作时它们是未修改的:
1 2 3 4 5 6 |
此输出:
1 2 3 4 5 6 |
任何对原版的更改都不能被注意到,事实上,即使您清楚地为$item指定了一个值,也没有对原版的更改。这是因为您操作的是$item,它出现在正在处理的$set的副本中。您可以通过引用获取$item来覆盖它,如下所示:
1 2 3 4 5 |
此输出:
1 2 3 4 5 6 |
因此很明显,可以观察到,当通过引用操作$item时,对$item所做的更改是对原始$set的成员所做的。通过引用使用$item还可以防止PHP创建数组副本。要测试这个,首先我们将显示一个演示副本的快速脚本:
1 2 3 4 5 |
此输出:
1 2 3 4 5 6 7 8 9 |
如示例所示,php复制了$set并使用它进行循环,但是当在循环中使用$set时,php将变量添加到原始数组,而不是复制的数组。基本上,PHP只使用复制的数组来执行循环和分配$item。因此,上面的循环只执行了3次,每次它在原始$set的末尾附加另一个值,将原始$set保留为6个元素,但从不进入无限循环。
但是,如果我们引用$item,如我之前提到的那样,会怎么样?添加到上述测试中的单个字符:
1 2 3 4 5 |
导致无限循环。注意,这实际上是一个无限循环,您要么自己杀死脚本,要么等待操作系统耗尽内存。我在脚本中添加了以下行,这样PHP就可以很快用完内存,如果要运行这些无限循环测试,我建议您也这样做:
1 |
所以在前面这个无限循环的例子中,我们看到了为什么编写PHP来创建一个要循环的数组副本。当一个副本被创建并仅由循环构造本身的结构使用时,数组在循环的整个执行过程中保持静态,因此您永远不会遇到问题。
php foreach循环可以与
在foreach循环中,php首先要做的是创建一个数组副本,该数组将被迭代。然后,php迭代这个新的数组的
1 2 3 | <?php $numbers = [1,2,3,4,5,6,7,8,9]; # initial values for our array echo '[cc lang="php"]', print_r($numbers, true), ' |
,'
';foreach($numbers as$index=>number){$numbers[$index]=$number+1;这将更改原始数组echo'在数组的内部='$index,':',$number,'
';显示复制数组中的数据}echo'
','
1 | ', print_r($numbers, true), ' |
;显示原始值(还包括新添加的值)。< /代码>
除此之外,PHP还允许使用
++$number; # we are incrementing the original value
echo 'Inside of the array = ', $index, ': ', $number, '
'; # this is showing the original value
}
echo '
';
echo 'ZZU1〔1〕'; # we are again showing the original value
[cc lang="php"]
注:不允许将
来源:http://bellupper.io/post/47/understanding-php-foreach-loop-with-examples