【PHP7源碼分析】奇妙的json_encode()

baiyanphp

json_encode()的奇怪輸出

最近在工做中碰到了一個現象:對於一個以數字爲索引的PHP數組,在數組索引下標分別爲連續和不連續的狀況下,咱們在分別對其進行json_encode()以後,獲得了兩種不同的輸出結果。看下面一段代碼:算法

<?php
$arr = [4, 5, 6];
echo json_encode($arr);
unset($arr[1]);
echo PHP_EOL;
echo json_encode($arr);

咱們首先初始化一個數組,而後將其索引位置爲1的元素去掉。因爲PHP在unset()以後,並不會對數組的數字索引進行從新組織,致使該索引數組的下標再也不連續。運行這段代碼,輸出結果以下:json

[4,5,6]
{"0":4,"2":6}

咱們能夠看到,在數組的數字索引連續的狀況下,輸出了一個json數組;而在數字索引不連續的狀況下,輸出了一個json對象,而並非咱們預期json數組。那麼,在PHP源碼層面中是如何實現的?PHP底層如何判斷數組是否連續?這種處理方式是否合理呢?segmentfault

json_encode()源碼分析

接下來咱們經過gdb來看一下PHP源碼層面中,json_encode()對數組類型的編碼處理。首先找到json_encode()函數的源碼實現:數組

static PHP_FUNCTION(json_encode)
{
    ......
    // 初始化encoder結構體(在具體encode階段纔會用到)
    php_json_encode_init(&encoder);
    // 執行json_encode()邏輯
    php_json_encode_zval(&buf, parameter, (int)options, &encoder);
    ......
}

這個php_json_encode_zval()函數是json_encode()的核心實現,咱們啓動gdb並在這裏打一個斷點:

運行上面這段代碼,咱們發現已經執行到了斷點處。使用n命令繼續往下執行:

首先進入了一個switch條件選擇,它會判斷PHP變量的類型,而後執行相應的case。咱們這裏是數組類型,用宏IS_ARRAY表示。完整的php_json_encode_zval()方法代碼以下:app

int php_json_encode_zval(smart_str *buf, zval *val, int options, php_json_encoder *encoder) /* {{{ */
{
again:
    switch (Z_TYPE_P(val))
    {
        case IS_NULL:
            smart_str_appendl(buf, "null", 4);
            break;

        case IS_TRUE:
            smart_str_appendl(buf, "true", 4);
            break;
        case IS_FALSE:
            smart_str_appendl(buf, "false", 5);
            break;

        case IS_LONG:
            smart_str_append_long(buf, Z_LVAL_P(val));
            break;

        case IS_DOUBLE:
            if (php_json_is_valid_double(Z_DVAL_P(val))) {
                php_json_encode_double(buf, Z_DVAL_P(val), options);
            } else {
                encoder->error_code = PHP_JSON_ERROR_INF_OR_NAN;
                smart_str_appendc(buf, '0');
            }
            break;

        case IS_STRING:
            return php_json_escape_string(buf, Z_STRVAL_P(val), Z_STRLEN_P(val), options, encoder);

        case IS_OBJECT:
            if (instanceof_function(Z_OBJCE_P(val), php_json_serializable_ce)) {
                return php_json_encode_serializable_object(buf, val, options, encoder);
            }
            /* fallthrough -- Non-serializable object */
        case IS_ARRAY: {
            /* Avoid modifications (and potential freeing) of the array through a reference when a
             * jsonSerialize() method is invoked. */
            zval zv;
            int res;
            ZVAL_COPY(&zv, val);
            res = php_json_encode_array(buf, &zv, options, encoder);
            zval_ptr_dtor_nogc(&zv);
            return res;
        }

        case IS_REFERENCE:
            val = Z_REFVAL_P(val);
            goto again;

        default:
            encoder->error_code = PHP_JSON_ERROR_UNSUPPORTED_TYPE;
            if (options & PHP_JSON_PARTIAL_OUTPUT_ON_ERROR) {
                smart_str_appendl(buf, "null", 4);
            }
            return FAILURE;
    }

    return SUCCESS;
}

判斷傳入參數的數據類型

