PHP内核之foreach

PHP8.0及之前的版本,foreach有一个行为很容易让我们的代码写错,甚至成为很多公司的面试题:

1
2
3
4
5
6
<?php

$array = [1, 2, 3];
foreach ($array as &$value) { /* ... */ }
foreach ($array as $value) { /* ... */ }
var_dump($array);

输出结果如下:

1
2
3
4
5
6
7
8
array(3) {
[0]=>
int(1)
[1]=>
int(2)
[2]=>
&int(2)
}

我们可以理解&$value是一个语法糖。

对于第一段foreach,等价于:

1
2
3
4
$array = [1, 2, 3];
$value =& $array[0];
$value =& $array[1];
$value =& $array[2];

这样,$value其实是一个引用了。它在执行完三条赋值语句之后,指向着数组的最后一个元素($array[2])。

对于第二段foreach,等价于:

1
2
3
4
// $value 仍然引用着 $array[2].
$value = $array[0]; // $array 此时是 [1, 2, 1].
$value = $array[1]; // $array 此时是 [1, 2, 2].
$value = $array[2]; // $array 此时是 [1, 2, 2].

因为$value始终引用着$array[2],所以,修改$value的同时,也会修改$array[2]。所以,最后$array[2]2,而不是3

为了解决这种犹如bug的特性,PHP提出了这个RFC

在这个RFC里面,当跳出foreach的时候,增加了一个opcode UNWRAP_REF来解开引用。我们可以来看看对于上面这段代码,RFC之后的opline是啥:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
L0003 0000 ASSIGN CV0($array) array(...)
L0004 0001 V3 = FE_RESET_RW CV0($array) 0004
L0004 0002 FE_FETCH_RW V3 CV1($value) 0004
L0004 0003 JMP 0002
L0004 0004 FE_FREE V3
L0004 0005 UNWRAP_REF CV1($value)
L0005 0006 V4 = FE_RESET_R CV0($array) 0009
L0005 0007 FE_FETCH_R V4 CV1($value) 0009
L0005 0008 JMP 0007
L0005 0009 FE_FREE V4
L0006 0010 INIT_FCALL 1 96 string("var_dump")
L0006 0011 SEND_VAR CV0($array) 1
L0006 0012 DO_ICALL
L0006 0013 RETURN int(1)

我们发现,当$value是引用的时候,会执行UNWRAP_REF来解开引用。这样,$value不再指向$array[2]了。

OK,到此为止,这个RFC其实已经介绍完了。但是,我还想多说一些容易犯错的犹如bug的特性:

1
2
3
4
5
6
7
<?php

$array = [1, 2, 3];
foreach ($array as $value) {
var_dump($value);
$array[count($array)] = 1;
}

执行这段代码,会输出如下内容:

1
2
3
int(1)
int(2)
int(3)

大家可能会奇怪为什么不会一直死循环的遍历$array,毕竟我们在不断的给$array末尾添加元素。

因为在遍历$array之前,会创建一个$array的副本(我们暂且叫做$array_2吧),是由上面的FE_RESET_R的实现了,但是此时只是增加对zend_array的引用计数而已,即:

graph TB
    1($array) --> 3[zend_array]
    2($array_2) --> 3[zend_array]

接着,实际上遍历的是这个看不见$array)2

当我们往$array[count($array)]位置写数据的时候,$array$array_2会发生写时分离。此时变成如下情况:

graph TB
    1($array) --> 3[zend_array_1]
    2($array_2) --> 4[zend_array_2]

所以,给$array赋值,并不会影响我们遍历$array_2。所以,当遍历3次的时候就会停下来。

我们再来看一段代码:

1
2
3
4
5
6
7
<?php

$array = [1, 2, 3];
foreach ($array as &$value) {
var_dump($value);
$array[count($array)] = 1;
}

这段代码就是死循环了,到最后我们会看到内存被耗尽:

1
Fatal error: Allowed memory size of 134217728 bytes exhausted

那么这是为什么呢?实际上,在遍历$array的时候,也会创建一个副本$array_2。但是,这里不是引用计数的关系了,而是引用的关系,也就意味着$array就是$array_2

graph TB
    1($array) --> 3[zend_ref]
    2($array_2) --> 3[zend_ref]
    3(zend_ref) --> 4[zend_array]

我们对$array的修改,会影响到$array_2,最终,foreach会死循环。

所以,&$value不但会让$value自身成为引用变量,还会让$array_2也变成引用。