php4中引入了foreach结构,这是一种遍历数组的简单方式。相比传统的for循环,foreach能够更加便捷的获取键值对。在php5之前,foreach仅能用于数组;php5之后,利用foreach还能遍历对象(详见:遍历对象)。本文中仅讨论遍历数组的情况。
foreach虽然简单,不过它可能会出现一些意外的行为,特别是代码涉及引用的情况下。
下面列举了几种case,有助于我们进一步认清foreach的本质。
$arr = array(<span 1</span>,<span 2</span>,<span 3</span><span ); </span><span foreach</span>($arr <span as</span> $k => &<span $v) { $v </span>= $v * <span 2</span><span ; } </span><span //</span><span now $arr is array(2, 4, 6)</span> <span foreach</span>($arr <span as</span> $k =><span $v) { echo </span><span "</span><span $k</span><span "</span>, <span "</span><span => </span><span "</span>, <span "</span><span $v</span><span "</span><span ; }</span>
先从简单的开始,如果我们尝试运行上述代码,就会发现最后输出为0=>2 1=>4 2=>4 。
为何不是0=>2 1=>4 2=>6 ?
其实,我们可以认为 ( => 隐含了如下操作,分别将数组当前的'键'和当前的'值'赋给变量$k和$v。具体展开形如:
<span foreach</span>(<span $arr</span> <span as</span> <span $k</span> => <span $v</span><span ){ <br /></span><span //在用户代码执行之前</span><span 隐含了2个赋值操作<br /></span><span $v</span> =<span currentVal(); <br /> </span><span $k</span> =<span currentKey();<br /> </span><span //继续运行</span><span 用户代码</span> <span …… }</span>
根据上述理论,现在我们重新来分析下第一个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()来将其销毁。
<span $arr</span> = <span array</span>(1,2,3<span ); </span><span foreach</span>(<span $arr</span> <span as</span> <span $k</span> => &<span $v</span><span ) { </span><span $v</span> = <span $v</span> * 2<span ; } </span><span unset</span>(<span $v</span><span ); </span><span foreach</span>(<span $arr</span> <span as</span> <span $k</span> => <span $v</span><span ) { </span><span echo</span> "<span $k</span>", " => ", "<span $v</span>"<span ; } </span><span //</span><span 输出 0=>2 1=>4 2=>6</span>
从这个问题中我们可以看出,引用很有可能会伴随副作用。如果不希望无意识的修改导致数组内容变更,最好及时unset掉这些引用。
<span $arr</span> = <span array</span>('a','b','c'<span ); </span><span foreach</span>(<span $arr</span> <span as</span> <span $k</span> => <span $v</span><span ) { </span><span echo</span> <span key</span>(<span $arr</span>), "=>", <span current</span>(<span $arr</span>)<span ; }<br /><br />// 打印 1=>b 1=>b 1=>b</span>
这个问题更加诡异。按照手册的说法,key和current分别是取数组中当前元素的的键值。
那为何key($arr)一直是1,current($arr)一直是b呢?
先用vld查看编译之后的opcode:
我们从第3行的ASSIGN指令看起,它代表将array('a','b','c')赋值给$arr。
由于$arr为CV,array('a','b','c')为TMP,因此ASSIGN指令找到实际执行的函数为ZEND_ASSIGN_SPEC_CV_TMP_HANDLER。这里需要特别指出,CV是PHP5.1之后才增加的一种变量cache,它采用数组的形式来保存zval**,被cache住的变量再次使用时无需去查找active符号表,而是直接去CV数组中获取,由于数组访问速度远超hash表,因而可以提高效率。
<span static</span> <span int</span><span ZEND_FASTCALL ZEND_ASSIGN_SPEC_CV_TMP_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { zend_op </span>*opline =<span EX(opline); zend_free_op free_op2; zval </span>*value = _get_zval_ptr_tmp(&opline->op2, EX(Ts), &<span free_op2 TSRMLS_CC); </span><span //</span><span CV数组中创建出$arr**指针</span> zval **variable_ptr_ptr = _get_zval_ptr_ptr_cv(&opline-><span op1, EX(Ts), BP_VAR_W TSRMLS_CC); </span><span if</span> (IS_CV == IS_VAR && !<span variable_ptr_ptr) { …… } </span><span else</span><span { </span><span //</span><span 将array赋值给$arr</span> value = zend_assign_to_variable(variable_ptr_ptr, value, <span 1</span><span TSRMLS_CC); </span><span if</span> (!RETURN_VALUE_UNUSED(&opline-><span result)) { AI_SET_PTR(EX_T(opline</span>->result.u.<span var</span>).<span var</span><span , value); PZVAL_LOCK(value); } } ZEND_VM_NEXT_OPCODE(); }</span>
ASSIGN指令完成之后,CV数组中被加入zval**指针,指针指向实际的array,这表示$arr已经被CV缓存了起来。
接下来执行数组的循环操作,我们来看FE_RESET指令,它对应的执行函数为ZEND_FE_RESET_SPEC_CV_HANDLER:
<span static</span> <span int</span><span ZEND_FASTCALL ZEND_FE_RESET_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { …… </span><span if</span><span (……) { …… } </span><span else</span><span { </span><span //</span><span 通过CV数组获取指向array的指针</span> array_ptr = _get_zval_ptr_cv(&opline-><span op1, EX(Ts), BP_VAR_R TSRMLS_CC); …… } …… </span><span //</span><span 将<span 指向</span>array的指针保存到zend_execute_data->Ts中(Ts用于存放代码执行期的temp_variable)</span> AI_SET_PTR(EX_T(opline->result.u.<span var</span>).<span var</span><span , array_ptr); PZVAL_LOCK(array_ptr); </span><span if</span><span (iter) { …… } </span><span else</span> <span if</span> ((fe_ht = HASH_OF(array_ptr)) !=<span NULL) { </span><span //</span><span 重置数组内部指针</span> <span zend_hash_internal_pointer_reset(fe_ht); </span><span if</span><span (ce) { …… } is_empty </span>= zend_hash_has_more_elements(fe_ht) !=<span SUCCESS; </span><span //</span><span 设置EX_T(opline->result.u.var).fe.fe_pos用于保存数组内部指针</span> zend_hash_get_pointer(fe_ht, &EX_T(opline->result.u.<span var</span><span ).fe.fe_pos); } </span><span else</span><span { …… } …… }</span>
这里主要将2个重要的指针存入了Ts中:
FE_RESET指令执行完毕之后,内存中实际情况如下:
接下来我们继续查看FE_FETCH,它对应的执行函数为ZEND_FE_FETCH_SPEC_VAR_HANDLER:
<span static</span> <span int</span><span ZEND_FASTCALL ZEND_FE_FETCH_SPEC_VAR_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { zend_op </span>*opline =<span EX(opline); </span><span //</span><span 注意指针是从EX_T(opline->op1.u.var).var.ptr获取的</span> zval *array = EX_T(opline->op1.u.<span var</span>).<span var</span><span .ptr; …… </span><span switch</span> (zend_iterator_unwrap(array, &<span iter TSRMLS_CC)) { </span><span default</span><span : </span><span case</span><span ZEND_ITER_INVALID: …… </span><span case</span><span ZEND_ITER_PLAIN_OBJECT: { …… } </span><span case</span><span ZEND_ITER_PLAIN_ARRAY: fe_ht </span>=<span HASH_OF(array); </span><span //</span><span 特别注意: </span><span //</span><span FE_RESET指令中将数组内部元素的指针保存在EX_T(opline->op1.u.var).fe.fe_pos </span><span //</span><span 此处获取该指针</span> zend_hash_set_pointer(fe_ht, &EX_T(opline->op1.u.<span var</span><span ).fe.fe_pos); </span><span //</span><span 获取元素的值</span> <span if</span> (zend_hash_get_current_data(fe_ht, (<span void</span> **) &value)==<span FAILURE) { ZEND_VM_JMP(EX(op_array)</span>->opcodes+opline-><span op2.u.opline_num); } </span><span if</span><span (use_key) { key_type </span>= zend_hash_get_current_key_ex(fe_ht, &str_key, &str_key_len, &int_key, <span 1</span><span , NULL); } </span><span //</span><span 数组内部指针移动到下一个元素</span> <span zend_hash_move_forward(fe_ht); </span><span //</span><span 移动之后的指针保存到EX_T(opline->op1.u.var).fe.fe_pos</span> zend_hash_get_pointer(fe_ht, &EX_T(opline->op1.u.<span var</span><span ).fe.fe_pos); </span><span break</span><span ; </span><span case</span><span ZEND_ITER_OBJECT: …… } …… }</span>
根据FE_FETCH的实现,我们大致上明白了foreach($arr as $k => $v)所做的事情。它会根据Ts的指针去获取数组元素,在获取成功之后,将该指针移动到下一个位置再重新保存。
简单来说,由于第一遍循环中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:
<span static</span> <span int</span><span ZEND_FASTCALL ZEND_SEND_REF_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { ……<br /></span><span //</span><span 从CV中获取$arr指针的指针</span> varptr_ptr = _get_zval_ptr_ptr_cv(&opline-><span op1, EX(Ts), BP_VAR_W TSRMLS_CC); …… </span><span //</span><span 变量分离,此处重新copy了一份array专门用于key函数</span> <span SEPARATE_ZVAL_TO_MAKE_IS_REF(varptr_ptr); varptr </span>= *<span varptr_ptr; Z_ADDREF_P(varptr); </span><span //</span><span 压栈</span> <span zend_vm_stack_push(varptr TSRMLS_CC); ZEND_VM_NEXT_OPCODE(); }</span>
上述代码中的
<span #define</span> SEPARATE_ZVAL_TO_MAKE_IS_REF(ppzv) \ <span if</span> (!PZVAL_IS_REF(*<span ppzv)) { \ SEPARATE_ZVAL(ppzv); \ Z_SET_ISREF_PP((ppzv)); \ }</span>
的主要作用为,如果变量不是一个引用,则在内存中copy出一份新的。本例中它将array('a','b','c')复制了一份。因此变量分离之后的内存为:
注意,变量分离完成之后,CV数组中的指针指向了新copy出来的数据,而通过Ts中的指针则依然可以获取旧的数据。
接下来的循环就不一一赘述了,结合上图来说:
至此我们明白了为何key和current一直返回array的第二个元素,由于没有外部代码作用于copy出来的array,它的内部指针便永远不会移动。
<span $arr</span> = <span array</span>('a','b','c'<span ); </span><span foreach</span>(<span $arr</span> <span as</span> <span $k</span> => &<span $v</span><span ) { </span><span echo</span> <span key</span>(<span $arr</span>), '=>', <span current</span>(<span $arr</span>)<span ; }<br /></span>
<span // 打印 1=>b 2=>c =></span>
本题与问题2仅有一点区别:本题中的foreach使用了引用。用VLD查看本题,发现与问题2代码编译出来的opcode一样。因此我们采用问题2的跟踪方法,逐步查看opcode对应的实现。
首先foreach会调用FE_RESET:
<span static</span> <span int</span><span ZEND_FASTCALL ZEND_FE_RESET_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { …… </span><span if</span> (opline->extended_value &<span ZEND_FE_RESET_VARIABLE) { </span><span //</span><span 从CV中获取变量</span> array_ptr_ptr = _get_zval_ptr_ptr_cv(&opline-><span op1, EX(Ts), BP_VAR_R TSRMLS_CC); </span><span if</span> (array_ptr_ptr == NULL || array_ptr_ptr == &<span EG(uninitialized_zval_ptr)) { …… } </span><span else</span> <span if</span> (Z_TYPE_PP(array_ptr_ptr) ==<span IS_OBJECT) { …… } </span><span else</span><span { </span><span //</span><span 针对遍历array的情况</span> <span if</span> (Z_TYPE_PP(array_ptr_ptr) ==<span IS_ARRAY) { SEPARATE_ZVAL_IF_NOT_REF(array_ptr_ptr); </span><span if</span> (opline->extended_value &<span ZEND_FE_FETCH_BYREF) { </span><span //</span><span 将保存array的zval设置为is_ref</span> <span Z_SET_ISREF_PP(array_ptr_ptr); } } array_ptr </span>= *<span array_ptr_ptr; Z_ADDREF_P(array_ptr); } } </span><span else</span><span { …… } …… }</span>
问题2中已经分析了一部分FE_RESET的实现。这里需要特别注意,本例foreach获取值采用了引用,因此在执行的时候FE_RESET中会进入与上题不同的另一个分支。
最终,FE_RESET会将array的is_ref设置为true,此时内存中只有一份array的数据。
接下来分析SEND_REF:
<span static</span> <span int</span><span ZEND_FASTCALL ZEND_SEND_REF_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { …… </span><span //</span><span 从CV中获取$arr指针的指针</span> varptr_ptr = _get_zval_ptr_ptr_cv(&opline-><span op1, EX(Ts), BP_VAR_W TSRMLS_CC); …… </span><span //</span><span 变量分离,</span><span 由于此时CV中的变量本身就是一个引用,</span><span 此处不会copy一份新的array</span> <span SEPARATE_ZVAL_TO_MAKE_IS_REF(varptr_ptr); varptr </span>= *<span varptr_ptr; Z_ADDREF_P(varptr); </span><span //</span><span 压栈</span> <span zend_vm_stack_push(varptr TSRMLS_CC); ZEND_VM_NEXT_OPCODE(); }</span>
宏SEPARATE_ZVAL_TO_MAKE_IS_REF仅仅分离is_ref=false的变量。由于之前array已经被设置了is_ref=true,因此它不会被拷贝一份副本。换句话说,此时内存中依然只有一份array数据。
上图解释了前2次循环为何会输出1=>b 2=>C。在第3次循环FE_FETCH的时候,将指针继续向前移动。
ZEND_API <span int</span> zend_hash_move_forward_ex(HashTable *ht, HashPosition *<span pos) { HashPosition </span>*current = pos ? pos : &ht-><span pInternalPointer; IS_CONSISTENT(ht); </span><span if</span> (*<span current) { </span>*current = (*current)-><span pListNext; </span><span return</span><span SUCCESS; } </span><span else</span> <span return</span><span FAILURE; }</span>
由于此时内部指针已经指向了数组的最后一个元素,因此再向前移动会指向NULL。将内部指针指向NULL之后,我们再对数组调用key和current,则分别会返回NULL和false,表示调用失败,此时是echo不出字符的。
<span $arr</span> = <span array</span>(1, 2, 3<span ); </span><span $tmp</span> = <span $arr</span><span ; </span><span foreach</span>(<span $tmp </span><span as</span> <span $k</span> => &<span $v</span><span ){ </span><span $v</span> *= 2<span ; } </span><span var_dump</span>(<span $arr</span>, <span $tmp</span>); <span //</span><span 打印什么?</span>
该题与foreach关系不大,不过既然涉及到了foreach,就一起拿来讨论吧:)
代码里首先创建了数组$arr,随后将该数组赋给了$tmp,在接下来的foreach循环中,对$v进行修改会作用于数组$tmp上,但是却并不作用到$arr。
为什么呢?
这是由于在php中,赋值运算是将一个变量的值拷贝到另一个变量中,因此修改其中一个,并不会影响到另一个。
题外话:这并不适用于object类型,从PHP5起,对象的便总是默认通过引用进行赋值,举例来说:
<span class</span><span A{ </span><span public</span> <span $foo</span> = 1<span ; } </span><span $a1</span> = <span $a2</span> = <span new</span><span A; </span><span $a1</span>->foo=100<span ; </span><span echo</span> <span $a2</span>->foo; <span //</span><span 输出100,$a1与$a2其实为同一个对象的引用</span>
回到题目中的代码,现在我们可以确定$tmp=$arr其实是值拷贝,整个$arr数组会被再复制一份给$tmp。理论上讲,赋值语句执行完毕之后,内存中会有2份一样的数组。
也许有同学会疑问,如果数组很大,岂不是这种操作会很慢?
幸好php有更聪明的处理办法。实际上,当$tmp=$arr执行之后,内存中依然只有一份array。查看php源码中的zend_assign_to_variable实现(摘自php5.3.26):
<span static</span> inline zval* zend_assign_to_variable(zval **variable_ptr_ptr, zval *value, <span int</span><span is_tmp_var TSRMLS_DC) { zval </span>*variable_ptr = *<span variable_ptr_ptr; zval garbage; …… </span><span //</span><span 左值为object类型</span> <span if</span> (Z_TYPE_P(variable_ptr) == IS_OBJECT && Z_OBJ_HANDLER_P(variable_ptr, <span set</span><span )) { …… } </span><span //</span><span 左值为引用的情况</span> <span if</span><span (PZVAL_IS_REF(variable_ptr)) { …… } </span><span else</span><span { </span><span //</span><span 左值refcount__gc=1的情况</span> <span if</span> (Z_DELREF_P(variable_ptr)==<span 0</span><span ) { …… } </span><span else</span><span { GC_ZVAL_CHECK_POSSIBLE_ROOT(</span>*<span variable_ptr_ptr); </span><span //</span><span 非临时变量</span> <span if</span> (!<span is_tmp_var) { </span><span if</span> (PZVAL_IS_REF(value) && Z_REFCOUNT_P(value) > <span 0</span><span ) { ALLOC_ZVAL(variable_ptr); </span>*variable_ptr_ptr =<span variable_ptr; </span>*variable_ptr = *<span value; Z_SET_REFCOUNT_P(variable_ptr, </span><span 1</span><span ); zval_copy_ctor(variable_ptr); } </span><span else</span><span { </span><span //</span><span $tmp=$arr会运行到这里,<br /> // value为指向$arr里实际array数据的指针,variable_ptr_ptr为$tmp里指向数据指针的指针 </span><span //</span><span 仅仅是复制指针,并没有真正拷贝实际的数组</span> *variable_ptr_ptr =<span value; </span><span //</span><span value的refcount__gc值+1,本例中refcount__gc为1,Z_ADDREF_P之后为2</span> <span Z_ADDREF_P(value); } } </span><span else</span><span { …… } } Z_UNSET_ISREF_PP(variable_ptr_ptr); } </span><span return</span> *<span variable_ptr_ptr; }</span>
可见$tmp = $arr的本质就是将array的指针进行复制,然后将array的自动加1.用图表达出此时的内存,依然只有一份array数组:
既然只有一份array,那foreach循环中修改$tmp的时候,为何$arr没有跟着改变?
继续看PHP源码中的ZEND_FE_RESET_SPEC_CV_HANDLER函数,这是一个OPCODE HANDLER,它对应的OPCODE为FE_RESET。该函数负责在foreach开始之前,将数组的内部指针指向其第一个元素。
<span static</span><span int ZEND_FASTCALL ZEND_FE_RESET_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS) { zend_op </span>*opline =<span EX(opline); zval </span>*array_ptr, **<span array_ptr_ptr; HashTable </span>*<span fe_ht; zend_object_iterator </span>*iter = <span NULL</span><span ; zend_class_entry </span>*ce = <span NULL</span><span ; zend_bool is_empty </span>= 0<span ; </span><span //</span><span 对变量进行FE_RESET</span> <span if</span> (opline->extended_value &<span ZEND_FE_RESET_VARIABLE) { array_ptr_ptr </span>= _get_zval_ptr_ptr_cv(&opline->op1, EX(Ts),<span BP_VAR_R TSRMLS_CC); </span><span if</span> (array_ptr_ptr == <span NULL</span> || array_ptr_ptr == &<span EG(uninitialized_zval_ptr)) { …… } </span><span //</span><span foreach一个object</span> <span else</span> <span if</span> (Z_TYPE_PP(array_ptr_ptr) == <span IS_OBJECT</span><span ) { …… } </span><span else</span><span { </span><span //</span><span 本例会进入该分支</span> <span if</span> (Z_TYPE_PP(array_ptr_ptr) == <span IS_ARRAY</span><span ) { </span><span //</span><span 注意此处的SEPARATE_ZVAL_IF_NOT_REF<br /> // 它会重新复制一个数组出来 // 真正分离$tmp和$arr,变成了内存中的2个数组</span> <span SEPARATE_ZVAL_IF_NOT_REF(array_ptr_ptr); </span><span if</span> (opline->extended_value &<span ZEND_FE_FETCH_BYREF) { Z_SET_ISREF_PP(array_ptr_ptr); } } array_ptr </span>= *<span array_ptr_ptr; Z_ADDREF_P(array_ptr); } } </span><span else</span><span { …… } </span><span //</span><span 重置数组内部指针</span> <span …… }</span>
从代码中可以看出,真正执行变量分离并不是在赋值语句执行的时候,而是推迟到了使用变量的时候,这也是Copy On Write机制在PHP中的实现。
FE_RESET之后,内存的变化如下:
上图解释了为何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中),本文不做详细剖析:)