PHP’foreach’如何实际工作?

让我在前面说,我知道什么foreach是,是什么以及如何使用它。这个问题涉及它在引擎盖下如何工作,我不希望有任何答案沿着“这就是你如何循环阵列foreach”的方式。


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

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

$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与数组的副本一起工作,但在循环之后将源数组的数组指针设置为数组的末尾。

  • 这是正确的和整个故事?
  • 如果不是,它究竟在做什么?
  • 是否有一个地方使用调整数组指针函数(任何情况下each()reset()等)过程中foreach可能会影响循环的结果?

 


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本质上仅反映IteratorC级接口。

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

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

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

  • 如果通过引用进行迭代,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)。只有当您执行某种修改时,才会完成一个真正的副本(称为“重复”)。请参阅对此主题进行更广泛的介绍。

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

阵列重复

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

  1. 该数组不是引用(is_ref = 0)。如果它是一个引用,那么对它的更改应该传播,所以它不应该被复制。
  2. 该数组的引用次数> 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将被复制以防止IAP变化$arr从泄漏到$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会再次在单独的阵列上工作。

例子:current在foreach中的效果

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

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

在这里你应该知道这current()是一个by-ref函数(实际上是:prefer-ref),尽管它不修改数组。它必须是为了与所有其他功能,如next所有的参考。通过引用传递意味着数组必须分开,因此$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 的refcount ,所以一旦我们实际上必须事先进行重复。因此$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位置和散列被备份到a中HashPointer。在循环体之后,它将被恢复,但仅当该元素仍然存在时,否则将使用当前的IAP位置(不管它可能是什么)。在上面的例子中,情况恰恰如此:外层循环的当前元素已被删除,所以它将使用已被内层循环标记为已完成的IAP!

HashPointer备份+恢复机制的另一个后果是对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

哈希表迭代器

如果你还记得,数组迭代的主要问题是如何在迭代中处理元素的移除。为此,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保持不变: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恢复机制直接跳转到新元素,因为它“看起来”像移除元素一样(由于碰撞散列和指针)。由于我们不再依赖元素散列来完成任何事情,这已不再是问题。

添加评论

友情链接:蝴蝶教程