编程语言
首页 > 编程语言> > PHP’foreach’如何实际工作?

PHP’foreach’如何实际工作?

作者:互联网

让我通过说我知道foreach是什么,做什么以及如何使用它来为此加上前缀.这个问题涉及它如何在发动机罩下工作,我不希望任何答案都是“这就是你如何用foreach循环一个阵列”.

很长一段时间,我认为foreach使用数组本身.然后我发现许多引用它的数据副本的事实,我已经假设这是故事的结尾.但是我最近讨论了这个问题,经过一些实验后发现这实际上并非100%正确.

让我说明我的意思.对于以下测试用例,我们将使用以下数组:

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

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

这清楚地表明我们不直接使用源数组 – 否则循环将永远持续,因为我们在循环期间不断地将项目推送到数组.但只是为了确保这种情况:

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

这支持了我们的初步结论,我们在循环期间使用源数组的副本,否则我们将在循环期间看到修改后的值.但…

如果我们查看manual,我们会发现以下声明:

When foreach first starts executing, the internal array pointer is automatically reset to the first element of the array.

对……这似乎表明foreach依赖于源数组的数组指针.但我们刚刚证明我们没有使用源数组,对吧?好吧,不完全是.

Test case 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)
*/

因此,尽管我们不直接使用源数组,但我们直接使用源数组指针 – 指针位于循环结束时数组末尾的事实表明了这一点.除非这不是真的 – 如果是,那么test case 1将永远循环.

PHP手册还指出:

As foreach relies on the internal array pointer changing it within the loop may lead to unexpected behavior.

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

Test case 4

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

/* Output: 1 2 3 4 5 */

Test case 5

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

/* Output: 1 2 3 4 5 */

……没有任何出乎意料的事实,它实际上似乎支持“源头复制”理论.

问题

这里发生了什么?我的C-fu对我来说不够好,仅仅通过查看PHP源代码就可以得出一个正确的结论,如果有人能为我翻译成英文,我将不胜感激.

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

>这是正确的还是整个故事?
>如果没有,它到底在做什么?
>在foreach期间使用调整数组指针的函数(each(),reset()等)是否会影响循环的结果?

解决方法:

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

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

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

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

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

>如果使用foreach($arr as& $v)按引用进行迭代,则$arr将变为引用,您可以在迭代期间更改它.
>在PHP 5中,即使您按值迭代也适用,但数组之前是参考:$ref =& $ARR; foreach($ref as $v)
>对象具有by-handle传递语义,对于大多数实际用途而言,它们的行为类似于引用.因此,在迭代期间总是可以更改对象.

在迭代期间允许修改的问题是删除当前所在元素的情况.假设您使用指针来跟踪您当前所在的数组元素.如果现在释放了此元素,则会留下悬空指针(通常会导致段错误).

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

作为最后一个初步,应该注意PHP使用引用计数和写时复制来管理内存.这意味着如果您“复制”一个值,实际上只是重用旧值并增加其引用计数(refcount).只有在执行某种修改后,才会执行真正的副本(称为“复制”).有关此主题的更广泛介绍,请参见You’re being lied to.

PHP 5

内部数组指针和HashPointer

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会将指向当前元素及其散列的指针备份到per-foreach HashPointer中.循环体运行后,如果IAP仍然存在,IAP将被设置回该元素.但是,如果元素已被删除,我们将只在IAP当前所在的位置使用.这个方案主要是有点类型的工作,但是你可以从中获得很多奇怪的行为,其中一些我将在下面演示.

数组重复

IAP是数组的可见特征(通过当前的函数族公开),因为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($outerArr);

这里,将复制$arr以防止$arr上的IAP更改泄漏到$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(意味着迭代已经完成),每个都通过返回false来指示.
>在测试用例4和5中,每个都重置为引用功能. $array在传递给它们时有一个refcount = 2,所以它必须重复.因此,foreach将再次在单独的阵列上工作.

示例:当前在foreach中的影响

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

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

在这里你应该知道current()是一个by-ref函数(实际上是:prefer-ref),即使它不修改数组.它必须是为了与所有其他功能一起玩得很好,就像下一个都是by-ref.引用传递意味着数组必须分开,因此$array和foreach-array将是不同的.上面也提到了你获得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,它已被内部循环标记为已完成!

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

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,因此每个()不受循环影响.它之前和之后将具有相同的输出.
>测试用例4和5保持不变:每个()和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恢复机制直接跳转到新元素,因为它“看起来”与删除的元素相同(由于冲突的散列和指针).由于我们不再依赖于元素哈希,因此不再是一个问题.

标签:php-internals,php,loops,foreach,iteration
来源: https://codeday.me/bug/20190911/1802644.html