摘要:本文首發(fā)于作者基于中的在中,的作用是將一個一維數(shù)組的值轉化為字符串。為了能通過修改代碼來看效果,將函數(shù)復制到擴展文件中,并將其命名為源碼內容省略在擴展中新增一個擴展函數(shù)因為擴展的編譯以及引入前面的已經提及。
PHP 中的 implode本文首發(fā)于 https://github.com/suhanyujie...*
作者:suhanyujie
基于 PHP 7.3.3
在 PHP 中,implode 的作用是:將一個一維數(shù)組的值轉化為字符串。記住一維數(shù)組,如果是多維的,會發(fā)生什么呢?在本篇分析中,會有所探討。
事實上,通過官方的文檔可以知道,implode 有兩種用法,通過函數(shù)簽名可以看得出來:
// 方法1 implode ( string $glue , array $pieces ) : string // 方法2 implode ( array $pieces ) : string
因為,在不傳 glue 的時候,內部實現(xiàn)會默認空字符串。
通過一個簡單的示例可以看出:
$pieces = [ 123, ",是一個", "number!", ]; $str1 = implode($pieces); $str2 = implode("", $pieces); var_dump($str1, $str2); /* string(20) "123,是一個number!" string(20) "123,是一個number!" */implode 源碼實現(xiàn)
通過搜索關鍵字 PHP_FUNCTION(implode) 可以找到,該函數(shù)定義于 extstandardstring.c 文件中的 1288 行
一開始的幾行是參數(shù)聲明相關的信息。其中 *arg2 是用于接收 pieces 參數(shù)的指針。
在下方對 arg2 的判斷中,如果 arg2 為空,則表示沒有傳 pieces 對應的值
if (arg2 == NULL) { if (Z_TYPE_P(arg1) != IS_ARRAY) { php_error_docref(NULL, E_WARNING, "Argument must be an array"); return; } glue = ZSTR_EMPTY_ALLOC(); tmp_glue = NULL; pieces = arg1; } else { if (Z_TYPE_P(arg1) == IS_ARRAY) { glue = zval_get_tmp_string(arg2, &tmp_glue); pieces = arg1; } else if (Z_TYPE_P(arg2) == IS_ARRAY) { glue = zval_get_tmp_string(arg1, &tmp_glue); pieces = arg2; } else { php_error_docref(NULL, E_WARNING, "Invalid arguments passed"); return; } }不傳遞 pieces 參數(shù)
在不傳遞 pieces 參數(shù)的判斷中,即 arg2 == NULL,主要是對參數(shù)的一些處理
將 glue 初始化為空字符串,并將傳進來的唯一的參數(shù),賦值給 pieces 變量,接著就調用 php_implode(glue, pieces, return_value);
十分關鍵的 php_implode無論有沒有傳遞 pieces 參數(shù),在處理好參數(shù)后,最終都會調用 PHPAPI 的相關函數(shù) php_implode,可見,關鍵邏輯都是在這個函數(shù)中實現(xiàn)的,那么我們深入其中看一看它
在調用 php_implode 時,出現(xiàn)了一個看起來沒有被聲明的變量 return_value。沒錯,它似乎就是憑空出現(xiàn)的
通過谷歌搜索 PHP源碼中 return_value,找到了答案。
原來,這個變量是伴隨著宏 PHP_FUNCTION 而出現(xiàn)的,而此處 implode 的實現(xiàn)就是通過 PHP_FUNCTION(implode) 來聲明的。而 PHP_FUNCTION 的定義是:
#define PHP_FUNCTION ZEND_FUNCTION // 對應的 ZEND_FUNCTION 定義如下 #define ZEND_FUNCTION(name) ZEND_NAMED_FUNCTION(ZEND_FN(name)) // 對應的 ZEND_NAMED_FUNCTION 定義如下 #define ZEND_NAMED_FUNCTION(name) void ZEND_FASTCALL name(INTERNAL_FUNCTION_PARAMETERS) // 對應的 ZEND_FN 定義如下 #define ZEND_FN(name) zif_##name // 對應的 ZEND_FASTCALL 定義如下 # define ZEND_FASTCALL __attribute__((fastcall))
(關于雙井號,它起連接符的作用,可以參考這里了解)
在被預處理后,它的樣子類似于下方所示:
void zif_implode(int ht, zval *return_value, zval **return_value_ptr, zval *this_ptr, int return_value_used TSRMLS_DC)
也就是說 return_value 是作為整個 implode 擴展函數(shù)定義的一個形參
在 php_implode 的定義中,一開始,先定義了一些即將用到的變量,隨后使用 ALLOCA_FLAG(use_heap) 進行標識,如果申請內存,則申請的是堆內存
通過 numelems = zend_hash_num_elements(Z_ARRVAL_P(pieces)); 獲取 pieces 參數(shù)的單元數(shù)量,如果是空數(shù)組,則直接返回空字符串
此處還有判斷,如果數(shù)組單元數(shù)為 1,則直接將唯一的單元作為字符串返回。
最后是處理多數(shù)組單元的情況,因為前面標識過,若申請內存則申請的是堆內存,堆內存相對于棧來講,效率比較低,所以只在非用不可的情形下,才會申請堆內存,那此處的情形就是多單元數(shù)組的情況。
隨后,針對 pieces 循環(huán),獲取其值進行拼接,在源碼中的 foreach 循環(huán)是固定結構,如下:
ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zend_array), tmp) { // ... } ZEND_HASH_FOREACH_END();
這種常用寫法我覺得,在編寫 PHP 擴展中是必不可少的吧。雖然我還沒有編寫過任何一個可用于生產環(huán)境的 PHP 擴展。但我正努力朝那個方向走呢!
在循環(huán)內,對數(shù)組單元分為三類:
字符串
整形數(shù)據(jù)
其它
事實上,在循環(huán)開始之前,源碼中,先申請了一塊內存,用于存放下面的結構體,并且個數(shù)恰好是 pieces 數(shù)組單元的個數(shù)。
struct { zend_string *str; zend_long lval; } *strings, *ptr;
可以看到,結構體成員包含 zend 字符串以及 zend 整形數(shù)據(jù)。這個結構體的出現(xiàn),恰好是為了存放數(shù)組單元中的 zend 字符串/zend 整形數(shù)據(jù)。
字符串先假設,pieces 數(shù)組單元中,都是字符串類型,此時循環(huán)中執(zhí)行的邏輯就是:
// tmp 是循環(huán)中的單元值 ptr->str = Z_STR_P(tmp); len += ZSTR_LEN(ptr->str); ptr->lval = 0; ptr++;
其中,tmp 是循環(huán)中的單元值。每經歷一次循環(huán),會將單元值放入結構體中,隨后進行指針 +1 運算,指針就指向存儲下一個結構體數(shù)據(jù)的地址:
并且,在這期間,統(tǒng)計出了字符串的總長度 len += ZSTR_LEN(ptr->str);
整數(shù)類型以上,討論了數(shù)組單元中是字符串的情況。接下來看看,如果數(shù)組單元的類型是數(shù)值類型時會發(fā)生什么?
判斷一個變量是否是數(shù)值類型(其實是 zend_long),通用方法是:Z_TYPE_P(tmp) == IS_LONG。一旦知道當前的數(shù)據(jù)類型是 zend_long,則將其賦值給 ptr 的 lval 結構體成員。然后 ptr 指針后移一個單位長度。
但是,我們知道我們不能像獲取 zend_string 的長度一樣去獲取 zend_long 的字符長度。如果是 zend_string,則可以通過 len += ZSTR_LEN(val); 的方式獲取其字符長度。對于 zend_long,有什么好的方法呢?
在源碼中是通過對 10 做除法運算,得出結果的一部分,再慢慢的累加其長度:
while (val) { val /= 10; len++; }
如果是負數(shù)呢?沒有什么特別的辦法,直接判斷處理:
if (val <= 0) { len++; }字符串的處理和拷貝
循環(huán)結束后,ptr 就是指向這段內存的尾部的指針。
然后,申請了一段內存:str = zend_string_safe_alloc(numelems - 1, ZSTR_LEN(glue), len, 0);,用于存放單元字符串總長度加上連接字符的總長度,即 (n-1)glue + len。因為 n 個數(shù)組單元,只需要 n-1 個 glue 字符串。然后,將這段內存的尾地址,賦值給 cptr,為什么要指向尾部呢?看下一部分,你就會明白了。
接下來,需要循環(huán)取出存放在 ptr 中的字符。我們知道,ptr 此時是所處內存區(qū)域的尾部,為了能有序展示連接的字符串,源碼中,是從后向前循環(huán)處理。這也就是為什么需要把 cptr 指向所在內存區(qū)域的尾部的原因。
進入循環(huán),先進行 ptr--;,然后針對 ptr->str 的判斷 if (EXPECTED(ptr->str)),看了一下此處的 EXPECTED 的作用,可以參考這里??梢院唵蔚膶⑵淅斫庖环N匯編層面的優(yōu)化,當實際執(zhí)行的情況更偏向于當前條件下的分支而非 else 的分支時,就用 EXPECTED 宏將其包裝起來:EXPECTED(ptr->str)。我敢說,當你調用 implode 傳遞的數(shù)組中都是數(shù)字而非字符串,那么這里的 EXPECTED 作用就會失效。
接下來的兩行是比較核心的:
cptr -= ZSTR_LEN(ptr->str); memcpy(cptr, ZSTR_VAL(ptr->str), ZSTR_LEN(ptr->str));
cptr 的指針前移一個數(shù)組單元字符的長度,然后將 ptr->str (某數(shù)組單元的值)通過 c 標準庫函數(shù) memcpy 拷貝到 cptr 內存空間中。
當 ptr == strings 滿足時,意味著 ptr 不再有可被復制的字符串/數(shù)字。因為 strings 是 ptr 所在區(qū)域的首地址。
通過上面,已經成功將一個數(shù)組單元的字符串拷貝到 cptr 對應的內存區(qū)域中,接下來如何處理 glue 呢?
只需要像處理 ptr->str 一樣處理 glue 即可。至少源碼中是這么做的。
代碼中有一段是:*cptr = 0,它的作用相當于賦值空字符串。
cptr 繼續(xù)前移 glue 的長度,然后,將 glue 字符串拷貝到 cptr 對應的內存區(qū)域中。沒錯,還是用 memcpy 函數(shù)。
到這里,第一次循環(huán)結束了。我應該不需要像實際循環(huán)中那樣描述這里的循環(huán)吧?相信優(yōu)秀的你,是完全可以參考上方的描述腦補出來的 ^^
當然,處理返回的兩句還是要提一下:
free_alloca(strings, use_heap); RETURN_NEW_STR(str);
strings 的那一片內存空間只是存儲臨時值的,因此函數(shù)結束了,就必須跟 strings 說再見。我們知道 c 語言是手動管理內存的,沒有 GC,你要顯示的釋放內存,即 free_alloca(strings, use_heap);。
在上面的描述中,我們只講到了 cptr,但這里的返回值卻是 str。
不用懷疑,這里是對的,我們所講的 cptr 那一片內存區(qū)域的首地址就是 str。并通過宏 RETURN_NEW_STR 會將最終的返回值寫入 return_value 中
實踐為了可能更加清晰 implode 源碼中代碼運行時的情況,接下來,我們通過 PHP 擴展的方式對其進行 debug。在這個過程中的代碼,我都放在 GitHub 的倉庫中,分支名是 debug/implode,可自行下載運行,看看效果。
新建 PHP 擴展模板的操作,可以參考這里。請確保操作完里面描述的步驟。
接下來,主要針對 su_dd.c 文件修改代碼。為了能通過修改代碼來看效果,將 php_implode 函數(shù)復制到擴展文件中,并將其命名為 su_php_implode:
static void su_php_implode(const zend_string *glue, zval *pieces, zval *return_value) { // 源碼內容省略 }
在擴展中新增一個擴展函數(shù) su_test:
PHP_FUNCTION(su_test) { zval tmp; zend_string *str, *glue, *tmp_glue; zval *arg1, *arg2 = NULL, *pieces; ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_ZVAL(arg1) Z_PARAM_OPTIONAL Z_PARAM_ZVAL(arg2) ZEND_PARSE_PARAMETERS_END(); glue = zval_get_tmp_string(arg1, &tmp_glue); pieces = arg2; su_php_implode(glue, pieces, return_value); }
因為擴展的編譯以及引入,前面的已經提及。因此,此時只需編寫 PHP 代碼進行調用:
// t1.php $res = su_test("-", [ 2019, "01", "01", ]); var_dump($res);
PHP 運行該腳本,輸出:string(10) "2019-01-01",這意味著,你已經成功編寫了一個擴展函數(shù)。別急,這只是邁出了第一步,別忘記我們的目標:通過調試來學習 implode 源碼。
接下來,我們通過 gdb 工具,調試以上 PHP 代碼在源碼層面的運行。為了防止初學者不會用 gdb,這里就繁瑣的寫出這個過程。如果沒有安裝 gdb,請自行谷歌。
先進入 PHP 腳本所在路徑。命令行下:
gdb php b zval_get_tmp_string r t1.php
b 即 break,表示打一個斷點
r 即 run,表示運行腳本
s 即 step,表示一步一步調試,遇到方法調用,會進入方法內部單步調試
n 即 next,表示一行一行調試。遇到方法,則調試直接略過直接執(zhí)行返回,調試不會進入其內部。
p 即 print,表示打印當前作用域中的一個變量
當運行完 r t1.php,則會定位到第一個斷點對應的行,顯示如下:
Breakpoint 1, zif_su_test (execute_data=0x7ffff1a1d0c0, return_value=0x7ffff1a1d090) at /home/www/clang/php-7.3.3/ext/su_dd/su_dd.c:179 179 glue = zval_get_tmp_string(arg1, &tmp_glue);
此時,按下 n,顯示如下:
184 su_php_implode(glue, pieces, return_value);
此時,當前的作用域中存在變量:glue,pieces,return_value
我們可以通過 gdb 調試,查看 pieces 的值。先使用命令:p pieces,此時在終端會顯示類似于如下內容:
$1 = (zval *) 0x7ffff1a1d120
表明 pieces 是一個 zval 類型的指針,0x7ffff1a1d120 是其地址,當然,你運行的時候對應的也是一個地址,只不過跟我的這個會不太一樣。
我們繼續(xù)使用 p 去打印存儲于改地址的變量內容:p *$1,$1 可以認為是一個臨時變量名,* 是取值運算符。運行完后,此時顯示如下:
(gdb) p *$1 $2 = {value = {lval = 140737247576960, dval = 6.9533439118030153e-310, counted = 0x7ffff1a60380, str = 0x7ffff1a60380, arr = 0x7ffff1a60380, obj = 0x7ffff1a60380, res = 0x7ffff1a60380, ref = 0x7ffff1a60380, ast = 0x7ffff1a60380, zv = 0x7ffff1a60380, ptr = 0x7ffff1a60380, ce = 0x7ffff1a60380, func = 0x7ffff1a60380, ww = {w1 = 4054188928, w2 = 32767}}, u1 = {v = {type = 7 "a", type_flags = 1 "