php遍历数组什么时候作用在拷贝上?

从stackoverflow翻译过来的一张文章,原文地址路径点这里

博主对于foreach提出了比较有意思的一些问题,

$array = array(1, 2, 3, 4, 5);
[html]  view plain copy
  1.   
测试用例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 */
以上例子说明,foreach是作用在了拷贝数组上,不然这里会出现无限循环。

测试用例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 */
这个例子说明,这里的遍历的确是作用在了拷贝数组上,不然在遍历的时候我们就应该看到修改结果了。但是……

接下来博主又提了一些问题:

测试用例3:

foreach ($array as $item) {
  echo "$item\n";
}

var_dump(each($array));

/* Output
   bool(false)
*/
如果是作用在拷贝的数组上,为什么这里的遍历指针被重置了呢?

php手册上有写到:

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

由于foreach遍历数组依赖于内部的遍历指针(pInternalPointer),在循环中修改它将会导致未知的行为

那博主就开始捣乱了

测试用例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 */

博主认为在这里使用each将pInternalPointer向前移动一步,以及使用reset将pInternalPointer重置为起始位置,将会导致php手册中所说的“未知的行为”,很可惜,博主捣乱失败了。

于是他提出了一问题,foreach到底使用的拷贝还是原数组?

最佳答案是这样的:

须知:该回答假设你已经对于PHP中zval的机制有了基础的了解,特别是你需要知道什么是refcount和is_ref。

foreach可以作用各种可遍历的类型上。这里只讨论数组。

在开始讨论之前,先介绍一些遍历数组比较重要的知识

数组遍历的背景知识

PHP中的数组是有序的hashtable(hash bucket是双向链表的一部分),foreach将会以这种顺序来遍历数组(注:我觉得这种说法有问题,应该是指插入元素的顺序,而不是hashtable的顺序,可以参考鸟哥博客)。

PHP内部有两种机制来遍历数组:

第一种叫内部指针(pInternalPointer)。该指针是hashtable结构的一个成员变量,作用是用来指向当前的bucket。内部指针在面临数组修改的时候是安全的,例如当前bucket被删除了,那么内部指针将自动更新到下一bucket。

第二种遍历机制叫外部指针(pExternalPointer),叫做HashPosition。这个指针的作用跟内部指针很像,但是没有存储在hashtable结构体重。外部指针在面临数组修改的时候是非安全的。如果你删除了外部指针当前指向的bucket,外部指针就成了悬垂指针(danling pointer),造成段错误。

所以,除非你非常确定在遍历期间,没有代码会去修改数组,才使用外部指针。不过用户的代码可能会以多种方式执行,比如在错误处理句柄(errror handler),内型转换(cast),zval 销毁(zval destruction)等。所以基本上PHP都会使用内部指针。如果不这样,当用户开始做奇怪的事情时,PHP可能会发生段错误。

使用内部指针会带来的一个问题是,因为内部指针是hashtable的一部分,因此你更新它的时候,也就更新了hashtbale,同时也是更新了数组。由于php有按值的机制,所以在需要遍历数组的时候我们都需要拷贝一份数组。

这个简单嵌套遍历例子可以解释为什么拷贝非常重要

foreach ($array as $a) {
    foreach ($array as $b) {
        // ...
    }
}
这里你希望每个遍历都是独立的,不用以奇怪的方式共享内部指针。既然说到foreach,那么就来聊聊它吧:

foreach遍历数组:

现在各位明白为什么foreach在遍历数组前需要一份拷贝了吧?但是很明显故事还没讲完,博主的问题还没搞定呢!虽然foreach是需要拷贝的,但是PHP到底拷不拷还是要依赖于一些因素的。
  • 首先如果遍历的数组被引用了(is_ref == 1),那么拷贝就不会发生,而是直接使用引用。

	$ref =& $array; // $array has is_ref=1 now
	foreach ($array as $val) {
    		// ...
	}
Why?因为这里数组的任何变动都必须体现在引用上,包括内部指针的变动。如果foreach使用了数组拷贝,就会打破引用机制的规定。

  • 如果数组的引用计数(refcount)等于1,也不会进行拷贝。引用计数等于1说明数组没有被其他变量指向(ps:搞清楚被指向和引用的区别,我觉得这里英文都叫ref,容易引起误解).如果引用计数大于1,说明还有其他变量指向他,因此为了避免修改,foreach必须拷贝一份(被指向的变量是独立(separate)的,被引用则是共享(share)的,所以被指向需要拷贝,被引用不能发生拷贝)。
  • 如果数组本身就是以引用遍历的(foreach ($array as &$ref)),那么以上规则全部报废,数组直接会以引用遍历。
