从stackoverflow翻译过来的一张文章,原文地址路径点这里
博主对于foreach提出了比较有意思的一些问题,
$array = array(1, 2, 3, 4, 5);
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遍历数组:
- 首先如果遍历的数组被引用了(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)),那么以上规则全部报废,数组直接会以引用遍历。
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在很多情况下必须拷贝一份遍历的数组。不过不用担心,我试过把拷贝要求去掉,也没有见到性能有差别(有些人说快两倍)。可能他们循环次数还不够吧:P
- 第二点总结是就是不应该有什么总结。foreach通常对用户是透明的,它该干嘛就会干嘛。你也不必去担心如果拷贝发生了会怎样,也不用去想指针什么时候才会移动一步。
- 第三点总结是,现在回到博主的问题了---有时候我们会看到各种奇怪的很难理解的行为。这种事情尤其会在你试图在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也是在拷贝上运行的。
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了,所以当遍历开始的时候,已经发生拷贝了,因此遍历的数组完全是独立的。这就是为什么你在循环体的任何时候都会得到相同的内部指针(这里是第一个)