让我在前面说一句,我知道foreach是什么,做什么,以及如何使用它.这个问题是关于它如何在引擎盖下工作的,我不想要任何类似"这就是如何用foreach循环数组"的答案.


在很长一段时间里,我假设foreach与数组本身一起工作.然后,我找到了许多关于它可以与copy的数组一起工作的参考资料,此后我假设故事就到此结束.但是我最近参与了一个关于这个问题的讨论,经过一些实验发现,这实际上并不是百分之百正确的.

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

$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 */

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

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

当foreach首次开始执行时,内部数组指针会自动重置为数组的第一个元素.

正当这似乎表明foreach依赖于源数组的数组指针.但我们刚刚证明我们是not working with the source array,对吗?不完全是这样.

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手册还规定:

因为foreach依赖于内部数组指针,所以在循环中更改它可能会导致意外行为.

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

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

...没有什么出人意料的,事实上,它似乎支持"复制源"理论.


The Question

这是怎么回事?我的C-fu不够好,我无法通过查看PHP源代码得出正确的结论,如果有人能帮我翻译成英语,我将不胜感激.

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

  • 这是正确的吗?整个故事是这样的吗?
  • 如果没有,它到底在做什么?
  • 是否存在使用调整数组指针的函数(each()reset()等)的情况会不会影响循环的结果呢?

推荐答案

foreach支持对三种不同类型的值进行迭代:

在下面,我将try 精确地解释迭代在不同情况下是如何工作的.到目前为止,最简单的情况是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将转换为引用,您可以在迭代期间更改它.
  • 在PHP5中,即使按值迭代也同样适用,但是该数组事先是一个引用:$ref =& $arr; foreach ($ref as $v)
  • 对象具有副句柄传递语义,在大多数实际用途中,这意味着它们的行为类似于引用.因此,在迭代过程中,对象总是可以更改的.

在迭代过程中允许修改的问题是,您当前所在的元素被删除.假设使用指针跟踪当前所在的数组元素.如果该元素现在被释放,您将得到一个悬空指针(通常会导致segfault).

解决这个问题有不同的方法.PHP5和PHP7在这方面有很大的不同,我将在下面描述这两种行为.总之,PHP5的方法相当愚蠢,会导致各种奇怪的边缘 case 问题,而PHP7更复杂的方法会导致更可预测和一致的行为.

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

PHP5

内部数组指针和哈希指针

PHP5中的数组有一个专用的"内部数组指针"(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会将指向当前元素的指针及其散列备份到每个foreach HashPointer中.循环体运行后,如果该元素仍然存在,IAP将被设置回该元素.但是,如果元素已被删除,我们将只使用IAP当前所在的任何位置.这个方案在很大程度上--有点--是可行的,但是你可以从中获得很多奇怪的行为,下面我将展示其中的一些.

数组复制

IAP是数组的一个可见特性(通过current个函数系列公开),因为对IAP的此类更改将被视为写时复制语义下的修改.不幸的是,这意味着foreach在许多情况下被迫复制它正在迭代的array.具体条件如下:

  1. 该数组不是引用(is_ref=0).如果它是一个引用,则对它所做的更改是supposed个要传播的,因此不应该复制它.
  2. The array has refcount>1. If refcount is 1, then the array is not shared and we're free to modify it directly.

如果数组没有重复(is_ref=0,refcount=1),那么只有它的refcount会增加(*).此外,如果使用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听起来无伤大雅,但却违反了写入时复制(Copy-on-Write,COW)语义:这意味着我们将修改refcount=2数组的IAP,而COW规定只能对refcount=1的值执行修改.这种违规会导致用户可见的行为更改(而CoW通常是透明的),因为迭代数组上的IAP更改将是可见的--但仅在对数组进行第一次非IAP修改之前.相反,三个"有效"选项应该是a)始终复制,b)不增加refcount,从而允许在循环中任意修改迭代数组,或者c)根本不使用IAP(PHP7解决方案).

职位晋升令

为了正确理解下面的代码示例,您必须了解最后一个实现细节.在一些数据 struct 中循环的"正常"方式在伪代码中看起来像这样:

reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
    code();
    move_forward(arr);
}

然而,作为一个相当特殊的snowflake,foreach Select 做一些稍微不同的事情:

reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
    move_forward(arr);
    code();
}

也就是说,在循环体运行时,数组指针已经向前移动before.这意味着当循环体在元素$i上工作时,IAP已经在元素$i+1处.这就是为什么在迭代期间显示修改的代码样本总是unset next元素,而不是当前元素的原因.

示例:您的测试用例

上面描述的三个方面应该会让您对foreach实现的特点有一个基本完整的印象,我们可以继续讨论一些示例.

此时,测试用例的行为很容易解释:

  • 在测试用例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将再次在单独的数组上工作.

