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

2022-08-30 05:45:03

让我在前面加上前缀,说我知道什么是,做什么以及如何使用它。这个问题涉及它在引擎盖下是如何工作的,我不希望有任何答案,比如“这就是你如何循环数组”。foreachforeach


很长一段时间,我假设这与数组本身一起工作。然后我发现了很多关于它适用于数组副本的事实,从那以后我就认为这是故事的结尾。但是我最近就这个问题进行了讨论,经过一些实验后发现这实际上并不是100%正确的。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-fu还不够好,我无法通过查看PHP源代码来提取正确的结论,如果有人能为我翻译成英语,我将不胜感激。

在我看来,它适用于数组的副本,但在循环之后将源数组的数组指针设置为数组的末尾。foreach

  • 这是正确的,整个故事吗?
  • 如果不是,它到底在做什么?
  • 在 a 期间使用调整数组指针(等)的函数可能会影响循环的结果吗?each()reset()foreach

答案 1

foreach支持对三种不同类型的值进行迭代:

在下文中,我将尝试准确解释迭代在不同情况下的工作原理。到目前为止,最简单的情况是对象,因为这些基本上只是语法糖,用于沿着这些行的代码:Traversableforeach

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

对于内部类,通过使用本质上只在 C 级别上镜像接口的内部 API,可以避免实际的方法调用。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)。只有执行某种修改后,才会完成真正的副本(称为“复制”)。请参阅您被骗了,以获取有关此主题的更广泛介绍。

5 菲律宾比索

内部数组指针和哈希指针

PHP 5 中的数组有一个专用的“内部数组指针”(IAP),它正确地支持修改:每当删除元素时,都会检查IAP是否指向此元素。如果是这样,则将其提升到下一个元素。

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

// 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) {
        // ...
    }
}

要仅用一个内部数组指针支持两个同时循环,请执行以下操作:在执行循环正文之前,会将指向当前元素的指针及其哈希备份到 per-foreach 中。循环正文运行后,如果 IAP 仍然存在,则将它设置回此元素。但是,如果该元素已被删除,我们将仅使用IAP当前所在的任何位置。这个方案大多是有效的,但是你可以从中得到很多奇怪的行为,其中一些我将在下面演示。foreachforeachHashPointer

阵列复制

IAP 是数组的可见功能(通过函数系列公开),因为对 IAP 的此类更改算作写入时复制语义下的修改。不幸的是,这意味着在许多情况下,它被迫复制它正在迭代的数组。确切的条件是:currentforeach

  1. 数组不是引用 (is_ref=0)。如果它是一个引用,那么对它的更改应该传播,所以它不应该被复制。
  2. 该数组具有 refcount>1。如果为 1,则数组不共享,我们可以自由地直接修改它。refcount

如果数组不重复(is_ref=0,refcount=1),则只有其递增 (*)。此外,如果使用按引用,则(可能重复的)数组将转换为引用。refcountforeach

将此代码视为发生重复的示例:

function iterate($arr) {
    foreach ($arr as $v) {}
}

$outerArr = [0, 1, 2, 3, 4];
iterate($outerArr);

在这里,将进行复制以防止IAP更改泄漏到。根据上述条件,数组不是引用(is_ref=0),而是在两个地方使用(refcount=2)。这个要求是不幸的,并且是次优实现的产物(这里没有迭代期间修改的问题,因此我们首先不需要使用IAP)。$arr$arr$outerArr

(*)增加 here 听起来无害,但违反了写入时复制 (COW) 语义:这意味着我们将修改 refcount=2 数组的 IAP,而 COW 规定只能在 refcount=1 值上执行修改。此冲突会导致用户可见的行为更改(而 COW 通常是透明的),因为迭代数组上的 IAP 更改是可观察的,但仅限于数组上的第一次非 IAP 修改。相反,三个“有效”选项是a)始终重复,b)不增加,从而允许在循环中任意修改迭代数组,或者c)根本不使用IAP(PHP 7解决方案)。refcountrefcount

职位晋升顺序

您必须注意最后一个实现细节,才能正确理解下面的代码示例。在伪代码中,循环访问某些数据结构的“正常”方式如下所示:

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

也就是说,数组指针在循环体运行之前已经向前移动。这意味着当循环体在元素上工作时,IAP已经在元素上。这就是为什么在迭代期间显示修改的代码示例将始终是下一个元素,而不是当前元素的原因。$i$i+1unset

示例:测试用例

上面描述的三个方面应该可以让您对实现的特性有一个基本的完整印象,我们可以继续讨论一些示例。foreach

此时,测试用例的行为很容易解释:

  • 在测试用例 1 和 2 中,以 refcount=1 开头,因此它不会被复制: 只有 递增。当循环主体随后修改数组(此时 refcount=2)时,将在该点发生重复。Foreach 将继续处理 未经修改的副本。$arrayforeachrefcount$array

  • 在测试用例 3 中,数组再次不重复,因此将修改变量的 IAP。在迭代结束时,IAP 为 NULL(表示迭代已完成),这表示返回 。foreach$arrayeachfalse

  • 在测试用例 4 和 5 中,两者都是按引用函数。当它传递给他们时,它有一个,所以它必须被复制。因此,将再次处理一个单独的数组。eachreset$arrayrefcount=2foreach

示例:在前期的影响current

显示各种重复行为的一个好方法是观察循环内函数的行为。请考虑以下示例:current()foreach

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

