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