咱們如今關注IS_ARRAY這個case。他首先定義了一個zval,而後將咱們傳入的PHP參數變量拷貝到新的zval中,避免修改咱們本來傳入的zval。接着,正如咱們上圖gdb中所示,php_json_encode_array()這個核心方法被調用,看方法名,咱們就知道應該是專門處理參數爲數組的狀況,咱們s進去,這裏應該就是具體的判斷邏輯了:

進入到php_json_encode_array()函數中,這裏又判斷了一次zval的類型是否爲IS_ARRAY。爲何要這樣作呢?這裏是由於當變量爲對象的時候,即IS_OBJECT,也會調用這個方法來進行encode處理。而後進入到這句最重要的判斷邏輯:函數

r = (options & PHP_JSON_FORCE_OBJECT) ? PHP_JSON_OUTPUT_OBJECT : php_json_determine_array_type(val);

判斷調用者是否傳了可選參數

咱們知道,json_encode()函數有一個可選參數,來強制指定編碼後返回的json類型,或者一些附加的編碼選項等等。下面是json_encode()的官方文檔:

關注這個JSON_FORCE_OBJECT,是指將索引數組也按照JSON對象的形式輸出而非一個JSON數組。這個判斷邏輯表示,若是用戶調用方法時強制指定了option爲PHP_JSON_FORCE_OBJECT,那麼該三元運算符的返回值r將被置爲PHP_JSON_OUTPUT_OBJECT宏的值,爲常量1。不然若是用戶沒有顯式指定輸出的格式爲JSON對象,就要進一步調用php_json_determine_array_type()方法來作最終的肯定。因爲咱們並無傳參數進去,因此咱們就對應這種狀況。果真,咱們的gdb按照咱們的預期執行到了該方法,咱們繼續s進去:
源碼分析

真相大白

php_json_determine_array_type()看這個方法名,就知道它最終決定了輸出的類型是JSON數組仍是對象。那麼這裏應該就可以解釋咱們最初對於索引非連續數組卻輸出JSON對象的疑問了。首先這裏判斷了當前數組的元素個數是否大於0,若是大於0才須要進行判斷。而後進行到了一句最最重要的判斷:性能

if (HT_IS_PACKED(myht) && HT_IS_WITHOUT_HOLES(myht)) {
    return PHP_JSON_OUTPUT_ARRAY;
}

gdb中直接跳過了這個if,說明這裏的if判斷條件爲false。這個if調用了兩個宏。咱們分別來看一下:學習

HT_IS_PACKED

講到這個宏,就不得不講一下PHP數組中的PACKED ARRAY和HASH ARRAY的概念。
PHP數組的全部元素,均存儲在一塊連續的內存空間中。這塊內存空間的每個單元,叫作bucket。每個數組元素都存放在這個bucket中。咱們訪問PHP數組中的元素,其實就是訪問bucket。在PHP源碼中,使用一個arData指針變量,指向這塊內存空間,即這些bucket的起始地址。在C語言中,咱們能夠經過指針運算或數組下標兩種形式來拿到一塊內存空間每一個存儲單元中的元素。那麼對於索引爲數字的PHP數組,能夠方便地將PHP數組中數字索引所對應的數據,直接存放到arData對應的bucket中。舉個例子,咱們PHP數組中的$arr[0],就能夠直接放到底層arData[0]的bucket中,咱們unset掉了$arr[1],因此arData[1]的bucket中沒有值,而後繼續將$arr[2]放到arData[2]的bucket中。這樣就構成了一個packed array。能夠說,絕大多數的索引爲數字的PHP數組都是packed array。那麼,hash array在何時使用呢?
接着數字索引數組來講,若是隻有一個數字key且其這個值較大,或者每一個key數字之間的間隔較大,致使packed array中間空的bucket過多,內存空間過於浪費,最終仍是會退化成hash array。固然對於索引key不是數字的關聯數組,必須用hash算法計算出它所在的bucket位置,那麼只能是hash array。雖然hash array也須要維護一個索引列表,確保數組的有序性,見:【PHP7源碼學習】剖析PHP數組的有序性,可是可能沒有packed array浪費的空間多。這裏其實就是對空間複雜度和時間複雜度做出權衡取捨的一個過程。packed array可以節省內存,優化性能。具體的packed array和hash array的結構這裏就不展開講了。
咱們知道,咱們示例中的數組,其實就是一個packed array,因此第一個宏返回true。

