foreach使用引用時的一個坑-foreach原始碼分析

aloof_發表於2018-07-10

背景描述

先看一段程式碼。

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

想一下應該輸出什麼呢?

執行一下指令碼,真實結果和你想的是否一致呢?
image.png
在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

建立解決方案

根據已有目錄生成解決方案

建立成功後如下圖所示
image.png

原始碼追蹤

詞法分析階段

搜尋關鍵字foreach
image.png

可以在zend_language_parser.c 中看到, 語法解析時 foreach會當做T_FOREACH
image.png

在zend_language_parser.y可以看到語法解析的具體方式
image.png

ZEND_AST_FOREACH
image.png
image.png

查詢zend_ast_create
image.png

zend_ast.c中:
image.png

zend_ast_create 函式是建立一個抽象語法樹(abstract syntax tree)返回的zend_ast結構如下:
image.png

具體的賦值操作如下:
image.png

編譯生成opcode

接下來在zend_compile.c中根據抽象語法樹生成opcode:
image.png

通過上圖及語法解析的分析可知,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;"

image.png

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副本

image.png

如果$v是引用 則在迴圈前,將原$arr設定為引用型別 即:
foreach ($arr as $k => &$v) {
    echo $v;
}

image.png

image.png

  • 編譯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的陣列或物件

結論分析

編譯後的結構

image.png
執行時步驟:

  • 執行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);

image.png

建議

因此 為了避免出現不必要的錯誤,建議在使用完&後,unset掉變數以取消對地址的引用

思維發散

針對以上情況,如果不取消對變數的引用,而是將陣列賦值給一個新的變數再foreach。是否可行?

普通變數的引用

先看一段程式碼:

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

image.png

輸出20 沒有任何問題

陣列整體引用

如果換成陣列:

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

image.png
還是20 符合預期

陣列元素引用

但如果這樣呢:

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

image.png

值卻變成了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`);

image.png

可以看出,直接引用陣列, $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`)

image.png

可以看出,對陣列中一個元素引用時,陣列的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);

image.png

因為$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)


相關文章