题 PHP'foreach'如何实际工作?


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


很长一段时间我都认为 foreach 使用阵列本身。然后我发现许多引用它与a一起工作的事实 复制 数组,我已经认为这是故事的结尾。但是我最近讨论了这个问题,经过一些实验后发现这实际上并非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 可能会影响循环的结果?

1644
2018-04-07 19:33


起源


@DaveRandom有一个 PHP-内部 标签这可能应该使用,但我会留给你来决定哪个替换其他5个标签。 - Michael Berkowski
看起来像COW,没有删除句柄 - zb'
起初我想:天哪,另一个新手问题。阅读文档...嗯,明确未定义的行为«。然后我读了完整的问题,我必须说:我喜欢它。你已经付出了很多努力并编写了所有的测试用例。 PS。测试用例4和5是一样的吗? - knittl
只是想一想为什么它有意义才能触及数组指针:PHP需要重置并移动原始数组的内部数组指针以及副本,因为用户可能会要求引用当前值(foreach ($array as &$value)) - PHP需要知道原始数组中的当前位置,即使它实际上是在副本上进行迭代。 - Niko
@Sean:恕我直言,PHP文档在描述核心语言功能的细微差别时非常糟糕。但也许这是因为许多特殊的特殊案例都融入了语言...... - Oliver Charlesworth


答案:


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来避免实际的方法调用 Iterator C级接口。

数组和普通对象的迭代要复杂得多。首先,应该注意的是,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)。只有在执行某种修改后,才会执行真正的副本(称为“复制”)。看到 你被骗了 有关此主题的更广泛的介绍。

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当前所在的位置。这个计划大多有点类型,但是你可以从中获得许多奇怪的行为,其中一些我将在下面演示。

数组重复

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

  1. 该数组不是引用(is_ref = 0)。如果它是一个参考,那么对它的改变是 应该 传播,所以不应该重复。
  2. 该数组的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 将被复制以防止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将修改IAP $array 变量。在迭代结束时,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。引用传递意味着必须分离数组 $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 备份+恢复机制是改为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 restore使用指向元素及其哈希的指针来确定它是否仍然存在。但是:哈希有碰撞,指针可以重复使用!这意味着,通过仔细选择数组键,我们可以制作 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() 在不同的参考/ 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恢复机制直接跳转到新元素,因为它“看起来”像是与remove元素相同(由于冲突的哈希和指针)。由于我们不再依赖元素哈希来解决任何问题,因此这不再是一个问题。


1384
2018-02-13 13:21



@Baba确实如此。将其传递给函数与执行相同 $foo = $array 在循环之前;) - NikiC
对于那些不知道什么是zval的人,请参考Sara Goleman的 blog.golemon.com/2007/01/youre-being-lied-to.html - shu zOMG chen
@unbeli我正在使用PHP内部使用的术语。该 Buckets是哈希冲突的双向链表的一部分,也是订单的双向链表的一部分;) - NikiC
@NikiC:你真的需要上大学吗?你只有19岁,但你看起来更有经验 - dynamic
伟大的anwser。我想你的意思 iterate($outerArr); 并不是 iterate($arr); 某处。 - niahoo


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


97
2018-04-07 20:43



似乎是你的权利,我做了一些例子证明: codepad.org/OCjtvu8r 与您的示例有一点不同 - 如果更改值,则仅在更改密钥时不会复制。 - zb'
这确实解释了上面显示的所有行为,并且通过调用可以很好地说明它 each() 在第一个测试用例结束时,在哪里 我们看 因为在第一次迭代期间修改了数组,所以原始数组的数组指针指向第二个元素。这也似乎证明了这一点 foreach 在执行循环的代码块之前移动数组指针,这是我没想到的 - 我原以为它会在最后执行此操作。非常感谢,这很好地为我清除了它。 - DaveRandom


使用时需要注意的一些要点 foreach()

