后端技术 · Develop

PHP 中的 foreach 工作原理

•̀.̫•́✧ · 7月26日 · 2020年本文7473字 · 阅读19分钟109

foreach 支持三种不同值的迭代:

  • 数组
  • 普通对象
  • Traversable 对象

在下文中,我将尝试精确解释迭代在不同情况下的工作原理。到目前为止,最简单的情况是 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();
    /* ... */
}

对于内部类,通过使用基本上只镜像 IteratorC 级接口的内部 API 来避免实际的方法调用。

数组和普通对象的迭代要复杂得多。首先,应该注意的是,PHP 中的 “数组” 实际上是有序的字典,它们将按照这个顺序遍历(只要你没有使用类似的东西就匹配插入顺序 sort)。这与通过键的自然顺序(其他语言中的列表通常如何工作)或根本没有定义的顺序(其他语言中的字典如何工作)相反。

这同样适用于对象,因为对象属性可以看作是将属性名称映射到其值的另一个(有序)字典,以及一些可见性处理。在大多数情况下,对象属性实际上并不是以这种相当低效的方式存储的。但是,如果您开始迭代对象,则通常使用的压缩表示将转换为实际字典。那时,普通对象的迭代变得非常类似于数组的迭代(这就是为什么我不在这里讨论普通对象迭代)。

到现在为止还挺好。迭代字典不算太难,对吧?当您意识到在迭代期间数组 / 对象可以更改时,问题就开始了。有多种方法可以实现:

如果您通过引用迭代使用 foreach ($arr as &$v) 然后 $ar r 转换为引用,您可以在迭代期间更改它。
在 PHP 5 中,即使按值迭代,同样适用,但数组事先是引用: $ref =& $arr; foreach ($ref as $v)
对象具有通过处理传递语义,这必须实际意味着它们的行为类似于引用。因此,在迭代期间总是可以更改对象。
在迭代期间允许修改的问题是删除当前所在元素的情况。假设您使用指针来跟踪您当前所在的数组元素。如果现在释放此元素,则会留下悬空指针(通常会导致段错误)。

有不同的方法来解决这个问题。PHP 5 和 PHP 7 在这方面有很大不同,我将在下面描述这两种行为。总结是 PHP 5 的方法相当愚蠢并导致各种奇怪的边缘情况问题,而 PHP 7 更复杂的方法导致更可预测和一致的行为。

作为最后的初始化,应该注意 PHP 使用引用计数和写时复制来管理内存。这意味着如果您 “复制” 一个值,实际上只是复用旧值并增加其引用计数(refcount)。只有在执行某种修改后,才会执行真正的副本(称为 “复制”)。

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

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

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

为了支持只有一个内部数组指针的两个同时循环,foreach 执行以下魔术方法:在执行循环体之前,foreach 会将指向当前元素及其散列的指针备份到 per-foreach 中 HashPointer。循环体运行后,如果 IAP 仍然存在,IAP 将被设置回该元素。但是,如果元素已被删除,我们将只使用 IAP 当前所在的位置。这个计划大多有点类型,但是你可以从中获得许多奇怪的行为,其中一些我将在下面演示。

数组重复
IAP 是数组的可见特征(通过 current 函数族公开),因为 IAP 计数的这种更改是在写时复制语义下的修改。不幸的是,这意味着 foreach 在很多情况下被迫复制它迭代的数组。确切的条件是:

该数组不是引用(is_ref = 0)。如果它是一个引用,那么对它的更改应该传播,因此不应该重复。
该数组的 refcount> 1。如果 refcount 为 1,则不共享该数组,我们可以直接修改它。
如果数组没有重复(is_ref = 0,refcount = 1),那么只有它的引用计数会递增(*)。此外,如果使用 foreach by reference,则(可能重复的)数组将变为引用。

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

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

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

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

(*)这里增加 refcount 听起来无害,但违反了写时复制(COW)语义:这意味着我们要修改 refcount = 2 数组的 IAP,而 COW 规定只能对 refcount 执行修改 = 1 个值。此违规导致用户可见的行为更改(而 COW 通常是透明的),因为迭代数组上的 IAP 更改将是可观察的 – 但直到对阵列进行第一次非 IAP 修改。相反,三个 “有效” 选项将是 a)始终复制,b)不递增引用计数,从而允许迭代数组在循环中任意修改,或 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。这就是为什么在迭代期间显示修改的代码示例将始终取消设置下一个元素而不是当前元素的原因。

示例:您的测试用例
上面描述的三个方面应该为您提供对 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 的影响
显示各种复制行为的一种好方法是观察 current () foreach 循环中函数的行为 。考虑这个例子:

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

在这里你应该知道这 current () 是一个 by-ref 函数(实际上是:prefer-ref ),即使它没有修改数组。它必须是为了与所有其他函数一起使用,这些函数 next 都是 by-ref 。引用传递意味着必须分离数组,因此 $arrayforeach 数组将是不同的。上面提到 2 的 1 是你得到的原因:在运行用户代码之前 foreach 推进数组指针,而不是之后。因此,即使代码位于第一个元素,foreach 已经将指针提升到第二个元素。

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

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

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

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

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 循环启动时,引用的引用次数为 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";
    }
}

// Output: (1, 1) (1, 3) (1, 4) (1, 5)

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

这背后的原因是上面描述的嵌套循环黑客:在循环体运行之前,当前的 IAP 位置和哈希被备份到一个 HashPointer。在循环体之后,它将被恢复,但仅当元素仍然存在时,否则使用当前的 IAP 位置(无论它可能是什么)。在上面的例子中,情况确实如此:外部循环的当前元素已被删除,因此它将使用 IAP,它已被内部循环标记为已完成!

HashPointe r 备份 + 恢复机制的另一个后果是 IAP 的更改 reset () 通常不会影响 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
Hashtable 迭代器
如果您还记得,数组迭代的主要问题是如何处理迭代中的元素删除。为了这个目的,PHP 5 使用了单个内部数组指针(IAP),这有点不是最理想的,因为必须拉伸一个数组指针以支持多个同时的 foreach 循环和与之交互 reset () 等。

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

这意味着将的 foreach 不再使用 IAP 可言。foreach 循环对结果 current () 等绝对没有影响,并且它自己的行为永远不会受到诸如此类函数的影响 reset ()。

数组重复
PHP 5 和 PHP 7 之间的另一个重要变化涉及阵列复制。现在不再使用 IAP,在所有情况下,按值数组迭代只会执行引用计数增量(而不是重复数组)。如果在 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 之间完全相同)。

测试用例 3 更改:Foreach 不再使用 IAP ,因此 each () 不受循环影响。它之前和之后将具有相同的输出。

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

第二组示例与 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 恢复机制直接跳转到新元素,因为它 “看起来” 像是与 remove 元素相同(由于冲突的哈希和指针)。由于我们不再依赖元素哈希来解决任何问题,因此这不再是一个问题。

参考

[1] http://blog.golemon.com/2007/01/youre-bein…
[2] https://stackoverflow.com/questions/100576…

0 条回应