问题

让我先说这个,说我知道 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 */

这回顾了我们的初步结论,我们在循环期间使用源数组的副本,否则我们将在循环期间看到修改的值. 但是...

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

When foreach first starts executing, the internal array pointer is automatically reset to the first element of the array.

对...这似乎暗示 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手册还规定:

As foreach relies on the internal array pointer changing it within the loop may lead to unexpected behavior.

好吧,让我们来看看"意想不到的行为"是什么(从技术上讲,任何行为都是意想不到的,因为我不知道该怎么做).

测试用例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 适用于数组的复制,但在循环后将源数组的数组指针设置为数组的末尾.

  • Is this correct and the whole story?
  • If not, what is it really doing?
  • Is there any situation where using functions that adjust the array pointer (each(), reset() et al.) during a foreach could affect the outcome of the loop?


解决方法

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基本上只是反映C级别上的 Iterator 接口.

数组和纯对象的迭代显得更加复杂.首先,应该注意的是,在PHP"数组"是真正有序的字典,它们将根据这个顺序遍历(它匹配插入顺序,只要你没有使用像 sort ).这与按键的自然顺序(其他语言的列表通常如何工作)或根本没有定义顺序(其他语言的字典如何工作)相反.

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

到目前为止,这么好.迭代字典不能太难,对不对?当你意识到数组/对象可以在迭代期间改变时,问题开始.有多种方式可能发生这种情况:

  • If you iterate by reference using foreach ($arr as &$v) then $arr is turned into a reference and you can change it during iteration.
  • In PHP 5 the same applies even if you iterate by value, but the array was a reference beforehand: $ref =& $arr; foreach ($ref as $v)
  • Objects have by-handle passing semantics, which for must practical purposes means that they behave like references. So objects can always be changed during iteration.

在迭代期间允许修改的问题是您当前所在的元素被删除的情况.假设你使用一个指针来跟踪你当前正在使用的数组元素.如果这个元素现在被释放,你会留下一个悬空指针(通常导致一个segfault).

有不同的方法来解决这个问题. PHP 5和PHP 7在这方面有很大不同,我将在下面描述这两种行为.总结是PHP 5的方法是相当愚蠢的,导致各种奇怪的边缘情况问题,而PHP 7更多的参与方法的结果是更可预测和一致的行为.

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

PHP 5

Internal array pointer and 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当前在.这个方案大多是kinda-sortof的作品,但有很多奇怪的行为,你可以从它,其中一些我会在下面演示.

Array duplication

IAP是数组的可见特征(通过 current 函数系列公开),因为IAP计数作为写时复制语义下的修改.这不幸意味着foreach在许多情况下被迫重复它正在迭代的数组.具体条件是:

  1. The array is not a reference (is_ref=0). If it's a reference, then changes to it are supposed to propagate, so it should not be duplicated.
  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),则只有其引用计数将增加(*).此外,如果使用foreach by引用,则(可能重复的)数组将变为引用.

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

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).

(*)增加引用计数在这里听起来无害,但违反了写时复制(COW)语义:这意味着我们将修改refcount = 2数组的IAP,而COW规定修改只能对refcount = 1值执行.这种违反会导致用户可见的行为更改(而COW通常是透明的),因为迭代数组上的IAP更改是可以观察到的,但只有在对数组进行第一次非IAP修改时才会发生.相反,三个"有效"选项应该是a)始终重复,b)不增加引用计数,从而允许迭代数组在循环中任意修改,或c)不使用IAP PHP 7解决方案).

Position advancement order

有一个最后的实现细节,你必须知道正确理解下面的代码示例.循环通过一些数据结构的"正常"方式在伪代码中看起来像这样:

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 .这就是为什么在迭代过程中显示修改的代码示例总是取消设置下一个元素,而不是当前元素.

Examples: Your test cases

上述三个方面应该为您提供一个对foreach实现的特性的完全印象,我们可以继续讨论一些例子.

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

  • In test cases 1 and 2 $array starts off with refcount=1, so it will not be duplicated by foreach: Only the refcount is incremented. When the loop body subsequently modifies the array (which has refcount=2 at that point), the duplication will occur at that point. Foreach will continue working on an unmodified copy of $array.

  • In test case 3, once again the array is not duplicated, thus foreach will be modifying the IAP of the $array variable. At the end of the iteration the IAP is NULL (meaning iteration done), which each indicates by returning false.

  • In test cases 4 and 5 both each and reset are by-reference functions. The $array has a refcount=2 when it is passed to them, so it has to be duplicated. As such foreach will be working on a separate array again.

Examples: Effects of current in foreach

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

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

这里你应该知道 current()是一个by-ref函数(实际上是prefer-ref),即使它不修改数组.它必须是为了玩所有其他功能像,它们都是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 is_ref = 1.所以基本上你有同样的情况.

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

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

这里,当循环开始时, $ array 的引用计数为2,所以一旦我们实际上必须预先做复制.因此, $ array 和foreach使用的数组将完全独立于开始.这就是为什么你得到IAP的位置,无论它在循环之前(在这种情况下,它是在第一个位置).

Examples: Modification during iteration

尝试在迭代期间考虑修改是我们所有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 备份+恢复机制的另一个后果是,通过 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 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最终直接跳到新插入的元素,从而缩短循环.

Substituting the iterated entity during the loop

最后一个奇怪的例子,我想提到的是,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 */

As you can see in this case PHP will just start iterating the other entity from the start once the substitution has happened.

PHP 7

Hashtable iterators

如果你还记得,数组迭代的主要问题是如何处理元素中间的删除.为了这个目的,PHP 5使用了单个内部数组指针(IAP),这是有些次优的,因为一个数组指针必须被拉伸以支持多个同时的foreach循环 reset 等等.

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

这意味着foreach将不再使用IAP .. foreach循环将对 current()等的结果绝对没有影响,并且它自己的行为不会受到像 reset()等函数的影响.

Array duplication

PHP 5和PHP 7之间的另一个重要变化与阵列复制有关.现在,不再使用IAP,按值阵列迭代只会在所有情况下执行引用计数增量(而不是重复数组).如果数组在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 */

先前的参考数组的by-value迭代是特殊情况.在这种情况下,没有发生重复,所以在迭代期间数组的所有修改都将反映在循环中.在PHP 7中,这种特殊情况已经消失:数组的按值迭代将始终继续处理原始元素,忽略循环期间的任何修改.

这当然不适用于by-reference迭代.如果你迭代by-reference所有的修改都会被循环反映.有趣的是,对于纯对象的按值迭代也是如此:

$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语义(即,即使在按值的上下文中,它们也表现为引用).

Examples

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

  • Test cases 1 and 2 retain the same output: By-value array iteration always keeps working on the original elements. (In this case even refcounting and duplication behavior is exactly the same between PHP 5 and PHP 7).

  • Test case 3 changes: Foreach no longer uses the IAP, so each() is not affected by the loop. It will have the same output before and after.

  • Test cases 4 and 5 stay the same: each() and reset() will duplicate the array before changing the IAP, while foreach still uses the original array. (Not that the IAP change would have mattered, even if the array was shared.)

第二组示例与不同引用/引用计数配置下的 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还原机制向右跳到新元素,因为它"看起来"就像它与remove元素相同(由于碰撞哈希和指针).由于我们不再依赖元素哈希的任何东西,这不再是一个问题.




相关问题推荐