PHP Arrays

PHP 数组是一种复杂的数据结构。它们可以表示一个有序的 map,其中的整数和字符串键指向任何 PHP 值(zval)。

在内部,PHP 数组是作为一种采用型(adoptive)数据结构来实现的,运行时可能会根据存储的数据改变其内部表示和行为。例如,如果脚本在数组中存储的元素有排序和相近的数字索引(如 [0=>1,1=>2,3=>3]),那么它将表示为普通数组。我们将此类数组命名为打包(packed)数组。打包数组的元素通过偏移量访问,速度与 C 数组接近。一旦一个 PHP 数组获得了带有字符串(或 "坏的" 数字)键(例如 [0=>1,1=>3,3=>3,"ops"=>4])的新元素,它就会自动转换成一个真正的哈希表,并解决冲突。

下面的示例解释了在 PHP 中如何对键进行逻辑组织:

  • $a = [1, 2, 3]; // packed array

    image 2023 11 14 12 46 31 395
  • $a = [0=>1, 1=>2, 3=>3]; //packed array with a "hole"

    image 2023 11 14 12 47 09 987
  • $a = [0=>1, 2=>3, 1=>2]; // hash table (because of ordering) without conflicts

    image 2023 11 14 12 48 20 397
  • $a = [0=>1, 1=>2, 256 =>3]; // hash table (because of density) with conflicts

    image 2023 11 14 12 49 01 644
  • $a = [0=>1, 1=>2, "x"=>3]; // hash table (because of string keys)

    image 2023 11 14 12 50 00 906

数值总是以有序的普通数组形式存储。它们可以简单地自上而下迭代,也可以反向迭代。实际上,这是一个 Buckets 数组,内嵌 zvals 和一些附加信息。

在打包数组中,值索引与数字键相同。偏移量的计算方法是 key * sizeof(Bucket)

HashTables 使用额外的索引数组(Hash)。它将为数字或字符串键值计算的哈希函数值重新映射到值索引。当一些数组键具有相同的哈希函数值时,可能会发生碰撞。这些碰撞可以通过具有相同哈希值的元素的链接列表来解决。

内部 PHP 数组表示

现在,让我们来看看 PHP 内部的数组表示法。IS_ARRAY 类型的 zval 的值域保存了一个指向 zend_array 结构的指针。它 "继承" 自 zend_refcounted,后者定义了第一个 64 位字的引用计数器格式。

其他字段是 zend_arrayHashTable 特有的。其中最重要的是 arData,它是一个指向依赖数据结构的指针。实际上,它们是作为一个内存块分配的两个数据结构。

arData 指向的地址上方是 "哈希" 部分(如上所述)。同一地址的下方是 "有序值" 部分。哈希部分是一个由 32 位 Bucket 偏移量组成的向下数组,以散列值为索引。打包数组 可能会漏掉这一部分,在这种情况下,可以直接通过数字索引访问 Bucket。

有序值 部分是一个 Bucket 数组。每个 Bucket 包含嵌入式 zval,字符串键由指向 zend_string 的指针表示(对于数字键,它为 NULL),以及数字键(或字符串键的字符串 hash_value)。Zvlas 中的保留空间用于组织冲突元素的链表。它包含下一个具有相同 hash_value 的元素的索引。

image 2023 11 14 12 56 50 794

从历史上看,PHP 5 对数组和 HashTable 结构有明确的区分(HashTable 不包含引用计数器)。然而,在 PHP 7 中,这些结构被合并并成为别名。

PHP 数组可以是不可变的。这与内部字符串非常相似。这种数组不需要引用计数,其行为与标量值相同。

PHP 数组 API

使用以下宏从 zval 检索 zend_array (也可使用 _P 后缀表示指向 zval 的指针):

  • Z_ARR(zv) – 返回 zval 的 zend_array 值(类型必须是 IS_ARRAY)。

  • Z_ARRVAL(zv) – Z_ARR(zv) 的历史别名。

