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

2272

让我先说一下,我知道foreach是什么,它的功能以及如何使用它。这个问题关注的是它在幕后的工作原理,我不希望得到任何类似“这就是你如何使用foreach循环数组”的答案。


很长一段时间,我认为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语言功夫不够好,无法通过查看PHP源代码来得出正确的结论,如果有人能将其翻译成英语,我将不胜感激。

看起来foreach使用数组的副本,但在循环结束后将源数组的数组指针设置为数组的末尾。

  • 这是正确的吗?
  • 如果不是,它真正做了什么?
  • foreach期间使用调整数组指针的函数(each(), reset()等)是否会影响循环的结果?

8
@DaveRandom 这篇文章应该加上 [tag:php-internals] 标签,但是其他五个标签是否需要替换就由你决定了。 - Michael Berkowski
6
看起来像COW,没有删除句柄。 - zb'
176
一开始我想:“天啊,又是一个新手问题,去看文档吧...嗯,显然是未定义的行为。” 然后我读完了整个问题,必须说:我很喜欢它。你在写测试用例方面做了相当多的努力。 另外,测试用例4和5是一样的吗? - knittl
24
关于为什么对数组指针进行操作是有意义的,我有一个想法:PHP需要重置和移动原始数组的内部数组指针以及副本,因为用户可能会请求对当前值的引用 (foreach ($array as &$value))——PHP需要知道原始数组中的当前位置,即使实际上正在遍历副本。 - Niko
6
在我看来,PHP文档在描述核心语言特性的细微差别方面做得不太好。但这或许是因为有太多特殊情况被嵌入到该语言中了。 - Oliver Charlesworth
显示剩余11条评论
7个回答

1827

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,它在C级别上基本上只是镜像了Iterator接口。

数组和普通对象的迭代要复杂得多。首先,应该注意的是,在PHP中,“数组”实际上是有序字典,并且将按照此顺序进行遍历(只要您没有使用类似于sort的东西)。这与按键的自然顺序迭代(其他语言中列表的工作方式)或根本没有定义顺序(其他语言中字典的工作方式)相反。

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

到目前为止还好。迭代字典不会太难,对吧?问题在于您意识到在迭代期间,数组/对象可以发生更改。这可以通过多种方式实现:

  • 如果使用foreach ($arr as &$v)进行引用迭代,则$arr将被转换为引用,并且您可以在迭代期间更改它。
  • 在PHP 5中,即使按值迭代,但数组先前是引用也适用:$ref =& $arr; foreach ($ref as $v)
  • 对象具有通过句柄传递语义,对于大多数实际目的来说,这意味着它们的行为类似于引用。因此,在迭代期间始终可以更改对象。

允许在迭代期间进行修改的问题在于当前正在处理的元素被删除的情况。假设您使用指针来跟踪当前的数组元素,如果现在释放了该元素,则会留下一个悬空指针(通常导致段错误)。

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

最后需要注意的是,PHP使用引用计数和写时复制来管理内存。这意味着如果您“复制”一个值,实际上只是重用旧值并增加其引用计数(refcount)。只有当您执行某种修改操作时,才会进行真正的复制(称为“复制”)。请参见You're being lied to以获取有关此主题的更全面介绍。

PHP 5

内部数组指针和哈希指针

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将备份当前元素和其哈希值的指针到每个 HashPointer 中。在循环体运行之后,如果该元素仍然存在,则IAP将被设置回此元素。然而,如果该元素已被删除,我们将只使用IAP当前所在的位置。这个方案大多数情况下基本上有点奇怪的行为,下面我会举一些例子。
数组复制
IAP是数组的可见特性(通过current函数系列公开),因此对IAP的更改在写时复制语义下算作修改。不幸的是,这意味着在许多情况下,foreach被迫复制它正在迭代的数组。具体条件如下:
1.数组不是引用(is_ref=0)。如果它是一个引用,那么对它的更改应该传播,因此不应该复制它。 2.数组具有refcount>1。如果refcount为1,则数组没有共享,我们可以直接修改它。
如果数组没有被复制(is_ref=0,refcount=1),则只会增加其refcount (*)。此外,如果使用引用的foreach,则(可能复制的)数组将变成一个引用。
请考虑以下代码作为复制发生的示例:
function iterate($arr) {
    foreach ($arr as $v) {}
}

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

