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

背景描述

先看一段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php
/*$arr = [
'jack' => '20',
'tom' => '21',
'marry' => '54',
'less' => '23'
];
$b = &$arr['jack'];
$a = $arr;
$b = 30;
var_dump($a);
$str = '20';
$c = &$str;
$d = $str;
$c = 30;
var_dump($d);*/
$arr = [
'jack' => '20',
'tom' => '21',
'marry' => '54',
'less' => '23'
];
foreach ($arr as &$val) {
echo $val;
}
foreach ($arr as $val) {
echo $val;
}
print_r($arr);

想一下应该输出什么呢?

运行一下脚本,真实结果和你想的是否一致呢?

在foreach中使用了引用后再次foreach发现$arr[‘less’]的值变成了54,常规理解应该是23才对。

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

环境准备

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

下载Visual Studio 2017,并安装

下载地址:https://www.visualstudio.com/zh-hans/downloads/

下载php源码

http://cn2.php.net/distributions/php-7.0.27.tar.bz2

从文件夹创建解决方案

创建成功后如下图所示

源码追踪

先搜索关键字foreach

可以在zend_language_parser.c 中看到, 语法解析时 foreach会当做T_FOREACH

在zend_language_parser.y可以看到语法解析的具体方式

ZEND_AST_FOREACH

查找zend_ast_create

zend_ast.c中:

zend_ast_create 函数是创建一个抽象语法树(abstract syntax tree)返回的zend_ast结构如下:

具体的赋值操作如下:

接下来在zend_compile.c中根据抽象语法树生成opcode:

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

1
2
3
4
5
$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;”

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

1
2
3
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副本

如果$v是引用 则在循环前,将原$arr设置为引用类型 即:

1
2
3
foreach ($arr as $k => &$v) {
echo $v;
}

  • 编译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的数组或对象

    结论分析

    编译后的结构:

运行时步骤:

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

因此根据上面的分析:赋值的核心操作是ZEND_FE_FETCH_RW

上面的例子可等价于

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$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);

等价于:

1
2
3
4
5
6
7
8
9
10
$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);

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

思维发散:

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

先看一段代码:

1
2
3
4
5
6
<?php
$str = '20';
$c = &$str;
$a = $str;
$c = 30;
var_dump($a);

输出20 没有任何问题
如果换成数组:

1
2
3
4
5
6
7
8
9
10
11
<?php
$arr = [
'jack' => '20',
'tom' => '21',
'marry' => '54',
'less' => '23'
];
$b = &$arr;
$a = $arr;
$b['jack'] = 30;
var_dump($a);

还是20 符合预期
但如果这样呢:

1
2
3
4
5
6
7
8
9
10
11
<?php
$arr = [
'jack' => '20',
'tom' => '21',
'marry' => '54',
'less' => '23'
];
$b = &$arr['jack'];
$a = $arr;
$b = 30;
var_dump($a)

值却变成了30
我们加上xdebug_debug_zval看看发生了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
$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');

可以看出,直接引用数组, $b = &$arr, $arr 的is_ref是1,refcount是2, 给$a = $arr时,发生分离,$a 与$arr指向不同的zval,$b 与 $arr指向相同的zval,因此给$b[‘jack’] = 30, $a的值不会发生改变

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
$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')

可以看出,对数组中一个元素引用时,数组的is_ref是0,因为$a = $arr 因此refcount是2 ,指向同一个zval,改变$b的值时,因为$arr[‘jack’]是一个引用,zval的值改变,$a和$arr的zval相同,$a[‘jack’]也变为30
同理可以回答最开始提出的疑问:如果我不取消对变量的引用,而是将数组赋值给一个新的变量再foreach。是否可行?答:不行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
$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);

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

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