【从入门到放弃-PHP】foreach 引用的坑

背景描述

先看一段代码。

$arr = [
    'jack'  => '20',
    'tom'   => '21',
    'marry' => '54',
    'less'  => '23'
];
foreach ($arr as &$val) {
    echo $val;
}
foreach ($arr as $val) {
    echo $val;
}
print_r($arr);

想一下应该输出什么呢?

运行一下脚本,真实结果和你想的是否一致呢?
【从入门到放弃-PHP】foreach 引用的坑
在foreach中使用了引用后再次foreach发现$arr['less']的值变成了54,常规理解应该是23才对。

猜测可能是因为使用引用导致该值变为54 但本着知其然更要知其所以然 我们一起追一下php源码 是什么原因导致的

环境准备

工欲善其事必先利其器,先下载调试工具及源码

安装Visual Studio

下载Visual Studio 2017,并安装
下载地址:https://www.visualstudio.com/zh-hans/downloads/

下载php源码

因神马前端目前使用php7.0,因此下载php7.0的最新版 7.0.27为研究对象
下载地址:http://cn2.php.net/distributions/php-7.0.27.tar.bz2

创建解决方案

根据已有目录生成解决方案

创建成功后如下图所示
【从入门到放弃-PHP】foreach 引用的坑

源码追踪

词法分析阶段

搜索关键字foreach
【从入门到放弃-PHP】foreach 引用的坑

可以在zend_language_parser.c 中看到, 语法解析时 foreach会当做T_FOREACH
【从入门到放弃-PHP】foreach 引用的坑

在zend_language_parser.y可以看到语法解析的具体方式
【从入门到放弃-PHP】foreach 引用的坑

ZEND_AST_FOREACH
【从入门到放弃-PHP】foreach 引用的坑
【从入门到放弃-PHP】foreach 引用的坑

查找zend_ast_create
【从入门到放弃-PHP】foreach 引用的坑

zend_ast.c中:
【从入门到放弃-PHP】foreach 引用的坑

zend_ast_create 函数是创建一个抽象语法树(abstract syntax tree)返回的zend_ast结构如下:
【从入门到放弃-PHP】foreach 引用的坑

具体的赋值操作如下:
【从入门到放弃-PHP】foreach 引用的坑

编译生成opcode

接下来在zend_compile.c中根据抽象语法树生成opcode:
【从入门到放弃-PHP】foreach 引用的坑

通过上图及语法解析的分析可知,foreach在编译阶段会生成如上图的四个zend_ast节点,分别表示:要遍历的数组或对象expr_ast,要遍历的value value_ast,要遍历的key key_ast,循环体stmt_ast
如:

$arr = [1, 2, 3];
foreach ($arr as $key => $val) {
    echo $val;
}

expr_ast 是可理解为是$arr编译时对应的ast结构
value_ast对应$val
key_ast对应$key
stmt_ast对应"echo $val;"

【从入门到放弃-PHP】foreach 引用的坑

copy一份要遍历的数组或对象,如果是引用则把原数组或对象设为引用类型

如:

foreach ($arr as $k => $v) {
        echo $v;
}
copy一份$arr用于遍历,从arData的首元素起,把bucket.zval.value赋值给$v,把bucket.h或key赋值给$k,然后将下一个元素的位置记录在zval.u2.fe_iter_idx中,下次遍历从该位置开始,当u2.fe_iter_idex到了arData的末尾则遍历结束并销毁copy的$arr副本

【从入门到放弃-PHP】foreach 引用的坑

如果$v是引用 则在循环前,将原$arr设置为引用类型 即:
foreach ($arr as $k => &$v) {
    echo $v;
}

【从入门到放弃-PHP】foreach 引用的坑

【从入门到放弃-PHP】foreach 引用的坑

  • 编译copy的数组、对象操作的指令:增加一条opcode指令 ZEND_FE_RESET_R(如果value是引用则用ZEND_FE_RESET_RW) 。执行时如果发现遍历的不是数组、对象 则抛出一个warning,然后跳出循环。
  • 编译fetch数组、对象当前单元key 、value的opcode : ZEND_FE_FETCH_R(如果value是引用则用ZEND_FE_FETCH_RW)。此opcode需要知道当遍历到达数组末尾时跳出遍历的位置。此外还会对key和value分配他们在内存中的位置,如果value不是一CV个变量,还会编译其它操作的opcode
  • 如果定义了key,则会编译一条opcode,对key进行赋值
  • 编译循环体statement
  • 编译跳回遍历开始时的opcode,一次遍历结束后跳到步骤2编译的opcode进行下次遍历
  • 设置步骤1、2两条opcode如果出错要跳到的opcode
  • 结束循环 编译ZEND_FE_FREE用于释放1中copy的数组或对象

结论分析

编译后的结构

