baiyanjson_encode()的奇怪输出最近在工作中遇到一个现象:对于一个用数字索引的PHP数组,数组索引中的下标分别是连续的和不连续的,在这种情况下,在我们分别对它们进行json_encode()之后,我们得到了两个不同的输出结果。看下面这段代码:error_code=PHP_JSON_ERROR_INF_OR_NAN;smart_str_appendc(buf,'0');}休息;案例IS_STRING:返回php_json_escape_string(buf,Z_STRVAL_P(val),Z_STRLEN_P(val),选项,编码器);caseIS_OBJECT:if(instanceof_function(Z_OBJCE_P(val),php_json_serializable_ce)){returnphp_json_encode_serializable_object(buf,val,options,encoder);}}/*fallthrough--不可序列化对象*/caseIS_ARRAY:{/*避免在调用*jsonSerialize()方法时通过引用修改(和可能释放)数组。*/zvalzv;内部资源;ZVAL_COPY(&zv,val);res=php_json_encode_array(buf,&zv,选项,编码器);zval_ptr_dtor_nogc(&zv);返回资源;案例IS_REFERENCE:val=Z_REFVAL_P(val);再次转到;默认值:encoder->error_code=PHP_JSON_ERROR_UNSUPPORTED_TYPE;if(options&PHP_JSON_PARTIAL_OUTPUT_ON_ERROR){smart_str_appendl(buf,"null",4);返回n失败;}returnSUCCESS;}判断传入参数的数据类型我们现在关注IS_ARRAY的情况,先定义一个zval,然后将我们传入的PHP参数变量复制到新的zval中,避免修改原来传入的zval.然后如上gdb所示,调用了核心方法php_json_encode_array()。看方法名就知道应该是处理参数为数组的情况。让我们输入它。这里应该是具体的判断逻辑:进入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按照我们的预期执行了这个方法,我们继续输入:真相是php_json_determine_array_type()看方法名就知道,它最终判断输出类型是JSON数组还是对象。所以这里应该可以解释我们最初对索引非连续数组却输出JSON对象的疑惑。首先这里判断当前数组的元素个数是否大于0,大于0则需要判断。接着是最重要的判断:if(HT_IS_PACKED(myht)&&HT_IS_WITHOUT_HOLES(myht)){returnPHP_JSON_OUTPUT_ARRAY;}gdb直接跳过了这个if,说明这里的if判断条件为假。这if调用两个宏。下面分别来看一下:HT_IS_PACKED说到这个宏,就不得不说说PHP数组中PACKEDARRAY和HASHARRAY的概念。PHP数组的所有元素都存储在一个连续的内存空间中。这个内存空间的每个单元称为一个桶。每个数组元素都存储在这个桶中。当我们访问PHP数组中的元素时,我们实际上访问的是桶。在PHP源码中,使用一个arData指针变量指向这个内存空间,也就是这些buckets的起始地址。在C语言中,我们可以通过指针运算或者数组下标的方式获取一个内存空间的每个存储单元中的元素。那么对于索引为数字的PHP数组,可以方便的将PHP数组中数字索引对应的数据直接存入arData对应的bucket中。比如我们PHP数组中的$arr[0]可以直接放在底层的arData[0]桶中,而我们unset$arr[1],那么arData[1]桶中就没有值了。然后继续将$arr[2]放入arData[2]的桶中。这构成了一个打包数组。可以说,绝大多数索引为数字的PHP数组都是打包数组。那么,什么时候使用hash数组呢?其次,对于数字索引数组,如果只有一个数字键,其值很大,或者每个键编号之间的间隔很大,导致packed数组中间空桶过多,内存空间太大很浪费,最终会退化成哈希数组。当然,对于索引键不是数字的关联数组,必须使用哈希算法计算出它的桶位置,所以只能是哈希数组。虽然hash数组也需要维护一个索引列表来保证数组的顺序,参见:【PHP7源码学习】解析PHP数组的顺序,但是可能不会像packed数组那样浪费空间。这实际上是一个在空间复杂度和时间复杂度之间进行权衡的过程。打包数组可以节省内存并优化性能。packedarray和hasharray的具体结构这里不再赘述。我们知道我们示例中的数组实际上是一个打包数组,所以第一个宏返回true。HT_IS_WITHOUT_HOLES宏的字面意思是查看这个数组中是否有空闲的桶。看这个宏的实现:#defineHT_IS_WITHOUT_HOLES(ht)\((ht)->nNumUsed==(ht)->nNumOfElements)这里的nNumUsed是上次使用的桶的索引,nNumOfElements是元素个数阵列。这个宏检查两者是否相等。如果相等,自然可以确定桶中没有空闲的桶单元,否则有空闲的桶单元。比如我们unset$arr[1]后,元素个数会减1,nNumOfElements为2。再看nNumUsed,虽然有一个bucket是空的,但不影响上一个的indexnNumUsed桶。所以nNumUsed比nNumOfElements大1,两者不相等,最后返回false。由于没有进入这个if判断,也就意味着不能以JSON数组的形式进行编码,只能以JSON对象的形式进行编码。现在看这个方法的完整源码:staticintphp_json_determine_array_type(zval*val)/*{{{*/{inti;哈希表*myht=Z_ARRVAL_P(val);我=我的?zend_hash_num_elements(myht):0;if(i>0){zend_string*key;zend_ulong索引,idx;如果(HT_IS_PACKED(myht)&&HT_IS_WITHOUT_HOLES(myht)){returnPHP_JSON_OUTPUT_ARRAY;}idx=0;ZEND_HASH_FOREACH_KEY(myht,index,key){if(key){returnPHP_JSON_OUTPUT_OBJECT;}else{if(index!=idx){returnPHP_JSON_OUTPUT_OBJECT;}}idx++;}ZEND_HASH_FOREACH_END();}返回PHP_JSON_OUTPUT_ARRAY;我们可以看到,经过上面的if判断之后,又遍历了一遍这个数组的所有bucket。如果key字段有值,即是关联数组,则直接返回为JSON对象;否则,如果桶是标准不等于自增idx,同样返回JSON对象类型。很明显index为1的元素没有了,两者不相等,所以我们只能返回一个JSON对象,即PHP_JSON_OUTPUT_OBJECT。至此,我们已经完成了PHP代码运行结果在源码层面的验证。具体的编码过程不是本文的重点。有兴趣的同学可以深入研究后面的编码过程。想想你为什么要这样做?有改进的余地吗?很多同学可能会认为在json_encode()的判断中,如果bucket不连续,可以重新排列所有的数组索引,使bucket连续,而json_encode()之后,不管数值索引是否连续,all可以输出一个JSON数组,而且这些操作对开发者来说是透明的,这对我来说比较容易接受。虽然PHP开发者可能会认为重建索引会带来比较大的开销,进而采用这种退而求其次的方法,但是从开发者的角度,我想很多人并不想使用json_encode进行连续且有两种输出结果对于不连续的数组,但是我们希望PHP能够帮助我们重新排列数组的索引。开发者不想也不需要知道索引是否连续,如果不连续,json_encode()会输出什么奇怪的结果,会有什么风险。这样做大大增加了开发者的成本。另外,对于真正想让数值索引不连续的数组连续的数组,可以使用array_merge($arr)这个特殊函数。只传入一个参数,就可以得到重新排列后的连续数索引。
