乐趣区

PHP7源码分析奇妙的jsonencode

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

退出移动版