在这里,$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。这就是为什么展示迭代期间修改的代码示例总是会unset下一个元素而不是当前元素的原因。

上述三个方面应该让你对foreach实现的特点有了一个基本的了解,我们可以继续讨论一些例子。

现在可以简单地解释你的测试用例的行为:

  • 在测试用例1和2中,$arrayrefcount初始值为1,因此它不会被foreach复制:只会增加refcount。随后,当循环体修改数组(此时$arrayrefcount为2)时,复制将在此时发生。Foreach将继续在未修改的$array副本上操作。

  • 在测试用例3中,数组再次没有被复制,因此foreach将修改$array变量的IAP。在迭代结束时,IAP为NULL(表示迭代已完成),each通过返回false来表示这一点。

  • 在测试用例4和5中,eachreset都是按引用传递的函数。将$array传递给它们时,$arrayrefcount为2,因此必须复制它。因此,foreach将再次在单独的数组上操作。

展示各种复制行为的好方法是观察foreach循环中current()函数的行为。考虑以下示例:

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

在这里你需要知道,current() 是一个按引用传递的函数(实际上是prefer-ref),尽管它不会修改数组。 它必须这样做才能与所有其他按引用传递的函数一起使用,如 next。 按引用传递意味着数组必须被拆分,因此 $arrayforeach-array 将不同。 之前提到获取到 2 而非 1 的原因是:在运行用户代码之前,foreach 会先移动数组指针 一次 ,而不是之后。 因此,即使代码在第一个元素处,foreach 已经将指针移动到第二个元素。

现在让我们进行一小部分修改:

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

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

通过按引用迭代时,您将获得相同的行为:

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问题的根源,因此可以考虑一些此类示例。

考虑对同一数组执行的这些嵌套循环(使用按引用迭代以确保实际上是相同的数组):

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,而这个 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

哈希表迭代器

如果您还记得,数组迭代的主要问题是如何处理迭代中间删除元素的情况。 PHP 5使用单个内部数组指针(IAP)来解决此问题,这种方法有些次优,因为一个数组指针必须被拉伸以支持多个同时使用的 foreach 循环和 reset()等函数的交互。

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

这意味着foreach将不再使用IAP at all。foreach 循环对 current() 等函数返回结果没有任何影响,并且其行为永远不会受到reset() 等函数的影响。

数组复制

PHP 5和PHP 7之间的另一个重要变化涉及数组复制。现在,由于不再使用IAP,按值的数组迭代将仅在所有情况下进行refcount增量(而不是复制数组)。如果在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 */

在PHP7之前,基于引用数组的按值迭代是一种特殊情况。在这种情况下,不会发生复制,因此迭代期间数组的所有修改都将反映在循环中。但在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保留相同的输出:按值数组迭代始终继续处理原始元素。(在这种情况下,甚至refcounting和复制行为在PHP 5和PHP 7之间也完全相同。)

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

  • 测试用例4和5保持不变:each()reset()在更改IAP之前将复制数组,而foreach仍使用原始数组。(请注意,即使数组是共享的,IAP的更改也无关紧要。)

第二组示例涉及不同reference/refcounting配置下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

以前哈希指针恢复机制会直接跳到新元素,因为它“看起来”像被删除的元素一样(由于哈希和指针冲突)。由于我们不再依赖元素哈希值,所以这不再是一个问题。


