PHP'foreach'如何实际工作?

让我以此为前缀说我知道什么是foreach ,是否以及如何使用它。 这个问题涉及它在引擎盖下的工作方式,我不希望按照“这是用foreach循环数组”的方式做出任何答案。


很长一段时间,我认为foreach与数组本身一起工作。 然后,我发现它提供了许多与数组副本一起工作的事实,并且我认为这是故事的结尾。 但是我最近就这个问题进行了讨论,经过一些实验后发现这实际上并不是100%真实的。

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

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

测试案例1:

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

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 "$itemn";
}

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 "$itemn";
  each($array);
}

/* Output: 1 2 3 4 5 */

测试案例5:

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

/* Output: 1 2 3 4 5 */

......没有什么意外的,实际上它似乎支持“源头”的理论。


问题

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

在我看来, foreach与数组的副本一起工作,但在循环之后将源数组的数组指针设置为数组的末尾。

  • 这是正确的和整个故事?
  • 如果不是,它究竟在做什么?
  • 有没有在foreach期间使用调整数组指针( each()reset()each()函数会影响循环结果的情况?

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

    对于内部类来说,实际的方法调用可以通过使用内部API来避免,该API本质上只是镜像C级Iterator接口。

    数组和平面对象的迭代显着更复杂。 首先,应该指出的是,在PHP“阵列”是真正有序字典的时候,就会按照这个顺序(其中插入顺序,只要你不使用类似匹配遍历sort )。 这与按键的自然顺序(其他语言中的列表经常工作)或者根本没有定义的顺序(其他语言的字典经常工作)是相反的。

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

    到现在为止还挺好。 迭代字典不会太难,对吧? 当你意识到在迭代过程中数组/对象可以改变时,问题就开始了。 有多种方式可以发生:

  • 如果使用foreach ($arr as &$v)通过引用进行迭代,则$arr将变为引用,并且可以在迭代过程中对其进行更改。
  • 在PHP 5中,即使按值迭代也是如此,但数组事先是一个参考: $ref =& $arr; foreach ($ref as $v) $ref =& $arr; foreach ($ref as $v)
  • 对象具有by-handle传递语义,这对于实际目的来说意味着它们的行为与引用类似。 所以在迭代过程中总是可以改变对象。
  • 在迭代过程中允许修改的问题是当前所在元素被删除的情况。 假设你使用一个指针来跟踪你当前在哪个数组元素。 如果这个元素现在被释放,你将留下一个悬挂指针(通常导致段错误)。

    解决这个问题有不同的方法。 PHP 5和PHP 7在这方面差异很大,我将在下面描述这两种行为。 总结一下,PHP 5的方法相当愚蠢,会导致各种奇怪的边缘案例问题,而PHP 7更多的涉及方法会产生更可预测和一致的行为。

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

    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执行以下schenanigans:在执行循环体之前,foreach会将指向当前元素的指针以及其散列值HashPointer到每个foreach HashPointer 。 循环体运行后,如果IAP仍然存在,它将被设置回该元素。 如果元素已被删除,我们将使用IAP当前所处的位置。 这种方案大多是有点作用的,但是你可以从中得到很多奇怪的行为,其中一些我将在下面展示。

    阵列重复

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

  • 该数组不是引用(is_ref = 0)。 如果它是一个引用,那么对它的更改应该传播,所以它不应该被复制。
  • 该数组的引用次数> 1。 如果refcount为1,那么数组不会共享,我们可以直接修改它。
  • 如果数组不重复(is_ref = 0,refcount = 1),那么只有它的引用计数会增加(*)。 此外,如果使用通过引用的foreach,那么(可能重复的)数组将被转换为引用。

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

    function iterate($arr) {
        foreach ($arr as $v) {}
    }
    
    $outerArr = [0, 1, 2, 3, 4];
    iterate($arr);
    

    在这里, $arr将被复制以防止$arr上的IAP更改泄漏到$outerArr 。 就上面的条件而言,该数组不是引用(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 。 这就是为什么在迭代期间显示修改的代码示例总是会取消设置下一个元素,而不是当前元素。

    例子:你的测试用例

    上述三个方面应该为您提供对每个实现的特质的完整印象,并且我们可以继续讨论一些示例。

    在这一点上,您的测试用例的行为很容易解释:

  • 在测试用例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效果

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

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

    这里你应该知道current()是一个by-ref函数(实际上是:prefer-ref),即使它不修改数组。 它必须是为了与所有其他功能(如下next都是by-ref)一起玩。 通过引用传递意味着数组必须分开,因此$array和foreach数组将不同。 你得到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为1_ref = 1,所以基本上你的情况与上面相同。

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

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

    这里$array循环开始时$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";
        }
    }
    
    // Output: (1, 1) (1, 3) (1, 4) (1, 5)
    

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

    这背后的原因是上面描述的嵌套循环hack:在循环体运行之前,当前的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 "$valn";
        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循环期间被修改,那么会发生复制(根据copy-on-write),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保持不变:在更改IAP之前, each()reset()将复制数组,而foreach仍使用原始数组。 (不是说IAP的变化将会重要,即使数组是共享的。)

  • 第二组示例与current()在不同参考/ 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";
        }
    }
    
    // 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恢复机制直接跳转到新元素,因为它“看起来”像移除元素一样(由于碰撞散列和指针)。 由于我们不再依赖元素散列来完成任何事情,这已不再是问题。


    在示例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.
    

    回想一下你的测试用例,你可以很容易想象foreach创建了一种带有对数组的引用的迭代器。 这个引用和我的例子中的变量$b完全一样。 但是,迭代器和引用一起只在循环过程中生效,然后它们都被丢弃。 现在你可以看到,除了3以外的所有情况下,数组在循环期间被修改,而这个额外的引用是活着的。 这触发了一个克隆,这解释了这里发生了什么!

    这篇文章描述了这种复制写入行为的另一个副作用:PHP三元运算符:快还是慢?


    使用foreach()时需要注意以下几点:

    a) foreach在原始数组的预期副本上工作。 这意味着foreach()将拥有SHARED数据存储空间,除非在Notes / User评论中未创建prospected copy

    b)什么触发了预期的副本 ? 预期的副本是基于copy-on-writecopy-on-write策略创建的,即每当传递给foreach()的数组发生更改时,都会创建原始数组的副本。

    c)原始数组和foreach()迭代器将具有DISTINCT SENTINEL VARIABLES ,即一个用于原始数组,另一个用于foreach; 请参阅下面的测试代码。 SPL,迭代器和数组迭代器。

    堆栈溢出问题如何确保在PHP的'foreach'循环中重置值? 解决您的问题(3,4,5)。

    以下示例显示每个()和reset()都不会影响foreach()迭代器的SENTINEL变量(for example, the current index variable)

    $array = array(1, 2, 3, 4, 5);
    
    list($key2, $val2) = each($array);
    echo "each() Original (outside): $key2 => $val2<br/>";
    
    foreach($array as $key => $val){
        echo "foreach: $key => $val<br/>";
    
        list($key2,$val2) = each($array);
        echo "each() Original(inside): $key2 => $val2<br/>";
    
        echo "--------Iteration--------<br/>";
        if ($key == 3){
            echo "Resetting original array pointer<br/>";
            reset($array);
        }
    }
    
    list($key2, $val2) = each($array);
    echo "each() Original (outside): $key2 => $val2<br/>";
    

    输出:

    each() Original (outside): 0 => 1
    foreach: 0 => 1
    each() Original(inside): 1 => 2
    --------Iteration--------
    foreach: 1 => 2
    each() Original(inside): 2 => 3
    --------Iteration--------
    foreach: 2 => 3
    each() Original(inside): 3 => 4
    --------Iteration--------
    foreach: 3 => 4
    each() Original(inside): 4 => 5
    --------Iteration--------
    Resetting original array pointer
    foreach: 4 => 5
    each() Original(inside): 0=>1
    --------Iteration--------
    each() Original (outside): 1 => 2
    
    链接地址: http://www.djcxy.com/p/1525.html

    上一篇: How does PHP 'foreach' actually work?

    下一篇: LINQ equivalent of foreach for IEnumerable<T>