菜单 学习猿地 - LMONKEY

VIP

开通学习猿地VIP

尊享10项VIP特权 持续新增

知识通关挑战

打卡带练!告别无效练习

接私单赚外块

VIP优先接,累计金额超百万

学习猿地私房课免费学

大厂实战课仅对VIP开放

你的一对一导师

每月可免费咨询大牛30次

领取更多软件工程师实用特权

入驻
0
0

PHP 内核:foreach 是如何工作的?

原创
05/13 14:22
阅读数 308

foreach 是如何工作的?

首先声明,我知道 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)。 只有在执行某种修改后,才会执行其真正的副本(复制)。 请参阅 你被骗了,以获得有关此主题的更多的介绍。

PHP 5

内部数组指针和散列指针

PHP 5中的数组有一个专用的“内部数组指针”(IAP),它适当地支持修改:每当删除一个元素时,都会检查IAP是否指向该元素。 如果是,则转发到下一个元素。

虽然foreach确实使用了IAP,但还有一个复杂因素:只有一个IAP,但是一个数组可以是多个foreach循环的一部分:

// 在这里使用by-ref迭代来确保它真的
// 两个循环中的相同数组而不是副本
foreach ($arr as &$v1) {
    foreach ($arr as &$v) {
        // ...
    }
}

为了支持只有一个内部数组指针的两个同时循环,foreach执行以下shenanigans:在执行循环体之前,foreach将备份指向当前元素及其散列的指针到每个foreachHashPointer。循环体运行后,如果IAP仍然存在,IAP将被设置回该元素。 但是,如果元素已被删除,我们将只在IAP当前所在的位置使用。这个计划基本上是可行的,但是你可以从中获得很多奇怪的情况,其中一些我将在下面演示。

数组复制

IAP是数组的一个可见特性(通过current系列函数公开),因此IAP计数的更改是在写时复制语义下的修改。不幸的是,这意味着foreach在许多情况下被迫复制它正在迭代的数组。 具体条件是:

  1. 数组不是引用(is_ref = 0)。 如果它是一个引用,那么对它的更改将被传播,因此不应该复制它。
  2. 数组的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将被复制以防止$arr上的IAP更改泄漏到$outerArr。 就上述条件而言,数组不是引用(is_ref = 0),并且在两个地方使用(refcount = 2)。 这个要求是不幸的,也是次优实现的工件(这里不需要修改迭代,因此我们不需要首先使用IAP)。

(*)增加refcount听起来无害,但违反了写时复制(COW)语义:这意味着我们要修改refcount = 2数组的IAP,而COW则要求只能执行修改 on 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,作为一个相当特殊的snowflake,选择做的事情略有不同:

reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
    move_forward(arr);
    code();
}

也就是说,数组指针 在循环体运行之前已经向前移动了。这意味着,当循环体处理元素$i时,IAP已经位于元素$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在foreach中的作用

显示各种复制行为的一个好方法是观察foreach循环中current()函数的行为。看如下这个例子:

foreach ($array as $val) {
    var_dump(current($array));
}
/* 输出: 2 2 2 2 2 */

在这里,你应该知道current()是一个by-ref函数(实际上是:preferences-ref),即使它没有修改数组。它必须很好地处理所有其他函数,如next,它们都是by-ref。通过引用传递意味着数组必须是分开的,因此$array和foreach-array将是不同的。你得到是2而不是1的原因也在上面提到过:foreach在运行用户代码之前指向数组指针,而不是之后。因此,即使代码位于第一个元素,foreach已经将指针指向第二个元素。

现在让我们尝试一下小修改:

$ref = &$array;
foreach ($array as $val) {
    var_dump(current($array));
}
/* 输出: 2 3 4 5 false */

这里我们有is_ref=1的情况,所以数组没有被复制(就像上面那样)。但是现在它是一个引用,当传递给by-ref current()函数时不再需要复制数组。因此,current()和foreach工作在同一个数组上。不过,由于foreach指向指针的方式,你仍可以看到off-by-one行为。

当执行by-ref迭代时,你会得到相同的行为:

foreach ($array as &$val) {
    var_dump(current($array));
}
/* 输出: 2 3 4 5 false */

