PHP 内核:foreach 是如何工作的(一)

foreach 是如何工作的?

八重樱:PHP 内核:foreach 是如何工作的(二)zhuanlan.zhihu.com图标

首先声明,我知道 foreach 是什么,也知道怎么去用它。但这个问题关心的是,内核中 foreach 是如何运行的,我不想回答关于 “如何使用 foreach 循环数组” 的任何问题。

很长时间我都认为 foreach 是直接作用于数组本身,后来一些资料表明,它作用于数组的一个副本,那时我以为这就是真相了。但最近我又讨论了一下这件事,经过一些试验,发现我之前的想法并非完全正确。


让我来展示一下我的观点。下面的测试用例中我们将使用以下数组:


$array = array(1, 2, 3, 4, 5);

测试用例 1:

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 */

这很清晰的表明我们不直接使用数据源 - 否则循环会一直持续下去,因此我们可以在循环中不停的推送元素到数组中。为了保证正确请看下面的测试用例:

测试用例 2:

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 依赖源数组的指针。但是我们刚刚证明我们 没有使用源数组,对吧?好吧,不完全是。

测试用例 3:

// 将数组指针移动到一个上面确保它不会影响循环
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 依赖与内部数组指针,因此在循环内部改变它可能导致意外的行为。

让我们找出那种 “意外行为” 是什么 (从技术上讲,任何行为都是意外的,因为我们也不知道将会发生什么)。

测试用例 4:

foreach ($array as $key => $item) {
  echo "$item\n";
  each($array);
}

/* 输出: 1 2 3 4 5 */

测试用例 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 对象,因为这些 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)。 只有在执行某种修改后,才会执行其真正的副本(复制)。 请参阅 你被骗了,以获得有关此主题的更多的介绍。

更多学习内容请访问:

八重樱:腾讯T3-T4标准精品PHP架构师教程目录大全,只要你看完保证薪资上升一个台阶(持续更新)zhuanlan.zhihu.com图标

以上内容希望帮助到大家,很多PHPer在进阶的时候总会遇到一些问题和瓶颈,业务代码写多了没有方向感,不知道该从那里入手去提升,对此我整理了一些资料,包括但不限于:分布式架构、高可扩展、高性能、高并发、服务器性能调优、TP6,laravel,YII2,Redis,Swoole、Swoft、Kafka、Mysql优化、shell脚本、Docker、微服务、Nginx等多个知识点高级进阶干货需要的可以免费分享给大家,需要的可以加入我的官方群点击此处

编辑于 05-20

文章被以下专栏收录