以上是第一部分:拷贝疑云。第二部分是关于遍历是怎么实现的,这个有一点点奇怪。我们通常所了解的遍历(在PHP中除了foreach)的,都是这样干的(伪代码):
reset();
while (get_current_data(&data) == SUCCESS) {
    code();
    move_forward();
}
foreach在遍历的时候有点不同:
reset();
while (get_current_data(&data) == SUCCESS) {
    move_forward();
    code();
}
区别就是指针的移动是先完成的,然后再执行循环体。所以当你的循环体还在执行第$i个bucket的时候,指针已经跑到第$i+1个bucket去了。
foreach在内部指针上的这种行为能够解释为什么当前bucket被删除的时候,内部指针可以移动到下一个bucket,而不是你希望的前一个。foreach使用这种方式可以很好遍历数组(但是很明显常用的遍历方式不能这样做,因为这样会跳过第一个元素)。

总结

  • 第一点关于以上描述的总结就是foreach在很多情况下必须拷贝一份遍历的数组。不过不用担心,我试过把拷贝要求去掉,也没有见到性能有差别(有些人说快两倍)。可能他们循环次数还不够吧:P
  • 第二点总结是就是不应该有什么总结。foreach通常对用户是透明的,它该干嘛就会干嘛。你也不必去担心如果拷贝发生了会怎样,也不用去想指针什么时候才会移动一步。
  • 第三点总结是,现在回到博主的问题了---有时候我们会看到各种奇怪的很难理解的行为。这种事情尤其会在你试图在foreach之中修改数组发生。

许多在遍历中对数组修改的边界行为都可以再php测试集中找到。用 这个测试集开始,把012增加到013等等,你可以体会到foreach在不同的情况中有怎样的行为。
不过现在我们来分析一下博主的代码:
  • 测试用例1:这里$array的refcount在遍历前等于1,所以它不会被拷贝,但是它refcount马上会加1(addref)。一旦执行了$array[]这样的代码,zval就会被独立开,所以循环体中被压入的元素的数组将会是另外一个数组。
  • 测试用例2:跟用例1一样
  • 测试用例3:也可以用以上理论解释。在遍历前,引用计数为1,所以不会遍历拷贝数组。因此数组内部指针会被修改。因此遍历完成后,内部指针指向NULL(意味着遍历完成了)。each当然返回false。
  • 测试用例4和5:each和reset这两个方法都是按引用传递的函数。那么这里当$array传递给这样的函数时候,refcount=2,所以必须产生拷贝。因此这里foreach也是在拷贝上运行的。
但是以上例子还不具有典型性。当你在循环体中使用current的时候,才会出现各种不符合只觉得行为:
foreach ($array as $val) {
    var_dump(current($array));
}
/* Output: 2 2 2 2 2 */
这里,你也应该知道current也是一个按引用传递的函数,即使他不会更新数组。这个函数必须得这样做,才能和一些按引用传递的函数一起正常运行(实际上current是一个可选择的按引用传递的函数( prefer-ref function)。它也可以传递一个值,但是它会尽量使用引用)。按引用传递意味着数组又必须得出现拷贝了,所以遍历的是不同的数组。至于为什么current结果是2而不是1,这个原因之前也提到了:foreach会在循环体之前移动内部指针,而不是之后。所以,即使代码中看起来是第一个元素,foreach已经把指针移动到第二个元素了。
稍微改动一下:
$ref = &$array;
foreach ($array as $val) {
    var_dump(current($array));
}
/* Output: 2 3 4 5 false */
现在$array的is_ref变成1了,所以数组不能被拷贝。而且现在数组在传递给current函数的时候,不用产生一份拷贝。因此,现在current和foreach作用的是同一个数组。由于预先移动指针,所以数组还是有点奇怪。
按引用遍历也会有一样的效果
foreach ($array as &$val) {
    var_dump(current($array));
}
/* Output: 2 3 4 5 false */
重要的是foreach在按引用遍历的时候,is_ref=1了。所以你会观察到相同的情况。

这次的小改动是我们先把数组赋值给一个变量:
$foo = $array;
foreach ($array as $val) {
    var_dump(current($array));
}
/* Output: 1 1 1 1 1 */
这里因为遍历之前refcount=2了,所以当遍历开始的时候,已经发生拷贝了,因此遍历的数组完全是独立的。这就是为什么你在循环体的任何时候都会得到相同的内部指针(这里是第一个)


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值