一个) foreach 适用于 预期副本 原始数组。     这意味着foreach()将具有SHARED数据存储,直到或除非a prospected copy 是     没有创建 foreach Notes / User comments

b)什么引发了 预期副本?     根据政策制定预期副本 copy-on-write,就是说,每当     传递给foreach()的数组被更改,创建了原始数组的克隆。

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

Stack Overflow问题 如何确保在PHP的'foreach'循环中重置该值? 解决你问题的案例(3,4,5)。

以下示例显示each()和reset()不会影响 SENTINEL 变量 (for example, the current index variable) 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

34
2018-04-07 21:03



你的答案不太正确。 foreach 对数组的潜在副本进行操作,但除非需要,否则它不会生成实际的副本。 - linepogl
您想展示通过代码创建潜在副本的方式和时间吗?我的代码证明了这一点 foreach 是100%的时间复制数组。我很想知道。谢谢你的评论 - sakhunzai
复制阵列需要花费很多。尝试计算使用其中任何一个迭代具有100000个元素的数组所花费的时间 for 要么 foreach。你不会发现它们之间有任何显着差异,因为实际的副本不会发生。 - linepogl
然后我会假设有 SHARED data storage 保留至或除非 copy-on-write ,但是(从我的代码片段)显然总会有两套 SENTINEL variables 一个人 original array 和其他 foreach。谢谢,这是有道理的 - sakhunzai
“展望”?你的意思是“受保护”吗? - Peter Mortensen


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 已经在数组的最后一个元素,它“决定”不再循环,即使我们添加了新元素,我们添加它“太晚了”并且它没有循环。

详细说明请参阅 PHP'foreach'如何实际工作? 这解释了这种行为背后的内部。


22
2018-04-15 08:46



你读完了答案的其余部分吗? foreach决定它是否会循环另一次是完全合理的 之前 它甚至可以在其中运行代码。 - Damir Kasipovic
不,数组被修改,但是“太晚了”因为foreach已经“认为”它处于最后一个元素(它在迭代开始时)并且不再循环。在第二个示例中,它不在迭代开始时的最后一个元素,并在下一次迭代开始时再次计算。我正在尝试准备一个测试用例。 - Damir Kasipovic
@AlmaDo看看 lxr.php.net/xref/PHP_TRUNK/Zend/zend_vm_def.h#4509 它在迭代时总是设置为下一个指针。因此,当它到达最后一次迭代时,它将被标记为已完成(通过NULL指针)。当您在最后一次迭代中添加一个键时,foreach将不会注意到它。 - bwoebi
@DKasipovic没有。没有 完整而清晰 解释那里(至少现在 - 可能是我错了) - Alma Do
实际上似乎@AlmaDo在理解自己的逻辑方面存在缺陷......你的答案很好。 - bwoebi


根据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; 作为参考传递。


8
2018-04-15 09:32





很好的问题,因为许多开发人员,甚至是经验丰富的开发人员都对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,因为它出现在正在处理的$ set副本中。你可以通过引用抓取$ 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来创建要循环的数组副本的原因。当副本被创建并仅由循环结构本身的结构使用时,该数组在循环执行期间保持静态,因此您永远不会遇到问题。


5
2018-04-21 08:44





PHP foreach循环可以使用 Indexed arraysAssociative arrays 和 Object public variables

在foreach循环中,php所做的第一件事就是它创建了一个要迭代的数组副本。 PHP然后迭代这个新的 copy 数组而不是原始数组。这在以下示例中进行了演示:

<?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允许使用 iterated values as a reference to the original array value 同样。这在下面说明:

<?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

注意: 它不允许 original array indexes 用作 references

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


4
2017-11-13 14:08



Object public variables 错误或充其量是误导。如果没有正确的接口(例如,Traversible),则不能在数组中使用对象 foreach((array)$obj ... 你实际上是在处理一个简单的数组,而不是一个对象。 - Christian