3

baiyan

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()之后,并不会对数组的数字索引进行重新组织,导致该索引数组的下标不再连续。运行这段代码,输出结果如下:

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

我们可以看到,在数组的数字索引连续的情况下,输出了一个json数组;而在数字索引不连续的情况下,输出了一个json对象,而并不是我们预期json数组。那么,在PHP源码层面中是如何实现的?PHP底层如何判断数组是否连续?这种处理方式是否合理呢?

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()方法代码如下:

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)的特异功能。你可以只传一个参数进去,就可以得到重新排列的连续的数字索引啦。


NoSay
449 声望544 粉丝