【从入门到放弃-PHP】foreach 引用的坑
运行时步骤:

  • 执行ZEND_FE_RESET_R,过程上面已经介绍了;
  • 执行ZEND_FE_FETCH_R,此opcode的操作主要有三个:检查遍历位置是否到达末尾、将数组元素的value赋值给$value、将数组元素的key赋值给一个临时变量(注意与value不同);
  • 如果定义了key则执行ZEND_ASSIGN,将key的值从临时变量赋值给$key,否则跳到步骤(4);
  • 执行循环体的statement;
  • 执行ZEND_JMPNZ跳回步骤(2);
  • 遍历结束后执行ZEND_FE_FREE释放数组。

根据上面的分析可知:赋值的核心操作是ZEND_FE_FETCH_R和ZEND_FE_FETCH_RW

等价关系

最开始举的例子可等价于

$arr = [
    'jack'  => '20',
    'tom'   => '21',
    'marry' => '54',
    'less'  => '23'
];
$val = &$arr['jack'];
$val = &$arr['tom'];
$val = &$arr['marry'];
$val = &$arr['less'];
$val = $arr['jack'];
$val = $arr['tom'];
$val = $arr['marry'];
$val = $arr['less'];
print_r($arr);

等价于:

$arr = [
    'jack'  => '20',
    'tom'   => '21',
    'marry' => '54',
    'less'  => '23'
];
$val = &$arr['less']; (23)
$val = $arr['marry']; (54,并且此时因为引用 $arr['less']也变为54了)
$val = $arr['less']; (54)
print_r($arr);

【从入门到放弃-PHP】foreach 引用的坑

建议

因此 为了避免出现不必要的错误,建议在使用完&后,unset掉变量以取消对地址的引用

思维发散

针对以上情况,如果不取消对变量的引用,而是将数组赋值给一个新的变量再foreach。是否可行?

普通变量的引用

先看一段代码:

<?php
$str = '20';
$c = &$str;
$a = $str;
$c = 30;
var_dump($a);

【从入门到放弃-PHP】foreach 引用的坑

输出20 没有任何问题

数组整体引用

如果换成数组:

<?php
$arr = [
    'jack'  => '20',
    'tom'   => '21',
    'marry' => '54',
    'less'  => '23'
];
$b = &$arr;
$a = $arr;
$b['jack'] = 30;
var_dump($a);

【从入门到放弃-PHP】foreach 引用的坑
还是20 符合预期

数组元素引用

但如果这样呢:

<?php
$arr = [
    'jack'  => '20',
    'tom'   => '21',
    'marry' => '54',
    'less'  => '23'
];
$b = &$arr['jack'];
$a = $arr;
$b = 30;
var_dump($a)

【从入门到放弃-PHP】foreach 引用的坑

值却变成了30

xdebug_debug_zval调试

我们加上xdebug_debug_zval看看发生了什么

<?
$arr = [
    'jack'  => '20',
    'tom'   => '21',
    'marry' => '54',
    'less'  => '23'
];
$b = &$arr;
$a = $arr;
$b['jack'] = 30;
var_dump($a);
xdebug_debug_zval('a');
xdebug_debug_zval('arr');

【从入门到放弃-PHP】foreach 引用的坑

可以看出,直接引用数组, $b = &$arr,   $arr 的is_ref是1,refcount是2, 给$a = $arr时,发生分离,$a 与$arr指向不同的zval,$b 与 $arr指向相同的zval,因此给$b['jack'] = 30, $a的值不会发生改变
<?
$arr = [
    'jack'  => '20',
    'tom'   => '21',
    'marry' => '54',
    'less'  => '23'
];
$b = &$arr['jack'];
$a = $arr;
$b = 30;
var_dump($a);
xdebug_debug_zval('a');
xdebug_debug_zval('arr')

【从入门到放弃-PHP】foreach 引用的坑

可以看出,对数组中一个元素引用时,数组的is_ref是0,因为$a = $arr 因此refcount是2  ,指向同一个zval,改变$b的值时,因为$arr['jack']是一个引用,zval的值改变,$a和$arr的zval相同,$a['jack']也变为30

结论

同理可以回答最开始提出的疑问:如果我不取消对变量的引用,而是将数组赋值给一个新的变量再foreach。是否可行?答:不行。

<?
$arr = [
    'jack'  => '20',
    'tom'   => '21',
    'marry' => '54',
    'less'  => '23'
];
foreach ($arr as &$val) {
    echo $val;
}

$a = $arr;

foreach ($a as $val) {
    echo $val;
}
print_r($a);

【从入门到放弃-PHP】foreach 引用的坑

因为$arr与$a指向同一份zval,还是会出现$a['less'] = 54的结果。因此,在foreach使用完&后,还是unset掉变量 取消对地址的引用再进行下一步操作吧

参考文献:
[https://github.com/pangudashu/php7-internal/blob/master/4/loop.md
](https://github.com/pangudashu/php7-internal/blob/master/4/loop.md)

更多文章见:https://nc2era.com

上一篇:某OK最新版漏洞组合拳GETSHELL


下一篇:Linux 常用命令更新汇总