4
@Baba 是的。将其传递给函数与在循环之前执行$foo = $array是相同的操作 ;) - NikiC
foreach 在迭代之后似乎会保存数组的位置在一个 HashPointer 结构中,并在迭代之前恢复它。该结构携带了 HashPosition 和由 HashPosition 指向的桶的 h。哈希用于验证桶是否仍在表中,因此即使在数组修改之后,一个 HashPointer 也是安全使用的。这可能就是为什么我们可以看到修改后的数组位置(在 foreach 之后 each() 返回 null),以及为什么尝试修改它不会影响 foreach 的原因。 - Arnaud Le Blanc
36
如果您不知道什么是zval,请参考Sara Goleman的http://blog.golemon.com/2007/01/youre-being-lied-to.html。 - shu zOMG chen
1
小修正:你所称之为“Bucket”的东西并不是哈希表中通常所说的“Bucket”。通常,“Bucket”是一组具有相同哈希%大小的条目。你似乎将其用于通常称为“entry”的内容。链表不是在“buckets”上,而是在“entries”上。 - unbeli
13
@unbeli 我正在使用 PHP 内部使用的术语。Bucket 是哈希冲突的双向链表的一部分,也是顺序的双向链表的一部分 ;) - NikiC
显示剩余2条评论

127

在示例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三元运算符:快还是慢?


看起来你是对的,我创建了一些示例来证明这一点: http://codepad.org/OCjtvu8r 与您的示例的一个不同之处是,它只在更改键时复制,而不是在更改值时。 - zb'
1
这确实解释了上面展示的所有行为,并且可以通过在第一个测试用例的末尾调用each()来很好地说明,我们可以看到原始数组的数组指针指向第二个元素,因为在第一次迭代期间修改了数组。这似乎也表明foreach在执行循环代码块之前移动了数组指针,这让我感到意外 - 我本以为它会在最后才这样做。非常感谢,这对我来说解决了问题。 - DaveRandom

57

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

a) foreach作用于原始数组的预期副本。这意味着,在创建预期副本之前,foreach()将具有共享数据存储。foreach笔记/用户评论

b) 什么会触发预期副本?预期副本是基于写时复制(copy-on-write)策略创建的,也就是说,当传递给foreach()的数组发生更改时,将创建原始数组的克隆。

c) 原始数组和foreach()迭代器将拥有不同的哨兵变量,即一个用于原始数组,另一个用于foreach;请参见下面的测试代码。SPL迭代器数组迭代器

Stack Overflow问题如何确保在PHP的“foreach”循环中重置值?解决了您问题中的(3,4,5)情况。

以下示例显示,each()和reset()不会影响foreach()迭代器的哨兵变量(例如,当前索引变量)

$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

2
你的答案不是完全正确的。foreach 循环操作的是可能的数组副本,但只有在需要时才会创建实际副本。 - linepogl
你想展示如何以及何时通过代码创建潜在的副本吗?我的代码演示了 foreach 每次都会复制数组。我渴望知道。感谢您的评论。 - sakhunzai
复制数组的成本很高。尝试使用forforeach迭代具有100000个元素的数组所需的时间。您不会看到它们之间有任何显着的区别,因为实际上并没有进行复制。 - linepogl
那么我会认为有一个“共享数据存储”被保留,直到或除非“写时复制”,但是(从我的代码片段中)很明显总会有两组“哨兵变量”,一组用于“原始数组”,另一组用于“foreach”。谢谢,这很有道理。 - sakhunzai
“prospected”? 你是指“protected”吗? - Peter Mortensen
1
是的,那是“prospected”副本,即“潜在”的副本。它并不像你所建议的那样受到保护。 - sakhunzai

41

关于 PHP 7 的注意事项

关于这个答案的更新:自 PHP 7 开始,本答案已经不适用了。如在“不兼容变更”中所解释的那样,在 PHP 7 中 foreach 在数组的副本上工作,因此对数组本身的任何更改都不会反映在 foreach 循环中。请查看链接获取更多细节。

说明(引自 php.net):

第一种形式循环遍历由 array_expression 给出的数组。在每次迭代中,当前元素的值被分配给 $value,并且内部数组指针向前移动一个(因此在下一次迭代中,您将查看下一个元素)。

因此,在您的第一个示例中,数组中只有一个元素,当指针移动到下一个元素时,下一个元素不存在,因此在添加新元素后,foreach 结束,因为它已经“决定”它是最后一个元素了。

在您的第二个示例中,您从两个元素开始,foreach 循环没有到达最后一个元素,因此在下一次迭代中评估数组,从而意识到数组中有新元素。

