PHP的“foreach”实际上是如何工作的?

35 浏览
0 Comments

PHP的“foreach”实际上是如何工作的?

首先声明一下,我知道 foreach 的作用和用法。这个问题关心的是它的内部实现,我不希望看到回答中出现“这就是如何使用 foreach 遍历数组”的内容。


很长一段时间我以为 foreach 使用的是数组本身,然后我发现很多资料都说它使用的是数组的副本,所以我就一直以为是这个原因。但最近讨论这个问题时,进行了一些验证后发现事实并非完全正确。

我来说说我的意思。下面的测试用例都会基于下面的数组:

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

测试用例 1

foreach ($array as $item) {
  echo "$item\n";
  $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

foreach ($array as $key => $item) {
  $array[$key + 1] = $item + 2;
  echo "$item\n";
}
print_r($array);
/* Output in loop:    1 2 3 4 5
   $array after loop: 1 3 4 5 6 7 */

这也支持了我们的初步结论,在循环中我们使用了源数组的副本,否则就能在循环过程中看到修改后的值。但是......

如果我们去看一下手册,可以找到这段话:

当 foreach 开始执行的时候,内部数组指针会自动指向数组中的第一个元素。

没错......这似乎表明 foreach 依赖于源数组的指针。但是我们刚刚证明了我们并没有使用源数组,对吗?其实并不完全对。

测试用例 3

// Move the array pointer on one to make sure it doesn't affect the loop
var_dump(each($array));
foreach ($array as $item) {
  echo "$item\n";
}
var_dump(each($array));
/* Output
  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);
}
/* Output: 1 2 3 4 5 */

测试用例 5

foreach ($array as $key => $item) {
  echo "$item\n";
  reset($array);
}
/* Output: 1 2 3 4 5 */

......没什么意外,事实上这似乎证明了“源数组副本”这一理论。


问题

这里到底发生了什么?我的C功力不足以仅仅通过查看PHP源代码来得出合适的结论,我希望有人能将其翻译成英文。

对我来说,foreach使用数组的副本,但是在循环结束后设置了源数组的数组指针指向数组末尾。

  • 这是正确的吗?这就是全部的情况吗?
  • 如果不是,它实际上在做什么?
  • foreach期间使用调整数组指针的函数(each()reset()等)会影响循环的结果吗?
admin 更改状态以发布 2023年5月23日
0
0 Comments

在示例3中,您不会修改数组。在所有其他示例中,您都会修改内容或内部数组指针。这在涉及到PHP数组时很重要,因为赋值运算符的语义不同。 \n\nPHP中的数组赋值运算符更像是一种懒惰的克隆。将一个包含数组的变量分配给另一个变量将克隆该数组,与大多数语言不同。但是,只有在需要时才会执行实际的克隆。这意味着只有在两个变量中的任一变量被修改时才会发生克隆(写时复制)。 \n\n以下是一个例子: \n\n

$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.

\n\n回到您的测试用例,您可以轻松想象foreach创建了一种迭代器,该迭代器具有对数组的引用。这个引用的工作方式就像我示例中的变量$b。然而,迭代器以及引用仅在循环期间存在,然后它们都将被丢弃。现在您可以看到,在3以外的所有情况下,在额外的引用存活期间修改了数组。这会触发克隆,这就解释了这里发生了什么! \n\n以下是另一个这种写时复制行为副作用的优秀文章: PHP三元运算符:快还是慢?

0
0 Comments

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();
    /* ... */
}

对于内部类,使用一个内部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)。只有当你执行某种修改时才会进行真正的复制(称为“复制”)。有关此主题的更全面介绍,请参见You're being lied to

PHP 5

内部数组指针和哈希指针

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将指针备份到当前元素及其哈希值的每个HashPointer中。循环体运行后,如果该元素仍然存在,则IAP将被设置回该元素。但是,如果该元素已被删除,我们将继续使用IAP当前所在的位置。这个方案有些奇怪的行为,我将在下面演示一些。

数组复制

IAP是数组的一个可见特征(通过current系列函数公开),因此在写时复制语意下对IAP的更改会被视为修改。不幸的是,这意味着foreach在许多情况下被迫复制其迭代的数组。确切的条件是:

  1. 数组不是引用(is_ref = 0)。如果它是一个引用,则对其所做的更改应该传播,因此不应复制它。
  2. 数组的引用计数> 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 将被复制,以防止在 $outerArr 中泄漏 $arr 中的 IAP 更改。就上述条件而言,该数组不是引用(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();
}

即,循环体运行前,数组指针已向前移动。这意味着当循环体在处理元素$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中,eachreset都是按引用传递的函数。当将$array传递给它们时,其refcount=2,因此必须进行复制。因此,foreach将再次处理一个单独的数组。

示例:foreach中current()的影响

观察在循环中current()函数的行为是展示各种复制行为的好方法。考虑以下示例:

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

在这里你应该知道current()是一个按引用传递的函数(实际上是prefer-ref),尽管它不修改数组。这是为了能够与所有其他函数友好地配合使用,例如按引用传递的next。按引用传递意味着数组必须被分开,因此$arrayforeach-array将不同。你之所以得到2而不是1,也已经在上面提到了:在执行用户代码之前,foreach会提前移动数组指针,而不是之后。因此,尽管代码在第一个元素处,foreach已经将指针移动到了第二个元素。

现在让我们做个小小的修改:

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

在这里,我们有is_ref=1的情况,因此数组不会被复制(就像上面一样)。但是现在它是一个引用,当传递给按引用传递的current()函数时,数组不再需要被复制。因此,current()foreach在同一个数组上工作。虽然你仍然可以看到那种差一个的行为,这是由于foreach移动指针的方式。

当进行按引用迭代时,你会得到同样的行为:

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困难的根源,因此,考虑一些这种情况下的示例是有益的。

考虑对同一个数组进行的这些嵌套循环(使用按引用迭代来确保它确实是同一个数组):

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。可能意想不到的是,外部循环在第一个元素之后停止了。这是为什么呢?

这背后的原因是嵌套循环的黑科技,如上所述:在循环体运行之前,当前IAP位置和哈希被备份到一个HashPointer中。在循环体之后它将被恢复,但仅当元素仍然存在时,否则当前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保留相同的输出:按值数组迭代始终保持在原始元素上工作。 (在这种情况下,即使在PHP 5和PHP 7之间,refcounting和复制行为也完全相同)。

  • 测试用例3发生变化:Foreach不再使用IAP(内部数组指针),因此each()不会受循环影响。它的输出在之前和之后都相同。

  • 测试用例4和5保持不变:each()reset()在更改IAP之前将复制数组,而foreach仍然使用原始数组。(请注意,即使数组是共享的,IAP的更改也不重要。)

第二个示例集与reference/refcounting配置下current()行为有关。这已经不再有意义,因为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恢复机制直接跳转到新元素,因为它“看起来”像已删除的元素(由于哈希和指针碰撞)。由于我们现在不再依赖元素哈希来做任何事情,这不再是一个问题。

0