这里重要的部分是,当通过引用迭代$array时,foreach会将$array设置为is_ref=1,所以基本上情况与上面相同。

另一个小变化,这次我们将数组分配给另一个变量:

$foo = $array;
foreach ($array as $val) {
    var_dump(current($array));
}
/* 输出: 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";
    }
}

// 输出: (1, 1) (1, 3) (1, 4) (1, 5)

这里的预期部分是输出中缺少(1,2),因为元素1被删除了。可能出乎意料的是,外部循环在第一个元素之后停止。这是为什么呢?

这背后的原因是上面描述的嵌套循环攻击:在循环体运行之前,当前IAP位置和散列被备份到一个HashPointer中。在循环体之后,它将被恢复,但是只有当元素仍然存在时,否则将使用当前IAP位置(无论它是什么)。在上面的例子中,情况正是这样:外部循环的当前元素已经被删除,所以它将使用IAP,而内部循环已经将IAP标记为finished !

HashPointer备份+恢复机制的另一个结果是,通过reset()等方法更改IAP。通常不会影响foreach。例如,下面的代码执行起来就像根本不存在reset()一样:

$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
    var_dump($value);
    reset($array);
}
// 输出: 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);
}
// 输出: 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);
}
// 输出: 1, 4

这里根据前面的规则,我们通常期望输出1,1,3,4。实际情况上'FYFY'具有与删除的元素'FYFY'相同的散列,而分配器恰好重用相同的内存位置来存储元素。因此,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;
    }
}
/* 输出: 1 2 3 6 7 8 9 10 */

正如你在本例中所看到的,一旦替换发生,PHP将从头开始迭代另一个实体。

PHP 7

散列表迭代器

如果你还记得,数组迭代的主要问题是如何处理迭代过程中元素的删除。PHP 5为此使用了一个内部数组指针(IAP),这有点不太理想,因为一个数组指针必须被拉伸以支持多个同时进行的foreach循环与reset()等的交互。最重要的是。

PHP 7使用了一种不同的方法,即支持创建任意数量的外部安全散列表迭代器。这些迭代器必须在数组中注册,从这一点开始,它们具有与IAP相同的语义:如果删除了一个数组元素,那么指向该元素的所有hashtable迭代器都将被提升到下一个元素。

这意味着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;
}
/* 旧输出: 1, 2, 0, 4, 5 */
/* 新输出: 1, 2, 3, 4, 5 */

以前,引用数组的按值迭代是一种特殊情况。在本例中,没有发生重复,因此在迭代期间对数组的所有修改都将由循环反映出来。在PHP 7中,这种特殊情况消失了:数组的按值迭代将始终继续处理原始元素,而不考虑循环期间的任何修改。

当然,这不适用于by-reference迭代。如果你通过引用进行迭代,那么所有的修改都将被循环所反映。有趣的是,对于普通对象的按值迭代也是如此:

$obj = new stdClass;
$obj->foo = 1;
$obj->bar = 2;
foreach ($obj as $val) {
    var_dump($val);
    $obj->bar = 42;
}
/* 新旧输出: 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";
    }
}

// 旧输出: (1, 1) (1, 3) (1, 4) (1, 5)
// 新输出: (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)

如你所见,外部循环在第一次迭代之后不再中止。原因是现在两个循环都有完全独立的hashtable散列表迭代器,并且不再通过共享的IAP对两个循环进行交叉污染。

现在修复的另外一个奇怪的边缘现象是,当删除并且添加恰好具有相同的哈希元素时,会得到奇怪的结果:

$array = ['EzEz' => 1, 'EzFY' => 2, 'FYEz' => 3];
foreach ($array as &$value) {
    unset($array['EzFY']);
    $array['FYFY'] = 4;
    var_dump($value);
}
// 旧输出: 1, 4
// 新输出: 1, 3, 4

之前的 HashPointer 恢复机制直接跳转到新元素,因为它“看起来”和删除的元素相同(由于哈希和指针冲突)。由于我们不再依赖于哈希元素,因此不再是一个问题。

发表评论

0/200
0 点赞
0 评论
收藏
为你推荐 换一批