使用以下宏和函数来处理 zval 表示的数组:

  • ZVAL_ARR(zv, arr) - 使用给定的 zend_array 初始化 PHP 数组 zval。

  • array_init(zv) - 创建一个新的空数组。

  • array_init_size(zv, count) - 创建一个新的空的 PHP 数组,并为 "count" 元素保留内存。

  • add_next_index_null(zval *arr) - 用下一个索引插入新的 NULL 元素。

  • add_next_index_bool(zval *ar, int b) - 插入新的 IS_BOOL 元素,其值为 "b",并插入下一个索引。

  • add_next_index_long(zval *arr, zend_long val) - 插入新的 IS_LONG 元素,其值为 "val",并插入下一个索引。

  • add_next_index_double(zval *arr, double val) - 插入值为 "val" 的新 IS_DOUBLE 元素和下一个索引。

  • add_next_index_str(zval *arr, zend_string *zstr) - 插入值为 "zstr" 的新 IS_DOUBLE 元素和下一个索引。

  • add_next_index_string(zval *arr, char *cstr) - 从 0 结尾的 C 字符串 "cstr" 创建 PHP 字符串,并插入下一个索引。

  • add_next_index_stringl(zval *arr, char *cstr, size_t len) - 从长度为 "len" 的 C 字符串 "cstr" 创建 PHP 字符串,并插入下一个索引。

  • add_next_index_zval(zval *arr, zval *val) - 将给定的 zval 以下一个索引插入数组。注意,插入值的引用计数器不会改变。你应该自己关注引用计数(例如调用 Z_TRY_ADDREF_P(val))。所有其他 add_next_index_…​() 函数都是通过该函数实现的。

  • add_index_…​(zval *arr, zend_ulong idx, …​) - 以给定的数字 "idx" 插入值的另一系列函数。与上述 add_next_index_…​() 系列具有类似后缀和参数的变体。

  • add_assoc_…​(zval *arr, char *key, …​) - 以给定的字符串 "key" 插入数值的另一系列函数,由零结尾的 C 字符串定义。

  • add_assoc_…​_ex(zval *arr, char *key, size_t key_len, …​) - 另一系列函数,通过给定的字符串 "key" 插入数值,由 C 字符串及其长度定义。

下面是一些可以直接使用 zend_array 的函数:

  • zend_new_arra(count) - 创建并返回新数组(为 "count" 元素保留内存)。

  • zend_array_destroy(zend_array *arr) - 释放为数组及其所有元素分配的内存。

  • zend_array_count(zend_array *arr) - 返回数组中元素的个数。

  • zend_array_dup(zend_array *arr) - 创建另一个与给定数组相同的数组。

zend_array 和 HashTable 的表示方法相同。每个 zend_array 也是一个 HashTable,但不能反过来。通用哈希表可以保存指向任何数据结构的指针。从技术上讲,这可以用一种特殊的 zval 类型 IS_PTR 来表示。HashTable API 的内容相当广泛,因此我们在此仅作简要介绍:

  • zend_hash_init() – 初始化哈希表。HashTable 本身可以嵌入到另一个结构中,在堆栈上分配或通过 malloc()/emalloc() 分配。该函数的参数之一是析构函数回调,它将针对从 HashTable 中删除的每个元素执行。对于 zend_arrays 来说,这是 zval_ptr_dtor()。

  • zend_hash_clean() – 删除 HashTable 的所有元素。

  • zend_hash_destroy() – 释放 HashTable 及其所有元素分配的内存。

  • zend_hash_copy() – 将给定哈希表的所有元素复制到另一个哈希表中。

  • zend_hash_num_elements() – 返回HashTable 中的元素数量。

  • zend_hash_[str_|index_]find[_ptr|_deref|_ind]() – 查找并返回具有给定字符串或数字键的 HashTable 元素。如果键不存在,则返回 NULL。

  • zend_hash_[str_|index_]exists[_ind]() – 检查哈希表中是否存在具有给定字符串或数字键的元素。

  • zend_hash_[str_|index_](add|update)[_ptr|_ind]() – 使用给定的字符串或数字键添加新元素或更新 HashTable 的现有元素。如果具有相同键的元素已经存在,"zend_hash…​add" 函数返回 NULL。"zend_hash…​update" 函数插入新元素(如果之前不存在)。

  • zend_hash_[str_|index_]del[_ind]() – 从 HashTable 中删除具有给定字符串或数字键的元素。

  • zend_symtable_[str_]find[_ptr|_deref|_ind]() – 与 zend_hash_find…​() 类似,但给定的键始终表示为字符串。它可能包含数字字符串。在这种情况下,它被转换为数字并调用 zend_hash_index_find…​() 。

  • zend_symtable_[str_|]exists[_ind]() - 与 zend_hash_exists…​() 类似,但给定的键始终表示为字符串。它可能包含数字字符串。在这种情况下,它被转换为数字并调用 zend_hash_index_exists…​() 。

  • zend_symtable_[str_](add|update)[_ptr|_ind]() – 与 zend_hash_add/update…​() 类似,但给定的键始终表示为字符串。它可能包含数字字符串。在这种情况下,它被转换为数字并调用 zend_hash_index_add/update…​() 。

  • zend_symtable_[str_|]del[_ind]() – 与 zend_hash_del…​() 类似,但给定的键始终表示为字符串。它可能包含数字字符串。在本例中,它被转换为数字并调用 zend_hash_index_del…​()。

