深入解析php中的foreach问题


Posted in PHP onJune 30, 2013

前言:
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:深入解析php中的foreach问题

我们从第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表,因而可以提高效率。

static int ZEND_FASTCALL  ZEND_ASSIGN_SPEC_CV_TMP_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    zend_op *opline = EX(opline);
    zend_free_op free_op2;
    zval *value = _get_zval_ptr_tmp(&opline->op2, EX(Ts), &free_op2 TSRMLS_CC);    // CV数组中创建出$arr**指针
    zval **variable_ptr_ptr = _get_zval_ptr_ptr_cv(&opline->op1, EX(Ts), BP_VAR_W TSRMLS_CC);
    if (IS_CV == IS_VAR && !variable_ptr_ptr) {
        ……
    }
    else {
        // 将array赋值给$arr
         value = zend_assign_to_variable(variable_ptr_ptr, value, 1 TSRMLS_CC);
        if (!RETURN_VALUE_UNUSED(&opline->result)) {
            AI_SET_PTR(EX_T(opline->result.u.var).var, value);
            PZVAL_LOCK(value);
        }
    }
    ZEND_VM_NEXT_OPCODE();
}

ASSIGN指令完成之后,CV数组中被加入zval**指针,指针指向实际的array,这表示$arr已经被CV缓存了起来。深入解析php中的foreach问题

接下来执行数组的循环操作,我们来看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指令执行完毕之后,内存中实际情况如下:

深入解析php中的foreach问题

接下来我们继续查看FE_FETCH,它对应的执行函数为ZEND_FE_FETCH_SPEC_VAR_HANDLER:

static int ZEND_FASTCALL  ZEND_FE_FETCH_SPEC_VAR_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    zend_op *opline = EX(opline);    // 注意指针是从EX_T(opline->op1.u.var).var.ptr获取的
    zval *array = EX_T(opline->op1.u.var).var.ptr;
    ……
    switch (zend_iterator_unwrap(array, &iter TSRMLS_CC)) {
        default:
        case ZEND_ITER_INVALID:
            ……
        case ZEND_ITER_PLAIN_OBJECT: {
            ……
        }
        case ZEND_ITER_PLAIN_ARRAY:
            fe_ht = HASH_OF(array);
            // 特别注意:
            // FE_RESET指令中将数组内部元素的指针保存在EX_T(opline->op1.u.var).fe.fe_pos
            // 此处获取该指针
            zend_hash_set_pointer(fe_ht, &EX_T(opline->op1.u.var).fe.fe_pos);
            // 获取元素的值
            if (zend_hash_get_current_data(fe_ht, (void **) &value)==FAILURE) {
                ZEND_VM_JMP(EX(op_array)->opcodes+opline->op2.u.opline_num);
            }
            if (use_key) {
                key_type = zend_hash_get_current_key_ex(fe_ht, &str_key, &str_key_len, &int_key, 1, NULL);
            }
            // 数组内部指针移动到下一个元素
            zend_hash_move_forward(fe_ht);
            // 移动之后的指针保存到EX_T(opline->op1.u.var).fe.fe_pos
            zend_hash_get_pointer(fe_ht, &EX_T(opline->op1.u.var).fe.fe_pos);
            break;
        case ZEND_ITER_OBJECT:
            ……
    }
    ……
}

根据FE_FETCH的实现,我们大致上明白了foreach($arr as $k => $v)所做的事情。它会根据zend_execute_data->Ts的指针去获取数组元素,在获取成功之后,将该指针移动到下一个位置再重新保存。

深入解析php中的foreach问题

简单来说,由于第一遍循环中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')复制了一份。因此变量分离之后的内存为:深入解析php中的foreach问题
注意,变量分离完成之后,CV数组中的指针指向了新copy出来的数据,而通过zend_execute_data->Ts中的指针则依然可以获取旧的数据。
接下来的循环就不一一赘述了,结合上图来说:
•foreach结构使用的是下方蓝色的array,会依次遍历a,b,c
•key、current使用的是上方黄色的array,它的内部指针永远指向b
至此我们明白了为何key和current一直返回array的第二个元素,由于没有外部代码作用于copy出来的array,它的内部指针便永远不会移动。
问题3:
$arr = array('a','b','c');
foreach($arr as $k => &$v) {
    echo key($arr), '=>', current($arr);
}// 打印 1=>b 2=>c =>