我相信这都是文档中“每次迭代”部分的描述的结果,这可能意味着 foreach 在调用 {} 中的代码之前执行所有逻辑。

测试案例

如果您运行以下代码:

<?
    $array = Array(
        'foo' => 1,
        'bar' => 2
    );
    foreach($array as $k=>&$v) {
        $array['baz']=3;
        echo $v." ";
    }
    print_r($array);
?>

你将会得到以下输出:

1 2 3 Array
(
    [foo] => 1
    [bar] => 2
    [baz] => 3
)
这意味着它接受了修改并成功执行,因为它是在“及时”修改的。但如果您这样做:
<?
    $array = Array(
        'foo' => 1,
        'bar' => 2
    );
    foreach($array as $k=>&$v) {
        if ($k=='bar') {
            $array['baz']=3;
        }
        echo $v." ";
    }
    print_r($array);
?>

您将获得:

1 2 Array
(
    [foo] => 1
    [bar] => 2
    [baz] => 3
)
这意味着数组已被修改,但由于我们在 foreach 已经到达数组的最后一个元素时进行了修改,因此它“决定”不再循环,即使我们添加了新元素,也“太迟”了而没有被循环遍历。
详细解释可以在 How does PHP 'foreach' actually work? 阅读,该文章解释了这种行为背后的内部情况。

7
你有没有阅读答案的其他部分?foreach 在运行其代码之前就已经决定是否继续循环,这是完全合理的。 - dkasipovic
2
不,数组已经被修改了,但是“太晚”了,因为foreach已经“认为”它在最后一个元素(实际上是在迭代开始时),并且不会再循环了。而在第二个例子中,在迭代开始时它不在最后一个元素,并且在下一次迭代开始时再次评估。我正在尝试准备一个测试用例。 - dkasipovic
1
@AlmaDo 请查看http://lxr.php.net/xref/PHP_TRUNK/Zend/zend_vm_def.h#4509。当它迭代时,它总是设置为下一个指针。因此,当它到达最后一次迭代时,它将被标记为已完成(通过NULL指针)。当您在最后一次迭代中添加键时,foreach不会注意到它。 - bwoebi
1
@DKasipovic 不是的。那里没有_完整和清晰_的解释(至少目前是这样-也许我错了)。 - Alma Do
4
实际上,看起来@AlmaDo 对自己的逻辑理解有缺陷......你的回答很好。 - bwoebi
显示剩余5条评论

18

根据PHP手册提供的文档。

在每次迭代中,当前元素的值被赋给$v,并且内部数组指针向前移动一位(因此在下一次迭代中,您将查看下一个元素)。

所以根据您提供的第一个示例:

$array = ['foo'=>1];
foreach($array as $k=>&$v)
{
   $array['bar']=2;
   echo($v);
}
$array只有一个元素,因此根据foreach的执行方式,1会被分配给$v,并且它没有其他元素可以移动指针。
但是在您的第二个示例中:
$array = ['foo'=>1, 'bar'=>2];
foreach($array as $k=>&$v)
{
   $array['baz']=3;
   echo($v);
}

$array有两个元素,因此现在$array评估零索引并将指针移动一个位置。 对于循环的第一次迭代,作为传递引用添加了$array['baz']=3;


15

这是一个很好的问题,因为许多开发人员,甚至经验丰富的开发人员,在PHP处理foreach循环中的数组时感到困惑。在标准的foreach循环中,PHP会对用于循环的数组进行一次拷贝。该拷贝将在循环结束后立即被丢弃。这在简单的foreach循环操作中是透明的。 例如:

$set = array("apple", "banana", "coconut");
foreach ( $set AS $item ) {
    echo "{$item}\n";
}

这将输出:

apple
banana
coconut

因此,复制已创建,但开发人员没有注意到,因为原始数组在循环内或循环结束后没有被引用。然而,当您尝试在循环中修改项目时,您会发现它们在完成后未被修改:

$set = array("apple", "banana", "coconut");
foreach ( $set AS $item ) {
    $item = strrev ($item);
}

