baiyanphp
最近在工做中碰到了一个现象:对于一个以数字为索引的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
接下来咱们经过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调用了两个宏。咱们分别来看一下:学习
讲到这个宏,就不得不讲一下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。
这个宏从字面意思上看,就是看这个数组有没有空闲的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)的特异功能。你能够只传一个参数进去,就能够获得从新排列的连续的数字索引啦。