本题与问题2仅有一点区别:本题中的foreach使用了引用。用VLD查看本题,发现与问题2代码编译出来的opcode一样。因此我们采用问题2的跟踪方法,逐步查看opcode对应的实现。
首先foreach会调用FE_RESET:
static int ZEND_FASTCALL  ZEND_FE_RESET_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    ……
    if (opline->extended_value & ZEND_FE_RESET_VARIABLE) {
        // 从CV中获取变量
        array_ptr_ptr = _get_zval_ptr_ptr_cv(&opline->op1, EX(Ts), BP_VAR_R TSRMLS_CC);
        if (array_ptr_ptr == NULL || array_ptr_ptr == &EG(uninitialized_zval_ptr)) {
            ……
        }
        else if (Z_TYPE_PP(array_ptr_ptr) == IS_OBJECT) {
            ……
        }
        else {
            // 针对遍历array的情况
            if (Z_TYPE_PP(array_ptr_ptr) == IS_ARRAY) {
                SEPARATE_ZVAL_IF_NOT_REF(array_ptr_ptr);
                if (opline->extended_value & ZEND_FE_FETCH_BYREF) {
                    // 将保存array的zval设置为is_ref
                    Z_SET_ISREF_PP(array_ptr_ptr);
                }
            }
            array_ptr = *array_ptr_ptr;
            Z_ADDREF_P(array_ptr);
        }
    } else {
        ……
    }
    ……
}

问题2中已经分析了一部分FE_RESET的实现。这里需要特别注意,本例foreach获取值采用了引用,因此在执行的时候FE_RESET中会进入与上题不同的另一个分支。
最终,FE_RESET会将array的is_ref设置为true,此时内存中只有一份array的数据。
接下来分析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);
    ……    // 变量分离,由于此时CV中的变量本身就是一个引用,此处不会copy一份新的array
    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仅仅分离is_ref=false的变量。由于之前array已经被设置了is_ref=true,因此它不会被拷贝一份副本。换句话说,此时内存中依然只有一份array数据。

深入解析php中的foreach问题

上图解释了前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数组:
深入解析php中的foreach问题
既然只有一份array,那foreach循环中修改$tmp的时候,为何$arr没有跟着改变?
继续看PHP源码中的ZEND_FE_RESET_SPEC_CV_HANDLER函数,这是一个OPCODE HANDLER,它对应的OPCODE为FE_RESET。该函数负责在foreach开始之前,将数组的内部指针指向其第一个元素。
static int ZEND_FASTCALL  ZEND_FE_RESET_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
    zend_op *opline = EX(opline);
    zval *array_ptr, **array_ptr_ptr;
    HashTable *fe_ht;
    zend_object_iterator *iter = NULL;
    zend_class_entry *ce = NULL;
    zend_bool is_empty = 0;
    // 对变量进行FE_RESET
    if (opline->extended_value & ZEND_FE_RESET_VARIABLE) {
        array_ptr_ptr = _get_zval_ptr_ptr_cv(&opline->op1, EX(Ts), BP_VAR_R TSRMLS_CC);
        if (array_ptr_ptr == NULL || array_ptr_ptr == &EG(uninitialized_zval_ptr)) {
            ……
        }
        // foreach一个object
        else if (Z_TYPE_PP(array_ptr_ptr) == IS_OBJECT) {
            ……
        }
        else {
            // 本例会进入该分支
            if (Z_TYPE_PP(array_ptr_ptr) == IS_ARRAY) {
                // 注意此处的SEPARATE_ZVAL_IF_NOT_REF
                // 它会重新复制一个数组出来
                // 真正分离$tmp和$arr,变成了内存中的2个数组
                SEPARATE_ZVAL_IF_NOT_REF(array_ptr_ptr);
                if (opline->extended_value & ZEND_FE_FETCH_BYREF) {
                    Z_SET_ISREF_PP(array_ptr_ptr);
                }
            }
            array_ptr = *array_ptr_ptr;
            Z_ADDREF_P(array_ptr);
        }
    } else {
        ……
    }    // 重置数组内部指针
    ……
}