print_r($set);

这将输出:

Array
(
    [0] => apple
    [1] => banana
    [2] => coconut
)

从原始的 $item 变量上做的任何更改都不会被注意到,实际上没有对原始的 $item 进行任何更改,即使您明确地为 $item 赋了一个值。这是因为您正在操作 $set 的副本中出现的 $item。您可以通过引用获取 $item 来覆盖此行为,方法如下:

$set = array("apple", "banana", "coconut");
foreach ( $set AS &$item ) {
    $item = strrev($item);
}
print_r($set);

这将输出:

Array
(
    [0] => elppa
    [1] => ananab
    [2] => tunococ
)

显而易见的是,当通过引用对$item进行操作时,对$item所做的更改会反映在原始$set的成员中。通过引用使用$item还可以防止PHP创建数组副本。为了测试这一点,首先我们将展示一个快速演示复制的脚本:

$set = array("apple", "banana", "coconut");
foreach ( $set AS $item ) {
    $set[] = ucfirst($item);
}
print_r($set);

这将输出:

Array
(
    [0] => apple
    [1] => banana
    [2] => coconut
    [3] => Apple
    [4] => Banana
    [5] => Coconut
)

正如示例所示,PHP复制了$set并用它来循环,但当$set在循环内部使用时,PHP将变量添加到原始数组中而不是复制的数组中。基本上,PHP仅在执行循环和分配$item时使用复制的数组。由于这个原因,上面的循环仅执行3次,并每次将另一个值附加到原始$set的末尾,使原始$set保留6个元素,但永远不会进入无限循环。

然而,如果我们像我之前提到的那样使用引用的$item呢?在以上测试中添加一个字符:

$set = array("apple", "banana", "coconut");
foreach ( $set AS &$item ) {
    $set[] = ucfirst($item);
}
print_r($set);

会导致无限循环。请注意,这实际上是一个无限循环,你必须手动停止脚本或等待操作系统耗尽内存。我将以下代码添加到我的脚本中,以便PHP能够非常快地耗尽内存,如果你打算运行这些无限循环测试,建议你也这样做:

ini_set("memory_limit","1M");

因此,在上一个无限循环的示例中,我们可以看到PHP为什么要创建数组的副本进行循环。当创建副本并且仅由循环结构本身使用时,数组在整个循环执行期间保持不变,因此您永远不会遇到问题。


10

PHP foreach循环可以与索引数组关联数组对象公共变量一起使用。

在foreach循环中,php首先创建一个要迭代的数组的副本。然后,PHP遍历这个新的副本而不是原始数组。以下示例演示了这一点:

<?php
$numbers = [1,2,3,4,5,6,7,8,9]; # initial values for our array
echo '<pre>', print_r($numbers, true), '</pre>', '<hr />';
foreach($numbers as $index => $number){
    $numbers[$index] = $number + 1; # this is making changes to the origial array
    echo 'Inside of the array = ', $index, ': ', $number, '<br />'; # showing data from the copied array
}
echo '<hr />', '<pre>', print_r($numbers, true), '</pre>'; # shows the original values (also includes the newly added values).

除此之外,PHP确实允许使用“迭代值作为对原始数组值的引用”。以下是演示:
<?php
$numbers = [1,2,3,4,5,6,7,8,9];
echo '<pre>', print_r($numbers, true), '</pre>';
foreach($numbers as $index => &$number){
    ++$number; # we are incrementing the original value
    echo 'Inside of the array = ', $index, ': ', $number, '<br />'; # this is showing the original value
}
echo '<hr />';
echo '<pre>', print_r($numbers, true), '</pre>'; # we are again showing the original value

注意:它不允许使用原始数组索引作为引用

来源:http://dwellupper.io/post/47/understanding-php-foreach-loop-with-examples


1
“Object public variables”是错误的,或者最好说是误导性的。如果没有正确的接口(例如Traversible),您不能在数组中使用对象,当您执行foreach((array)$obj ...时,实际上您正在使用一个简单的数组,而不是一个对象。 - Christian

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接