HT_IS_WITHOUT_HOLES

這個宏從字面意思上看,就是看這個數組有沒有空閒的bucket,看下這個宏的實現:

#define HT_IS_WITHOUT_HOLES(ht) \
    ((ht)->nNumUsed == (ht)->nNumOfElements)

這裏nNumUsed爲最後一個使用的bucket的索引,而nNumOfElements是數組中元素的數量。這個宏判斷兩者是否相等。若是相等,那麼天然可以肯定bucket中沒有空閒的bucket單元,不然就存在空閒的bucket單元。舉個例子,在咱們unset掉$arr[1]以後,元素的數量要減小一個,nNumOfElements爲2。再看nNumUsed,雖然bucket有一個爲空,可是並不影響最後一個bucket的索引nNumUsed。因此nNumUsed要比nNumOfElements大1,兩者並不相等,最終返回false。

既然沒有進這個if判斷,就說明不可以以JSON數組的形式來編碼了,只可以以JSON對象來進行編碼。如今看一下該方法完整的源碼:

static int php_json_determine_array_type(zval *val) /* {{{ */
{
    int i;
    HashTable *myht = Z_ARRVAL_P(val);

    i = myht ? zend_hash_num_elements(myht) : 0;
    if (i > 0) {
        zend_string *key;
        zend_ulong index, idx;

        if (HT_IS_PACKED(myht) && HT_IS_WITHOUT_HOLES(myht)) {
            return PHP_JSON_OUTPUT_ARRAY;
        }

        idx = 0;
        ZEND_HASH_FOREACH_KEY(myht, index, key) {
            if (key) {
                return PHP_JSON_OUTPUT_OBJECT;
            } else {
                if (index != idx) {
                    return PHP_JSON_OUTPUT_OBJECT;
                }
            }
            idx++;
        } ZEND_HASH_FOREACH_END();
    }

    return PHP_JSON_OUTPUT_ARRAY;
}

可是,到底是在哪裏明確地告訴咱們,須要返回一個JSON對象的呢?
咱們看到,在沒有進上述的if判斷以後,又從新遍歷了一遍這個數組的全部bucket,若是key字段有值,即它是一個關聯數組,就直接以JSON對象的形式返回;不然若是bucket下標不等於自增的idx,也返回JSON對象類型。顯然咱們這裏的index下標爲1的元素已經沒有了,兩者並不相等,因此就只能返回一個JSON對象了,即PHP_JSON_OUTPUT_OBJECT。到此爲止,咱們就完成了在源碼層面,對PHP代碼運行結果的驗證。具體編碼的過程,不是本文敘述的重點,有興趣的同窗能夠深刻研究一下後續的編碼過程。

思考

那麼爲何要這樣作呢?是否有改進的空間呢?不少同窗可能會想到,在json_encode()的判斷中,若是bucket之間不連續,能夠將其全部的數組索引從新排列,使bucket連續,進而在json_encode()以後,無論數字索引連續與否,都可以輸出一個JSON數組,而這些操做對開發者而言是透明的,這種處理方式更可以讓我接受。雖然PHP開發者可能認爲重建索引會帶來比較大的開銷,進而採用了這種退而求其次的方法,可是從開發者的角度看,我以爲不少人都不但願在json_encode以後,對於連續和不連續的數組有兩種輸出結果,而是但願PHP幫助咱們從新排列數組的索引。開發者不想、也不須要知道這個索引是否是連續,也不須要知道若是不連續,json_encode()要輸出什麼奇怪的結果、會有什麼風險。這樣作,大大增長了開發者的成本。另外,對於真正想讓數組數字索引不連續的數組變爲連續,可使用array_merge($arr)的特異功能。你能夠只傳一個參數進去,就能夠獲得從新排列的連續的數字索引啦。

相關文章
相關標籤/搜索