从代码中可以看出,真正执行变量分离并不是在赋值语句执行的时候,而是推迟到了使用变量的时候,这也是Copy On Write机制在PHP中的实现。
FE_RESET之后,内存的变化如下:
深入解析php中的foreach问题

上图解释了为何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中),本文不做详细剖析:)

PHP 相关文章推荐
PHP 操作文件的一些FAQ总结
Feb 12 PHP
php xml文件操作实现代码(二)
Mar 20 PHP
PHP中使用memcache存储session的三种配置方法
Apr 05 PHP
ThinkPHP字符串函数及常用函数汇总
Jul 18 PHP
php使用PDO操作MySQL数据库实例
Dec 30 PHP
PHP实现过滤各种HTML标签
May 17 PHP
php技术实现加载字体并保存成图片
Jul 27 PHP
分享PHP函数实现数字与文字分页代码
Jul 28 PHP
基于PHP实现等比压缩图片大小
Mar 04 PHP
PHP的Yii框架中过滤器相关的使用总结
Mar 29 PHP
PHP+Ajax验证码验证用户登录
Jul 20 PHP
PHP封装curl的调用接口及常用函数详解
May 31 PHP
浅析Apache中RewriteCond规则参数的详细介绍
Jun 30 #PHP
浅析关于PHP位运算的简单权限设计
Jun 30 #PHP
PHP删除HTMl标签的三种解决方法
Jun 30 #PHP
PHP删除HTMl标签的实现代码
Jun 30 #PHP
浅析php面向对象public private protected 访问修饰符
Jun 30 #PHP
解析link_mysql的php版
Jun 30 #PHP
分享8个最佳的代码片段在线测试网站
Jun 29 #PHP
You might like
十天学会php之第十天
2006/10/09 PHP
PHP中的正规表达式(一)
2006/10/09 PHP
用phpmyadmin更改mysql5.0登录密码
2008/03/25 PHP
php 读取shell管道传输过来的内容
2010/03/01 PHP
php高级编程-函数-郑阿奇
2011/07/04 PHP
PHP 万年历实现代码
2012/10/18 PHP
如何使用PHP实现javascript的escape和unescape函数
2013/06/29 PHP
yii去掉必填项中星号的方法
2015/12/28 PHP
Yii框架连接mongodb数据库的代码
2016/07/27 PHP
PHP date()格式MySQL中插入datetime方法
2019/01/29 PHP
jQuery UI 应用不同Theme的办法
2010/09/12 Javascript
深入分析JSON编码格式提交表单数据
2015/06/25 Javascript
jQuery遍历DOM节点操作之filter()方法详解
2016/04/14 Javascript
去除html代码里面的script正则方法
2016/05/19 Javascript
简洁实用的BootStrap jQuery手风琴插件
2016/08/31 Javascript
基于AngularJS实现iOS8自带的计算器
2016/09/12 Javascript
JQueryEasyUI框架下的combobox的取值和绑定的方法
2017/01/22 Javascript
拖动时防止选中
2017/02/03 Javascript
解决betterScroll在vue中存在图片时,出现拉不动的问题
2018/09/27 Javascript
Python实现批量执行同目录下的py文件方法
2019/01/11 Python
解决在Python编辑器pycharm中程序run正常debug错误的问题
2019/01/17 Python
python GUI实现小球满屏乱跑效果
2019/05/09 Python
OpenCV 模板匹配
2019/07/10 Python
Django框架model模型对象验证实现方法分析
2019/10/02 Python
python hmac模块验证客户端的合法性
2020/11/07 Python
Expedia意大利旅游网站:酒店、机票和租车预订
2017/10/30 全球购物
加拿大快时尚零售商:Ardene
2018/02/14 全球购物
阿根廷票务网站:StubHub阿根廷
2018/04/13 全球购物
广告学专业毕业生自荐信
2013/09/24 职场文书
2014年文学毕业生自我鉴定
2014/04/23 职场文书
山东省召开党的群众路线教育实践活动总结大会新闻稿
2014/10/21 职场文书
2015人事行政工作总结范文
2015/05/21 职场文书
2016元旦晚会主持词开场白和结束语
2015/12/04 职场文书
会计做账心得体会
2016/01/22 职场文书
Python下opencv使用hough变换检测直线与圆
2021/06/18 Python
JavaScript模拟实现网易云轮播效果
2022/04/04 Javascript