示例: Effects of current in foreach

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

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

在这里,您应该知道current()是一个by-ref函数(实际上是:preferred-ref),即使它不修改array.为了更好地使用所有其他函数,比如next,它们都是按引用传递的.按引用传递意味着数组必须分开,因此$arrayforeach-array将不同.上面也提到了获得2而不是1的原因:foreach使运行用户代码的数组指针before前进,而不是在后面.因此,即使代码位于第一个元素,foreach已经将指针前进到第二个元素.

现在,让我们try 一个小的修改:

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

这里我们有is_ref=1的情况,所以数组不会被复制(如上所述).但现在它是一个引用,当传递给by ref current()函数时,不再需要复制array.因此,current()foreach在同一数组上工作.不过,由于foreach向前移动指针的方式,您仍然可以通过一个行为看到关闭.

执行by-ref迭代时,您会获得相同的行为:

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

这里重要的部分是,foreach在通过引用迭代时将使$arrayan is_ref=1,因此基本上您有与上面相同的情况.

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

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

在这里,当循环开始时,$array的recount是2,所以这一次我们实际上必须提前进行复制.因此,$array和foreach使用的数组将从一开始就完全分开.这就是为什么您可以获得IAP在循环之前的任何位置的位置(在本例中,它位于第一个位置).

示例:迭代期间的修改

试图解释迭代过程中的修改是我们所有的前因问题产生的原因,所以它可以考虑一些例子.

在同一个数组上考虑这些嵌套循环(使用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被移除,所以输出中缺少(1, 2).可能出乎意料的是,外部循环在第一个元素之后停止.为什么?

背后的原因是上面描述的嵌套循环攻击:在循环体运行之前,当前的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还原使用指向元素及其散列的指针来确定它是否仍然存在,那么真正的乐趣就开始了.但是:哈希有冲突,指针可以重用!这意味着,通过仔细 Select 数组键,我们可以让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将从一开始就开始迭代另一个实体.

PHP7

哈希表迭代器

如果您还记得,数组迭代的主要问题是如何处理迭代中期元素的删除.PHP5为此使用了单个内部数组指针(IAP),这有点不太理想,因为必须拉伸一个数组指针,以支持多个同时的foreach循环andreset()等的交互.

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

这意味着foreach将不再使用IAP at all.foreach循环绝对不会影响current()等的结果,其自身行为也不会受到reset()等函数的影响.

数组复制

PHP5和PHP7之间的另一个重要变化与数组复制有关.现在不再使用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中,这种特殊情况已经消失:数组的按值迭代将继续处理原始元素,而不考虑循环期间的任何修改.

当然,这不适用于引用迭代.如果通过引用进行迭代,所有修改都将由循环反映.有趣的是,普通对象的逐值迭代也是如此:

$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 */

这反映了对象的by-handle语义(即,即使在by-value上下文中,它们的行为也类似于引用).

示例

让我们考虑几个例子,从测试用例开始:

  • 测试用例1和2保持相同的输出:按值数组迭代总是在原始元素上工作.(在本例中,即使是refcounting和复制行为在PHP5和PHP7之间也完全相同).

  • 测试用例3的变化:Foreach不再使用IAP,因此each()不受循环的影响.前后输出相同.

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

第二组示例与current()在不同reference/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恢复机制直接跳到新元素上,因为它"看起来"与删除的元素相同(由于hash和pointer发生冲突).由于我们不再依赖元素散列来做任何事情,这不再是一个问题.

Php相关问答推荐

相同的数组(一个来自func_get_args(),另一个显式构建)在用作参数时并不相同

在不刷新页面的情况下无法使用JQuery更新id

PHP cUrl扩展与v8.2.12更新损坏

如何将对我的域的请求重定向到子文件夹中的索引,同时保留域URL并使用.htaccess?

在PHP中使用curl命令执行SCRAPYD操作.json不返回任何内容

如何使用索引加速和优化MySQL搜索

如何在微软图形API中使用用户S访问令牌或基于租户ID的访问令牌?

无额外字段的Laravel同步

扩展 WooCommerce 类触发未捕获错误:未找到WC_Coupon类

在 WooCommerce 更新结帐事件上动态更新自定义内容

无法在 WordPress 上获取 JSON 数据

Woocommerce API - 图像问题

多个产品类别 Select 自定义 WooCommerce 插件设置页面

如何显示所有数组数据而不仅仅是最后的结果

添加产品标签以通过 WooCommerce 邮箱通知订购商品

在WooCommerce我的账户单个订单页面中添加订单商品部分和按钮

docker |管道失败的 ubuntu 源列表

同时执行odbc_execute、odbc_fetch_row和odbc_result

如何使用属性创建属性预设?

将样式文件或脚本引入WordPress模板