在这里,您应该知道这是一个by-ref函数(实际上是:prefer-ref),即使它不修改数组。它必须是为了与所有其他功能(例如所有by-ref)一起玩得漂亮。按引用传递意味着数组必须分开,因此和将不同。上面还提到了您得到而不是的原因:在运行用户代码之前推进数组指针,而不是之后。因此,即使代码位于第一个元素,也已将指针提升到第二个元素。current()next$arrayforeach-array21foreachforeach

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

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

这里我们有is_ref=1的情况,所以数组不会被复制(就像上面一样)。但现在它是一个引用,数组在传递给 by-ref 函数时不再需要重复。因此,并在同一数组上工作。但是,由于指针前进的方式,您仍然会看到 off-by-one 行为。current()current()foreachforeach

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

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

这里重要的部分是,当通过引用迭代时,foreach将产生is_ref = 1,因此基本上您具有与上述相同的情况。$array

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

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

在这里,当循环启动时,的引用计数是2,所以一次我们实际上必须预先进行复制。因此,foreach 使用的数组将从一开始就完全分开。这就是为什么在循环之前的任何位置都可以获得IAP的位置(在本例中,它位于第一个位置)。$array$array

示例:迭代期间的修改

试图在迭代过程中考虑修改是我们所有前期问题的起源,因此它有助于考虑这种情况的一些示例。

考虑同一数组上的这些嵌套循环(其中by-ref迭代用于确保它确实是相同的循环):

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位置和哈希被备份到一个.在循环体之后,它将被恢复,但前提是元素仍然存在,否则将使用当前的IAP位置(无论它可能是什么)。在上面的示例中,情况正是如此:外部循环的当前元素已被删除,因此它将使用IAP,该IAP已被内部循环标记为已完成!HashPointer

备份+还原机制的另一个后果是,对IAP的更改通常不会影响。例如,以下代码的执行方式就好像 根本不存在一样:HashPointerreset()foreachreset()

$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
    var_dump($value);
    reset($array);
}
// output: 1, 2, 3, 4, 5

原因是,虽然临时修改了 IAP,但它将在循环主体之后恢复到当前的 foreach 元素。要强制对循环产生影响,您必须另外删除当前元素,以便备份/还原机制失败:reset()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

但是,这些例子仍然是理智的。如果您记得还原使用指向元素及其哈希的指针来确定它是否仍然存在,那么真正的乐趣就开始了。但是:哈希有冲突,指针可以重复使用!这意味着,通过仔细选择数组键,我们可以相信已删除的元素仍然存在,因此它将直接跳转到它。例如:HashPointerforeach

$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

在这里,我们通常应该根据前面的规则期望输出。如何发生的事情是,它具有与删除的元素相同的哈希值,并且分配器碰巧重用相同的内存位置来存储元素。因此,foreach 最终会直接跳转到新插入的元素,从而缩短循环。1, 1, 3, 4'FYFY''EzFY'

在循环期间替换迭代的实体

我想提到的最后一个奇怪的情况是,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将在替换发生后从头开始迭代另一个实体。

7 菲律宾比索

哈希表迭代器

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

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

这意味着将不再使用IAP。循环对 etc. 的结果绝对没有影响,它自己的行为永远不会受到 etc 等函数的影响。foreachforeachcurrent()reset()

阵列复制

PHP 5 和 PHP 7 之间的另一个重要变化与数组复制有关。现在不再使用 IAP,在所有情况下,按值数组迭代将仅执行增量(而不是复制数组)。如果在循环期间修改了数组,此时将发生重复(根据写入时复制),并将继续处理旧数组。refcountforeachforeach

在大多数情况下,此更改是透明的,除了更好的性能之外,没有其他影响。但是,有一种情况会导致不同的行为,即数组事先是引用的情况:

$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 更改:不再使用 IAP,因此不受循环影响。它之前和之后将具有相同的输出。Foreacheach()

  • 测试用例 4 和 5 保持不变:并将在更改 IAP 之前复制数组,同时仍使用原始数组。(并不是说 IAP 更改很重要,即使阵列是共享的。each()reset()foreach

第二组示例与 不同配置下的行为有关。这不再有意义,因为完全不受循环的影响,因此其返回值始终保持不变。current()reference/refcountingcurrent()

但是,在迭代期间考虑修改时,我们会得到一些有趣的更改。我希望你会发现新的行为更理智。第一个示例:

$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还原机制直接跳转到新元素,因为它“看起来”与已删除的元素相同(由于哈希和指针冲突)。由于我们不再依赖元素哈希来获取任何内容,因此这不再是问题。


答案 2

在示例 3 中,您没有修改数组。在所有其他示例中,您可以修改内容或内部数组指针。当涉及到PHP数组时,这很重要,因为赋值运算符的语义。

PHP 中数组的赋值运算符的工作方式更像是一个惰性克隆。将一个变量分配给另一个包含数组的变量将克隆该数组,这与大多数语言不同。但是,除非需要,否则不会进行实际的克隆。这意味着仅当修改了任一变量(写入时复制)时,才会进行克隆。

下面是一个示例:

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

回到测试用例,您可以轻松想象这会创建某种具有数组引用的迭代器。此引用的工作方式与我示例中的变量完全相同。但是,迭代器和引用仅在循环期间处于活动状态,然后,它们都将被丢弃。现在,您可以看到,在除 3 之外的所有情况下,数组在循环期间都被修改,而此额外引用处于活动状态。这会触发克隆,这就解释了这里发生了什么!foreach$b

这是一篇关于这种写入时复制行为的另一个副作用的优秀文章:PHP三元运算符:快速与否?


推荐