内部数组指针和散列指针数组
PHP 5 中的数组有一个专用的 “内部数组指针”(IAP),它适当地支持修改:每当删除一个元素时,都会检查 IAP 是否指向该元素。 若是是,则转发到下一个元素。安全
虽然 foreach 确实使用了 IAP,但还有一个复杂因素:只有一个 IAP,可是一个数组能够是多个 foreach 循环的一部分:数据结构
// 在这里使用by-ref迭代来确保它真的
// 两个循环中的相同数组而不是副本
foreach ($arr as &$v1) {
foreach ($arr as &$v) {
// ...
}
}
为了支持只有一个内部数组指针的两个同时循环,foreach 执行如下 shenanigans:在执行循环体以前,foreach 将备份指向当前元素及其散列的指针到每一个 foreachHashPointer。循环体运行后,若是 IAP 仍然存在,IAP 将被设置回该元素。 可是,若是元素已被删除,咱们将只在 IAP 当前所在的位置使用。这个计划基本上是可行的,可是你能够从中得到不少奇怪的状况,其中一些我将在下面演示。架构
IAP 是数组的一个可见特性 (经过 current 系列函数公开),所以 IAP 计数的更改是在写时复制语义下的修改。不幸的是,这意味着 foreach 在许多状况下被迫复制它正在迭代的数组。 具体条件是:svg
若是数组没有被复制 (is_ref=0, refcount=1),那么只有它的 refcount
会被增长 (*)。此外,若是使用带引用的 foreach
,那么 (可能重复的) 数组将转换为引用。函数
以下代码做为引发复制的示例:性能
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 听起来无害,但违反了写时复制(COW)语义:这意味着咱们要修改 refcount = 2 数组的 IAP,而 COW 则要求只能执行修改 on refcount = 1 值。这种违反会致使用户可见的行为更改 (而 COW 一般是透明的),由于迭代数组上的 IAP 更改将是可见的 -- 但只有在数组上的第一个非 IAP 修改以前。相反,这三个 “有效” 选项是:a) 始终复制,b) 不增长 refcount,从而容许在循环中任意修改迭代数组,c) 彻底不使用 IAP (PHP 7 解决方案)。spa
要正确理解下面的代码示例,你必须了解最后一个实现细节。在伪代码中,循环遍历某些数据结构的 “正常” 方法是这样的:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
code();
move_forward(arr);
}
然而,foreach
,做为一个至关特殊的 snowflake,选择作的事情略有不一样:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
move_forward(arr);
code();
}
也就是说,数组指针 在循环体运行以前已经向前移动了。这意味着,当循环体处理元素 $i 时,IAP 已经位于元素 $i+1。这就是为何在迭代期间显示修改的代码示例老是 unset下一个元素,而不是当前元素的缘由。
上面描述的三个方面应该可让你大体了解 foreach
实现的特性,咱们能够继续讨论一些例子。
此时,测试用例的行为更容易理解:
current
在 foreach 中的做用显示各类复制行为的一个好方法是观察 foreach
循环中 current()
函数的行为。看以下这个例子:
foreach ($array as $val) {
var_dump(current($array));
}
/* 输出: 2 2 2 2 2 */
在这里,你应该知道 current() 是一个 by-ref 函数 (其实是:preferences-ref),即便它没有修改数组。它必须很好地处理全部其余函数,如 next,它们都是 by-ref。经过引用传递意味着数组必须是分开的,所以 $array 和 foreach-array 将是不一样的。你获得是 2 而不是 1 的缘由也在上面提到过:foreach在运行用户代码以前指向数组指针,而不是以后。所以,即便代码位于第一个元素,foreach 已经将指针指向第二个元素。
如今让咱们尝试一下小修改:
$ref = &$array;
foreach ($array as $val) {
var_dump(current($array));
}
/* 输出: 2 3 4 5 false */
这里咱们有 is_ref=1 的状况,因此数组没有被复制 (就像上面那样)。可是如今它是一个引用,当传递给 by-ref current() 函数时再也不须要复制数组。所以,current() 和 foreach 工做在同一个数组上。不过,因为 foreach 指向指针的方式,你仍能够看到 off-by-one 行为。
当执行 by-ref 迭代时,你会获得相同的行为:
foreach ($array as &$val) {
var_dump(current($array));
}
/* 输出: 2 3 4 5 false */
这里重要的部分是,当经过引用迭代 $array 时,foreach 会将 $array 设置为 is_ref=1,因此基本上状况与上面相同。
另外一个小变化,此次咱们将数组分配给另外一个变量:
$foo = $array;
foreach ($array as $val) {
var_dump(current($array));
}
/* 输出: 1 1 1 1 1 */
这里 $array 的 refcount 在循环开始时是 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";
}
}
// 输出: (1, 1) (1, 3) (1, 4) (1, 5)
这里的预期部分是输出中缺乏 (1,2),由于元素 1 被删除了。可能出乎意料的是,外部循环在第一个元素以后中止。这是为何呢?
这背后的缘由是上面描述的嵌套循环攻击:在循环体运行以前,当前 IAP 位置和散列被备份到一个 HashPointer 中。在循环体以后,它将被恢复,可是只有当元素仍然存在时,不然将使用当前 IAP 位置 (不管它是什么)。在上面的例子中,状况正是这样:外部循环的当前元素已经被删除,因此它将使用 IAP,而内部循环已经将 IAP 标记为 finished !
HashPointer 备份 + 恢复机制的另外一个结果是,经过 reset() 等方法更改 IAP。一般不会影响 foreach。例如,下面的代码执行起来就像根本不存在 reset() 同样:
$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
var_dump($value);
reset($array);
}
// 输出: 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);
}
// 输出: 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);
}
// 输出: 1, 4
这里根据前面的规则,咱们一般指望输出 1,1,3,4。实际状况上'FYFY' 具备与删除的元素'FYFY' 相同的散列,而分配器刚好重用相同的内存位置来存储元素。所以,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;
}
}
/* 输出: 1 2 3 6 7 8 9 10 */
正如你在本例中所看到的,一旦替换发生,PHP 将从头开始迭代另外一个实体。
若是你还记得,数组迭代的主要问题是如何处理迭代过程当中元素的删除。PHP 5 为此使用了一个内部数组指针 (IAP),这有点不太理想,由于一个数组指针必须被拉伸以支持多个同时进行的 foreach 循环和与 reset() 等的交互。最重要的是。
PHP 7 使用了一种不一样的方法,即支持建立任意数量的外部安全散列表迭代器。这些迭代器必须在数组中注册,从这一点开始,它们具备与 IAP 相同的语义:若是删除了一个数组元素,那么指向该元素的全部 hashtable 迭代器都将被提高到下一个元素。
这意味着 foreach 将再也不使用 IAP。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;
}
/* 旧输出: 1, 2, 0, 4, 5 */
/* 新输出: 1, 2, 3, 4, 5 */
之前,引用数组的按值迭代是一种特殊状况。在本例中,没有发生重复,所以在迭代期间对数组的全部修改都将由循环反映出来。在 PHP 7 中,这种特殊状况消失了:数组的按值迭代将始终继续处理原始元素,而不考虑循环期间的任何修改。
固然,这不适用于 by-reference 迭代。若是你经过引用进行迭代,那么全部的修改都将被循环所反映。有趣的是,对于普通对象的按值迭代也是如此:
$obj = new stdClass;
$obj->foo = 1;
$obj->bar = 2;
foreach ($obj as $val) {
var_dump($val);
$obj->bar = 42;
}
/* 新旧输出: 1, 42 */
这反映了对象的按句柄语义 (即,即便在按值上下文中,它们的行为也相似于引用)。
让咱们考虑几个例子,从你的测试用例开始:
测试用例 1 和 2 输出相同:按值数组迭代始终在原始元素上工做。(在本例中,甚至 refcounting 和复制行为在 PHP 5 和 PHP 7 之间也是彻底相同的)。
测试用例 3 的变化:Foreach 再也不使用 IAP,所以 each() 不受循环影响。先后输出同样。
测试用例 4 和 5 保持不变:each() 和 reset() 将在更改 IAP 以前复制数组,而 foreach 仍然使用原始数组。(即便数组是共享的,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";
}
}
// 旧输出: (1, 1) (1, 3) (1, 4) (1, 5)
// 新输出: (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)
如你所见,外部循环在第一次迭代以后再也不停止。缘由是如今两个循环都有彻底独立的 hashtable 散列表迭代器,而且再也不经过共享的 IAP 对两个循环进行交叉污染。
如今修复的另一个奇怪的边缘现象是,当删除而且添加刚好具备相同的哈希元素时,会获得奇怪的结果:
$array = ['EzEz' => 1, 'EzFY' => 2, 'FYEz' => 3];
foreach ($array as &$value) {
unset($array['EzFY']);
$array['FYFY'] = 4;
var_dump($value);
}
// 旧输出: 1, 4
// 新输出: 1, 3, 4
以前的 HashPointer 恢复机制直接跳转到新元素,由于它 “看起来” 和删除的元素相同(因为哈希和指针冲突)。因为咱们再也不依赖于哈希元素,所以再也不是一个问题。