还有多种方法可以迭代 HashTable:

  • ZEND_HASH_FOREACH_KEY_VAL(ht, num_key, str_key, zv) – 在哈希表"ht" 的所有元素上启动迭代循环的宏。将为每个元素调用嵌套的 C 代码块。C 变量"num_key"、"str_key" 和 "zv" 将使用数字键、字符串键和指向元素 zval 的指针进行初始化。对于带有数字键的元素,"str_key" 将为 NULL。还有更多类似的宏仅适用于值、键等。还有类似的宏以相反的顺序进行迭代。该宏的用法将在下一个示例中演示。

  • ZEND_HASH_FOREACH_END() – 结束迭代循环的宏。

  • zend_hash_[_reverse]apply() – 为 HashTable 的每个元素调用给定的回调函数。

  • zend_hash_apply_with_argument[s]() – 使用附加参数为给定 HashTable 的每个元素调用给定回调函数。

请参阅 Zend/zend_hash.h 中的更多信息。

在我们的示例扩展中使用 PHP 数组

让我们扩展 test_scale() 函数,使其支持数组。让它返回另一个具有保留键和缩放值的数组。

由于数组的元素可能是另一个数组(递归更深),我们必须将 scale 逻辑分离到一个单独的递归函数 do_scale() 中。IS_LONG、IS_DOUBLE 和 IS_STRING 的逻辑保持不变,只是我们的函数现在向调用者报告 SUCCESS 或 FAILURE,因此我们必须将 RETURN_…​() 宏替换为 RETVAL_…​() 和 返回 SUCCESS。

static int do_scale(zval *return_value, zval *x, zend_long factor)
{
    if (Z_TYPE_P(x) == IS_LONG) {
        RETVAL_LONG(Z_LVAL_P(x) * factor);
    } else if (Z_TYPE_P(x) == IS_DOUBLE) {
        RETVAL_DOUBLE(Z_DVAL_P(x) * factor);
    } else if (Z_TYPE_P(x) == IS_STRING) {
        zend_string *ret = zend_string_safe_alloc(Z_STRLEN_P(x), factor, 0, 0);
        char *p = ZSTR_VAL(ret);

        while (factor-- > 0) {
            memcpy(p, Z_STRVAL_P(x), Z_STRLEN_P(x));
            p += Z_STRLEN_P(x);
        }
        *p = '\000';
        RETVAL_STR(ret);
    } else if (Z_TYPE_P(x) == IS_ARRAY) {
        zend_array *ret;
        zend_ulong idx;
        zend_string *key;
        zval *val, tmp;

        ALLOC_HASHTABLE(ret);
        zend_hash_init(ret, zend_array_count(Z_ARR_P(x)), NULL, ZVAL_PTR_DTOR, 0);

        ZEND_HASH_FOREACH_KEY_VAL(Z_ARR_P(x), idx, key, val) {
                    if (do_scale(&tmp, val, factor) != SUCCESS) {
                        return FAILURE;
                    }
                    if (key) {
                        zend_hash_add(ret, key, &tmp);
                    } else {
                        zend_hash_index_add(ret, idx, &tmp);
                    }
                } ZEND_HASH_FOREACH_END();
        RETVAL_ARR(ret);
    } else {
        php_error_docref(NULL, E_WARNING, "unexpected argument type");
        return FAILURE;
    }
    return SUCCESS;
}

PHP_FUNCTION(test_scale)
{
    zval *x;
    zend_long factor = TEST_G(scale); // default value
    ZEND_PARSE_PARAMETERS_START(1, 2)
        Z_PARAM_ZVAL(x)
        Z_PARAM_OPTIONAL
        Z_PARAM_LONG(factor)
    ZEND_PARSE_PARAMETERS_END();
    do_scale(return_value, x, factor);
}

IS_ARRAY 参数的新代码会创建一个空的结果数组(保留与源数组相同的元素数)。然后遍历源数组元素,并对每个元素调用相同的 do_scale() 函数,将临时结果存储在 tmp zval 中。然后将临时值添加到结果数组中的相同字符串键或数字索引下。

让我们测试一下新功能…​

$ php -r 'var_dump(test_scale([2, 2.0, "x" => ["2"]], 3));'
array(3) {
    [0]=>
    int(6)
    [1]=>
    float(6)
    ["x"]=>
    array(1) {
        [0]=>
        string(3) "222"
    }
}

运行正常,但实际上,我们的函数有一个错误。它可能会在某些边缘条件下泄漏内存。