PHP 内核:foreach 是如何工作的(一)
foreach 是如何工作的?
首先声明,我知道 foreach 是什么,也知道怎么去用它。但这个问题关心的是,内核中 foreach 是如何运行的,我不想回答关于 “如何使用 foreach 循环数组” 的任何问题。
很长时间我都认为 foreach 是直接作用于数组本身,后来一些资料表明,它作用于数组的一个副本,那时我以为这就是真相了。但最近我又讨论了一下这件事,经过一些试验,发现我之前的想法并非完全正确。
让我来展示一下我的观点。下面的测试用例中我们将使用以下数组:
$array = array(1, 2, 3, 4, 5);
foreach ($array as $item) {
echo "$item\n";
$array[] = $item;
}
print_r($array);
/* 循环中输出: 1 2 3 4 5
循环后的$array: 1 2 3 4 5 1 2 3 4 5 */
这很清晰的表明我们不直接使用数据源 - 否则循环会一直持续下去,因此我们可以在循环中不停的推送元素到数组中。为了保证正确请看下面的测试用例:
foreach ($array as $key => $item) {
$array[$key + 1] = $item + 2;
echo "$item\n";
}
print_r($array);
/* 循环中输出: 1 2 3 4 5
循环后 $array: 1 3 4 5 6 7 */
这印证了我们的初步结论,在循环中使用的是数组的副本,否则我们将看到在循环中改变后的值。 但是...
如果我们查阅手册 手册,我们会发现下面这句话:
当 foreach 首次开始执行时,数组的内部指针自动重置为数组的第一个元素。
没错。。。这似乎表明 foreach 依赖源数组的指针。但是我们刚刚证明我们 没有使用源数组,对吧?好吧,不完全是。
// 将数组指针移动到一个上面确保它不会影响循环
var_dump(each($array));
foreach ($array as $item) {
echo "$item\n";
}
var_dump(each($array));
/* 输出
array(4) {
[1]=>
int(1)
["value"]=>
int(1)
[0]=>
int(0)
["key"]=>
int(0)
}
1
2
3
4
5
bool(false)
*/
因此,尽管我们不支持使用源数组,但是直接使用源数组指针 - 指针位于循环结束时的数组末尾证明了这一点。除非这不是真的 - 如果是,那么 测试用例 1 将永远循环。
PHP 手册还说明:
由于 foreach 依赖与内部数组指针,因此在循环内部改变它可能导致意外的行为。
让我们找出那种 “意外行为” 是什么 (从技术上讲,任何行为都是意外的,因为我们也不知道将会发生什么)。
foreach ($array as $key => $item) {
echo "$item\n";
each($array);
}
/* 输出: 1 2 3 4 5 */
foreach ($array as $key => $item) {
echo "$item\n";
reset($array);
}
/* 输出: 1 2 3 4 5 */
... 意料之中,事实上它似乎支持 「复制源」 理论。
问题
这是怎么回事呢?我的 C-fu 还不够好,不能通过简单地查看 PHP 源代码就得出正确的结论,如果有人能把它翻译成英语,我将不胜感激。
在我看来,foreach
使用数组的 copy ,但是在循环之后将源数组的数组指针设置为数组的末尾。
- 这是真的吗?
- 如果不是,正确的流程是什么样的呢?
- 在
foreach
期间使用调整数组指针 (each()
,reset()
等) 的函数是否会影响循环的结果呢?
解答
foreach
支持三种不同类型值的迭代:
- 数组
- 普通对象
Traversable
对象
在下面的讨论中,我将尝试准确的解释迭代在不同的场景中是如何工作的。到目前为止,最简单的例子是 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 来避免实际的方法调用,这个 API 本质上只是在 C 级对 Iterator 接口的映射。
数组和普通对象的迭代要复杂的多。首先,应该注意,在 PHP 中,“数组” 实际上是有序的字典,它们将按照这个顺序遍历(只要不使用形如 sort 一类的函数对其排序,它就能按照插入的顺序遍历)。这与按照键的自然顺序迭代(其他语言中的列表通常是如何排序的呢?)或者无序(其他语言中的字典通常是如何工作的呢)是截然不同的。
同样的情况也适用于对象,因为对象属性可以看做是另一个(有序的)字典,将属性名映射为对应的值,并加上一些可见性的操作处理。在大多数情况下,对象属性实际上并不是以这种低效的方式存储的。然而,如果你开始遍历一个对象,通常它将被打包转换为一个真正的字典。在这一点上,普通对象的迭代与数组的迭代非常相似(这就是为什么我在这没有过多讨论普通对象的迭代)。
到目前为止,一切顺利。遍历字典应该不难,对吧?当你意识到数组 / 对象可以在迭代期间更改时,问题就出现了。发生这种情况有如下几种:
- 如果你使用 foreach($arr as &$v) 通过引用迭代,那么 $arr 将会转为引用,你可以在迭代期间更改它。
- 在 PHP 5 中,即使按值迭代也是如此,但数组之前是一个引用:$ref=&$arr;foreach($ref as $v)。
- 对象具有处理传递语义的功能,对于大多数实际用途而言,它们的行为类似于引用。 因此,在迭代期间总是可以更改对象。
在迭代期间允许修改的问题是删除当前所在元素的情况。假设你使用指针来跟踪你当前所在的数组元素。 如果现在释放了此元素,则会留下悬空指针(通常会导致 segfault 段错误)
有不同的方法来解决这个问题。 PHP 5 和 PHP 7 在这方面有很大不同,我将在下面描述这两种情况。 总结是 PHP 5 的方法相当愚蠢并导致出现各种奇怪的边缘情况问题,而 PHP 7 更复杂的方法导致出现更可预测和一致的行为情况。
初步得出结论,PHP 是使用引用计数和写时复制来管理内存。 这意味着如果你 “复制” 一个值,实际上只是复用其旧值并增加其引用计数(refcount)。 只有在执行某种修改后,才会执行其真正的副本(复制)。 请参阅 你被骗了,以获得有关此主题的更多的介绍。