本篇文章是对php中的foreach问题进行了详细的分析介绍,需要的朋友参考下
前言: php4中引入了foreach结构,这是一种遍历数组的简单方式。相比传统的for循环,foreach能够更加便捷的获取键值对。在php5之前,foreach仅能用于数组;php5之后,利用foreach还能遍历对象(详见:遍历对象)。本文中仅讨论遍历数组的情况。
foreach虽然简单,不过它可能会出现一些意外的行为,特别是代码涉及引用的情况下。 下面列举了几种case,有助于我们进一步认清foreach的本质。 问题1: 复制代码 代码如下: $arr = array(1,2,3); foreach($arr as $k => &$v) { $v = $v * 2; } // now $arr is array(2, 4, 6) foreach($arr as $k => $v) { echo "$k", " => ", "$v"; }
先从简单的开始,如果我们尝试运行上述代码,就会发现最后输出为0=>2 1=>4 2=>4 。 为何不是0=>2 1=>4 2=>6 ? 其实,我们可以认为 foreach($arr as $k => $v) 结构隐含了如下操作,分别将数组当前的'键'和当前的'值'赋给变量$k和$v。具体展开形如: 复制代码 代码如下: foreach($arr as $k => $v){ //在用户代码执行之前隐含了2个赋值操作 $v = currentVal(); $k = currentKey(); //继续运行用户代码 …… }
根据上述理论,现在我们重新来分析下第一个foreach: 第1遍循环,由于$v是一个引用,因此$v = &$arr[0],$v=$v*2相当于$arr[0]*2,因此$arr变成2,2,3 第2遍循环,$v = &$arr[1],$arr变成2,4,3 第3遍循环,$v = &$arr[2],$arr变成2,4,6 随后代码进入了第二个foreach: 第1遍循环,隐含操作$v=$arr[0]被触发,由于此时$v仍然是$arr[2]的引用,即相当于$arr[2]=$arr[0],$arr变成2,4,2 第2遍循环,$v=$arr[1],即$arr[2]=$arr[1],$arr变成2,4,4 第3遍循环,$v=$arr[2],即$arr[2]=$arr[2],$arr变成2,4,4 OK,分析完毕。 如何解决类似问题呢?php手册上有一段提醒: Warning : 数组最后一个元素的 $value 引用在 foreach 循环之后仍会保留。建议使用unset()来将其销毁。 复制代码 代码如下: $arr = array(1,2,3); foreach($arr as $k => &$v) { $v = $v * 2; } unset($v); foreach($arr as $k => $v) { echo "$k", " => ", "$v"; } // 输出 0=>2 1=>4 2=>6
从这个问题中我们可以看出,引用很有可能会伴随副作用。如果不希望无意识的修改导致数组内容变更,最好及时unset掉这些引用。 问题2: 复制代码 代码如下: $arr = array('a','b','c'); foreach($arr as $k => $v) { echo key($arr), "=>", current($arr); } // 打印 1=>b 1=>b 1=>b
这个问题更加诡异。按照手册的说法,key和current分别是取数组中当前元素的的键值。 那为何key($arr)一直是1,current($arr)一直是b呢? 先用vld查看编译之后的opcode:
接下来执行数组的循环操作,我们来看FE_RESET指令,它对应的执行函数为ZEND_FE_RESET_SPEC_CV_HANDLER: 复制代码 代码如下: static int ZEND_FASTCALL ZEND_FE_RESET_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { …… if (……) { …… } else { // 通过CV数组获取指向array的指针 array_ptr = _get_zval_ptr_cv(&opline->op1, EX(Ts), BP_VAR_R TSRMLS_CC); …… } …… // 将指向array的指针保存到zend_execute_data->Ts中(Ts用于存放代码执行期的temp_variable) AI_SET_PTR(EX_T(opline->result.u.var).var, array_ptr); PZVAL_LOCK(array_ptr); if (iter) { …… } else if ((fe_ht = HASH_OF(array_ptr)) != NULL) { // 重置数组内部指针 zend_hash_internal_pointer_reset(fe_ht); if (ce) { …… } is_empty = zend_hash_has_more_elements(fe_ht) != SUCCESS;
// 设置EX_T(opline->result.u.var).fe.fe_pos用于保存数组内部指针 zend_hash_get_pointer(fe_ht, &EX_T(opline->result.u.var).fe.fe_pos); } else { …… } …… }
这里主要将2个重要的指针存入了zend_execute_data->Ts中: •EX_T(opline->result.u.var).var ---- 指向array的指针 •EX_T(opline->result.u.var).fe.fe_pos ---- 指向array内部元素的指针 FE_RESET指令执行完毕之后,内存中实际情况如下:
简单来说,由于第一遍循环中FE_FETCH中已经将数组的内部指针移动到了第二个元素,所以在foreach内部调用key($arr)和current($arr)时,实际上获取的便是1和'b'。 那为何会输出3遍1=>b呢? 我们继续看第9行和第13行的SEND_REF指令,它表示将$arr参数压栈。紧接着一般会使用DO_FCALL指令去调用key和current函数。PHP并非被编译成本地机器码,因此php采用这样的opcode指令去模拟实际CPU和内存的工作方式。 查阅PHP源码中的SEND_REF: 复制代码 代码如下: static int ZEND_FASTCALL ZEND_SEND_REF_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { …… // 从CV中获取$arr指针的指针 varptr_ptr = _get_zval_ptr_ptr_cv(&opline->op1, EX(Ts), BP_VAR_W TSRMLS_CC); ……
// 变量分离,此处重新copy了一份array专门用于key函数 SEPARATE_ZVAL_TO_MAKE_IS_REF(varptr_ptr); varptr = *varptr_ptr; Z_ADDREF_P(varptr);
// 压栈 zend_vm_stack_push(varptr TSRMLS_CC); ZEND_VM_NEXT_OPCODE(); }
上述代码中的SEPARATE_ZVAL_TO_MAKE_IS_REF是一个宏: 复制代码 代码如下: #define SEPARATE_ZVAL_TO_MAKE_IS_REF(ppzv) \ if (!PZVAL_IS_REF(*ppzv)) { \ SEPARATE_ZVAL(ppzv); \ Z_SET_ISREF_PP((ppzv)); \ }
SEPARATE_ZVAL_TO_MAKE_IS_REF的主要作用为,如果变量不是一个引用,则在内存中copy出一份新的。本例中它将array('a','b','c')复制了一份。因此变量分离之后的内存为:
上图解释了前2次循环为何会输出1=>b 2=>C。在第3次循环FE_FETCH的时候,将指针继续向前移动。 复制代码 代码如下: ZEND_API int zend_hash_move_forward_ex(HashTable *ht, HashPosition *pos) { HashPosition *current = pos ? pos : &ht->pInternalPointer; IS_CONSISTENT(ht); if (*current) { *current = (*current)->pListNext; return SUCCESS; } else return FAILURE; }
由于此时内部指针已经指向了数组的最后一个元素,因此再向前移动会指向NULL。将内部指针指向NULL之后,我们再对数组调用key和current,则分别会返回NULL和false,表示调用失败,此时是echo不出字符的。 问题4: 复制代码 代码如下: $arr = array(1, 2, 3); $tmp = $arr; foreach($tmp as $k => &$v){ $v *= 2; } var_dump($arr, $tmp); // 打印什么?
该题与foreach关系不大,不过既然涉及到了foreach,就一起拿来讨论吧:) 代码里首先创建了数组$arr,随后将该数组赋给了$tmp,在接下来的foreach循环中,对$v进行修改会作用于数组$tmp上,但是却并不作用到$arr。 为什么呢? 这是由于在php中,赋值运算是将一个变量的值拷贝到另一个变量中,因此修改其中一个,并不会影响到另一个。 题外话:这并不适用于object类型,从PHP5起,对象的便总是默认通过引用进行赋值,举例来说: 复制代码 代码如下: class A{ public $foo = 1; } $a1 = $a2 = new A; $a1->foo=100; echo $a2->foo; // 输出100,$a1与$a2其实为同一个对象的引用
回到题目中的代码,现在我们可以确定$tmp=$arr其实是值拷贝,整个$arr数组会被再复制一份给$tmp。理论上讲,赋值语句执行完毕之后,内存中会有2份一样的数组。 也许有同学会疑问,如果数组很大,岂不是这种操作会很慢? 幸好php有更聪明的处理办法。实际上,当$tmp=$arr执行之后,内存中依然只有一份array。查看php源码中的zend_assign_to_variable实现(摘自php5.3.26): 复制代码 代码如下: static inline zval* zend_assign_to_variable(zval **variable_ptr_ptr, zval *value, int is_tmp_var TSRMLS_DC) { zval *variable_ptr = *variable_ptr_ptr; zval garbage; …… // 左值为object类型 if (Z_TYPE_P(variable_ptr) == IS_OBJECT && Z_OBJ_HANDLER_P(variable_ptr, set)) { …… } // 左值为引用的情况 if (PZVAL_IS_REF(variable_ptr)) { …… } else { // 左值refcount__gc=1的情况 if (Z_DELREF_P(variable_ptr)==0) { …… } else { GC_ZVAL_CHECK_POSSIBLE_ROOT(*variable_ptr_ptr); // 非临时变量 if (!is_tmp_var) { if (PZVAL_IS_REF(value) && Z_REFCOUNT_P(value) > 0) { ALLOC_ZVAL(variable_ptr); *variable_ptr_ptr = variable_ptr; *variable_ptr = *value; Z_SET_REFCOUNT_P(variable_ptr, 1); zval_copy_ctor(variable_ptr); } else { // $tmp=$arr会运行到这里, // value为指向$arr里实际array数据的指针,variable_ptr_ptr为$tmp里指向数据指针的指针 // 仅仅是复制指针,并没有真正拷贝实际的数组 *variable_ptr_ptr = value; // value的refcount__gc值+1,本例中refcount__gc为1,Z_ADDREF_P之后为2 Z_ADDREF_P(value); } } else { …… } } Z_UNSET_ISREF_PP(variable_ptr_ptr); } return *variable_ptr_ptr; }
可见$tmp = $arr的本质就是将array的指针进行复制,然后将array的refcount自动加1.用图表达出此时的内存,依然只有一份array数组:
上图解释了为何foreach并不会对原来的$arr产生影响。至于ref_count以及is_ref的变化情况,感兴趣的同学可以详细阅读ZEND_FE_RESET_SPEC_CV_HANDLER和ZEND_SWITCH_FREE_SPEC_VAR_HANDLER的具体实现(均位于php-src/zend/zend_vm_execute.h中),本文不做详细剖析:)
|