[ PHP 内核与扩展开发系列] Array 与 HashTable:操作 HashTable 的 API

Zend 把与 HashTable 有关的 API 分成了好几类以便于我们查找,这些 API 的返回值大多都是常量SUCCESS 或者 FAILURE

初始化 HashTable

下面在介绍函数原型的时候都使用了 ht,但是我们在编写扩展的时候,一定不要使用这个名称,因为一些 PHP 宏展开后会声明这个名称的变量,进而引发命名冲突。

创建并初始化一个 HashTable 非常简单,只要使用 zend_hash_init 函数即可,它的定义如下:

int zend_hash_init(
    HashTable *ht,
    uint nSize,
    hash_func_t pHashFunction,
    dtor_func_t pDestructor,
    zend_bool persistent
);
  • *ht 是指针,指向一个 HashTable,我们既可以 & 一个已存在的 HashTable 变量,也可以通过emalloc()pemalloc() 等函数来直接申请一块内存,不过最常用的方法还是用ALLOC_HASHTABLE(ht) 宏来让内核自动替我们完成这项工作。ALLOC_HASHTABLE(ht) 所做的工作相当于 ht = emalloc(sizeof(HashTable))
  • nSize 代表着这个 HashTable 可以拥有的元素的最大数量(HashTable 能够包含任意数量的元素, 这个值只是为了提前申请好内存,提高性能,省得不停地进行 rehash 操作)。在我们添加新的元素时,这个值会根据情况决定是否自动增长,有趣的是,这个值永远都是 2 的次方,如果你给它的值不是一个 2 的次方的形式,那它将自动调整成大于它的最小的2的次方值。它的计算方法就像这样:nSize = pow(2, ceil(log(nSize, 2)))
  • pHashFunction 是早期 Zend Engine 中的一个参数,为了兼容没有去掉它,但它已经没有用处了,所以我们直接赋成 NULL 就可以了。在以前,它其实是一个钩子,用来让用户自己 hook 一个散列函数,替换 PHP 默认的 DJBX33A 算法实现。
  • pDestructor 代表着一个回调函数,当我们删除或者修改 HashTable 中其中一个元素时候便会调用,它的函数原型必须是这样的:void method_name(void pElement);,这里的 pElement 是一个指针,指向 HashTable 中将要被删除或者修改的那个数据,而数据的类型往往也是个指针。
  • persistent 是最后一个参数,它的含义非常简单。如果它为 true,那么这个 HashTable 将永远存在于内存中,而不会在 RSHUTDOWN 阶段自动被注销掉。此时第一个参数 ht 所指向的地址必须是通过 pemalloc() 函数申请的。

举个例子,PHP 内核在每个请求的头部都调用了这个函数来初始化 symbol_table

zend_hash_init(&EG(symbol_table), 50, NULL, ZVAL_PTR_DTOR, 0);
//#define ZVAL_PTR_DTOR (void (*)(void *)) zval_ptr_dtor_wrapper

如你所见,每个元素在从符号表里删除的时候(比如执行"<?php unset($foo);"操作),都会触发ZVAL_PTR_DTOR 宏代表的函数来对其进行与引用计数有关的操作。因为 50 不是 2 的整数幂形式,所以它会在函数执行时被调整为 64。

添加 && 修改

我们有四个常用的函数来完成这项操作,它们的原型分别如下:

int zend_hash_add(
    HashTable *ht,      //待操作的ht
    char *arKey,        //索引,如"my_key"
    uint nKeyLen,       //字符串索引的长度,如6
    void **pData,       //要插入的数据,注意它是void **类型的
    uint nDataSize,
    void *pDest         //如果操作成功,则 pDest = *pData;
);

int zend_hash_update(
    HashTable *ht,
    char *arKey,
    uint nKeyLen,
    void *pData,
    uint nDataSize,
    void **pDest
);

int zend_hash_index_update(
    HashTable *ht,
    ulong h,
    void *pData,
    uint nDataSize,
    void **pDest
);

int zend_hash_next_index_insert(
    HashTable *ht,
    void *pData,
    uint nDataSize,
    void **pDest
);

前两个函数用于添加带字符串索引的数据到 HashTable 中,就像我们在 PHP 中使用的那样 $foo['bar'] = 'baz';,用C来完成便是:

zend_hash_add(fooHashTbl, "bar", sizeof("bar"), &barZval, sizeof(zval*), NULL);

zend_hash_add()zend_hash_update() 唯一的区别就是如果这个 key 已经存在了,那么zend_hash_add() 将返回 FAILURE,而不会修改原有数据。

接下来的两个函数用于向 ht 中添加数字索引的数据,zend_hash_next_index_insert() 函数则不需要索引值参数,而是自己直接计算出下一个数字索引值。但是如果我们想获取下一个元素的数字索引值,也是有办法的,可以使用zend_hash_next_free_element() 函数:

ulong nextid = zend_hash_next_free_element(ht);
zend_hash_index_update(ht, nextid, &data, sizeof(data), NULL);

所有这些函数中,如果 pDest 不为 NULL,内核便会修改其值为被操作的那个元素的地址。在下面的代码中这个参数也有同样的功能。

查找

因为 HashTable 中有两种类型的索引值,所以需要两个函数来执行 find 操作。

int zend_hash_find(HashTable *ht, char *arKey, uint nKeyLength,void **pData);
int zend_hash_index_find(HashTable *ht, ulong h, void **pData);

第一种就是我们处理 PHP 语言中字符串索引数组时使用的,第二种是我们处理 PHP 语言中数字索引数组使用的。我们看一个使用示例:

void hash_sample(HashTable *ht, sample_data *data1)
{
    sample_data *data2;
    ulong targetID = zend_hash_next_free_element(ht);
    if (zend_hash_index_update(ht, targetID, data1, sizeof(sample_data), NULL) == FAILURE) {
           /* Should never happen */
             return;
    }
    if(zend_hash_index_find(ht, targetID, (void **)&data2) == FAILURE) {
            /* Very unlikely since we just added this element */
            return;
    }
    /* data1 != data2, 但是 *data1 == *data2 */
}

除了读取,我们还需要检测某个 key 是否存在:

int zend_hash_exists(HashTable *ht, char *arKey, uint nKeyLen);
int zend_hash_index_exists(HashTable *ht, ulong h);

这两个函数返回 SUCCESS 或者 FAILURE,分别代表着是否存在:

if( zend_hash_exists(EG(active_symbol_table), "foo", sizeof("foo")) == SUCCESS )
{
    /* $foo is set */
}
else
{
    /* $foo does not exist */
}

ulong zend_get_hash_value(char *arKey, uint nKeyLen);

当我们需要对同一个字符串的 key 进行多个操作时,比如先检测有没有,如果没有的话插入,然后修改等等,这时我们便可以使用 zend_get_hash_value 函数来对我们的操作进行加速!这个函数的返回值可以和 quick 系列函数使用,达到加速的目的(就是不再重复计算这个字符串的散列值,而直接使用已准备好的)!

int zend_hash_quick_add(
    HashTable *ht,
    char *arKey,
    uint nKeyLen,
    ulong hashval,
    void *pData,
    uint nDataSize,
    void **pDest
);

int zend_hash_quick_update(
    HashTable *ht,
    char *arKey,
    uint nKeyLen,
    ulong hashval,
    void *pData,
    uint nDataSize,
    void **pDest
);

int zend_hash_quick_find(
    HashTable *ht,
    char *arKey,
    uint nKeyLen,
    ulong hashval,
    void **pData
);

int zend_hash_quick_exists(
    HashTable *ht,
    char *arKey,
    uint nKeyLen,
    ulong hashval
);

虽然很意外,但你还是要接受没有 zend_hash_quick_del() 这个函数。quick 类函数会在下面这种场合中用到:

void php_sample_hash_copy(HashTable *hta, HashTable *htb, char *arKey, uint nKeyLen TSRMLS_DC)
{
    ulong hashval = zend_get_hash_value(arKey, nKeyLen);
    zval **copyval;

    if (zend_hash_quick_find(hta, arKey, nKeyLen, hashval, (void**)&copyval) == FAILURE)
    {
        // 表明不存在这个索引
        return;
    }

    // 这个 zval 已经被其它的 Hashtable 使用了,这里我们进行引用计数操作。
    (*copyval)->refcount__gc++;
    zend_hash_quick_update(htb, arKey, nKeyLen, hashval, copyval, sizeof(zval*), NULL);
}

复制与合并

在 PHP 语言中,我们经常需要进行数组间的拷贝与合并操作,所以 PHP 语言中的数组在 C 语言中的实现 HashTable 也肯定会经常碰到这种情况。为了简化这一类操作,内核中早已准备好了相应的 API 供我们使用。

void zend_hash_copy(
    HashTable *target,
    HashTable *source,
    copy_ctor_func_t pCopyConstructor,
    void *tmp,
    uint size
);

*source 中的所有元素都会通过 pCopyConstructor 函数拷贝到 *target 中去,我们还是以PHP 语言中的数组举例,pCopyConstructor 这个 hook 使得我们可以在拷贝变量的时候对他们的ref_count 进行加 1 操作。target 中原有的与 source 相同索引位置上的数据会被替换掉,而其它的元素则会被保留不动。tmp 参数是为了兼容 PHP 4.0.3 以前版本的,现在赋值为 NULL 即可。size 参数代表每个元素的大小,对于 PHP 语言中的数组来说,这里的便是 sizeof(zval*) 了。

void zend_hash_merge(
    HashTable *target,
    HashTable *source,
    copy_ctor_func_t pCopyConstructor,
    void *tmp,
    uint size,
    int overwrite
);

zend_hash_mergezend_hash_copy 唯一的不同之处便是多了个 int 类型的 overwrite 参数,当其值非 0 的时候,两个函数的工作是完全一样的;如果 overwrite 参数为 0,则 zend_hash_merge 函数就不会对 target 中已有索引的值进行替换了。

typedef zend_bool (*merge_checker_func_t)(HashTable *target_ht, void *source_data, zend_hash_key *hash_key, void *pParam);
void zend_hash_merge_ex(
    HashTable *target,
    HashTable *source,
    copy_ctor_func_t pCopyConstructor, 
    uint size,
    merge_checker_func_t pMergeSource,
    void *pParam
);

这个函数又繁琐了些,与 zend_hash_copy 相比,其多了两个参数,多出来的 pMergeSoure 回调函数允许我们选择性的进行 merge,而不是全都 merge。这组功能的最终形式是允许使用合并检查器功能进行选择性复制。以下示例显示 zend_hash_merge_ex 用于仅复制源 HashTable 的关联索引成员(用户空间变量数组):

zend_bool associative_only(HashTable *ht, void *pData, zend_hash_key *hash_key, void *pParam)
{
    //如果是字符串索引
    return (hash_key->arKey && hash_key->nKeyLength);
}

void merge_associative(HashTable *target, HashTable *source)
{
    zend_hash_merge_ex(target, source, zval_add_ref, sizeof(zval*), associative_only, NULL);
}

遍历

在 PHP 语言中,我们有很多方法需要遍历一个数组,对于数组的本质 HashTable,我们也有很多办法来对其进行遍历操作。首先最简单的一种办法便是使用一种与 PHP 语言中 foreach 语句功能类似的函数——zend_hash_apply,它接收一个回调函数,并将 HashTable 的每一个元素都传递给它:

typedef int (*apply_func_t)(void *pDest TSRMLS_DC);
void zend_hash_apply(HashTable *ht, apply_func_t apply_func TSRMLS_DC);

下面是另外一种遍历函数:

typedef int (*apply_func_arg_t)(void *pDest, void *argument TSRMLS_DC);
void zend_hash_apply_with_argument(HashTable *ht, apply_func_arg_t apply_func, void *data TSRMLS_DC);

通过上面的函数可以在执行遍历时向回调函数传递任意数量的值,这在一些 DIY 操作中非常有用。

上述函数对传给它们的回调函数的返回值有一个共同的约定,详细介绍下下表:

常量 含义
ZEND_HASH_APPLY_KEEP 结束当前请求,进入下一个循环。与 PHP 语言 foreach 语句中的一次循环执行完毕或者遇到 continue 关键字的作用一样
ZEND_HASH_APPLY_STOP 跳出,与 PHP 语言 foreach 语句中的 break 关键字的作用一样
ZEND_HASH_APPLY_REMOVE 删除当前的元素,然后继续处理下一个。相当于在PHP语言中:unset($foo[$key]);continue;

我们来看一下 PHP 语言中的 foreach 循环:

<?php
foreach($arr as $val) {
    echo "The value is: $val\n";
}
?>

那我们的回调函数在 C 语言中应该这样写:

int php_sample_print_zval(zval **val TSRMLS_DC)
{
    // 重新copy一个zval,防止破坏原数据
    zval tmpcopy = **val;
    zval_copy_ctor(&tmpcopy);

    // 转换为字符串
    INIT_PZVAL(&tmpcopy);
    convert_to_string(&tmpcopy);

    // 开始输出
    php_printf("The value is: ");
    PHPWRITE(Z_STRVAL(tmpcopy), Z_STRLEN(tmpcopy));
    php_printf("\n");

    //毁尸灭迹
    zval_dtor(&tmpcopy);

    //返回,继续遍历下一个
    return ZEND_HASH_APPLY_KEEP;
}

遍历我们的 HashTable:

//生成一个名为arrht、元素为 zval* 类型的HashTable
zend_hash_apply(arrht, php_sample_print_zval TSRMLS_CC);

再次提醒,保存在 HashTable 中的元素并不是真正的最终变量,而是指向它的一个指针。我们的上面的遍历函数接收的是一个 zval** 类型的参数。

typedef int (*apply_func_args_t)(void *pDest,int num_args, va_list args, zend_hash_key *hash_key);
void zend_hash_apply_with_arguments(HashTable *ht, apply_func_args_t apply_func, int numargs, ...);

为了能在遍历的同时接收索引的值,我们必须使用第三种形式的 zend_hash_apply,就像 PHP 语言中这样的功能:

<?php
foreach($arr as $key => $val)
{
    echo "The value of $key is: $val\n";
}
?>

为了配合 zend_hash_apply_with_arguments() 函数,需要对遍历执行函数做一些小小的改动,使其接受索引作为一个参数:

int php_sample_print_zval_and_key(zval **val, int num_args, va_list args, zend_hash_key *hash_key)
{
    // 重新copy一个zval,防止破坏原数据
    zval tmpcopy = **val;
    // tsrm_ls is needed by output functions
    TSRMLS_FETCH();
    zval_copy_ctor(&tmpcopy);
    INIT_PZVAL(&tmpcopy);

    // 转换为字符串
    convert_to_string(&tmpcopy);

    // 执行输出
    php_printf("The value of ");
    if (hash_key->nKeyLength)
    {
        // 如果是字符串类型的key
            PHPWRITE(hash_key->arKey, hash_key->nKeyLength);
       }
    else
    {
            // 如果是数字类型的key
            php_printf("%ld", hash_key->h);
    }

    php_printf(" is: ");
    PHPWRITE(Z_STRVAL(tmpcopy), Z_STRLEN(tmpcopy));
    php_printf("\n");

    // 毁尸灭迹
    zval_dtor(&tmpcopy);
    // continue
    return ZEND_HASH_APPLY_KEEP;
}

执行遍历:

zend_hash_apply_with_arguments(arrht, php_sample_print_zval_and_key, 0);

这个函数通过 C 语言中的可变参数特性来接收参数。

当我们检查这个 hash_key 是字符串类型还是数字类型时,是通过 nKeyLength 属性来检测的,而不是 arKey 属性。这是因为内核有时候会在 arKey 属性里留些脏数据,但 nKeyLength 属性是安全的,可以放心使用,甚至对于空字符串索引,它也照样能处理。比如:$foo[''] = "Bar"; 索引的值是NULL 字符,但它的长度却是包括最后这个 NULL 字符的,所以为 1。

向前遍历

有时我们希望不用回调函数也能遍历一个数组的数据,为了实现这个功能,内核特意的为每个 HashTable 加了个属性:内部指针。我们还是以 PHP 语言中的数组举例,有以下函数来处理它所对应的那个 HashTable 的内部指针:reset()key()current()next()prev()each()end()

<?php
    $arr = array('a'=>1, 'b'=>2, 'c'=>3);
    reset($arr);
    while (list($key, $val) = each($arr)) {
        /* Do something with $key and $val */
    }
    reset($arr);
    $firstkey = key($arr);
    $firstval = current($arr);
    $bval = next($arr);
    $cval = next($arr);
?>

Zend 内核中有一组操作 HashTable 的功能与以上函数功能类似的函数:

/* reset() */
void zend_hash_internal_pointer_reset(HashTable *ht);

/* key() */
int zend_hash_get_current_key(HashTable *ht, char **strIdx, unit *strIdxLen, ulong *numIdx, zend_bool duplicate);

/* current() */
int zend_hash_get_current_data(HashTable *ht, void **pData);

/* next()/each() */
int zend_hash_move_forward(HashTable *ht);

/* prev() */
int zend_hash_move_backwards(HashTable *ht);

/* end() */
void zend_hash_internal_pointer_end(HashTable *ht);

/* 其他 */
int zend_hash_get_current_key_type(HashTable *ht);
int zend_hash_has_more_elements(HashTable *ht);

PHP 语言中的 next()prev()end() 函数在移动完指针之后,都通过调用zend_hash_get_current_data() 函数来获取当前所指的元素并返回。而 each() 虽然和 next() 很像,却是使用 zend_hash_get_current_key() 函数的返回值来作为它的返回值。

现在我们用另外一种方法来实现上面的 foreach

void php_sample_print_var_hash(HashTable *arrht)
{

    for(
    zend_hash_internal_pointer_reset(arrht);
    zend_hash_has_more_elements(arrht) == SUCCESS;
    zend_hash_move_forward(arrht))
    {
        char *key;
        uint keylen;
        ulong idx;
        int type;
        zval **ppzval, tmpcopy;

        type = zend_hash_get_current_key_ex(arrht, &key, &keylen, &idx, 0, NULL);
        if (zend_hash_get_current_data(arrht, (void**)&ppzval) == FAILURE)
        {
            /* Should never actually fail
             * since the key is known to exist. */
            continue;
        }

        //重新copy一个zval,防止破坏原数据
        tmpcopy = **ppzval;
        zval_copy_ctor(&tmpcopy);
        INIT_PZVAL(&tmpcopy);

        convert_to_string(&tmpcopy);

        /* Output */
        php_printf("The value of ");
        if (type == HASH_KEY_IS_STRING)
        {
            /* String Key / Associative */
            PHPWRITE(key, keylen);
        } else {
            /* Numeric Key */
            php_printf("%ld", idx);
        }
        php_printf(" is: ");
        PHPWRITE(Z_STRVAL(tmpcopy), Z_STRLEN(tmpcopy));
        php_printf("\n");
        /* Toss out old copy */
        zval_dtor(&tmpcopy);
    }
}

上面的代码你应该都能看懂了,唯一还没接触到的可能是 zend_hash_get_current_key() 函数的返回值,它的返回值见下表:

常量 含义
HASH_KEY_IS_STRING 当前元素的索引是字符串类型的
HASH_KEY_IS_LONG 当前元素的索引是数字型的
HASH_KEY_NON_EXISTANT HashTable 中的内部指针已经移动到尾部,不指向任何元素。

删除

内核中一共预置了四个删除 HashTable 元素的函数,头两个是用户删除某个确定索引的数据:

int zend_hash_del(HashTable *ht, char *arKey, uint nKeyLen);
int zend_hash_index_del(HashTable *ht, ulong h);

它们两个分别用来删除字符串索引和数字索引的数据,操作完成后都返回 SUCCESS 或者 FAILURE 表示成功或失败。回顾一下最上面的叙述,当一个元素被删除时,会激活 HashTable 的 destructor 回调函数:

void zend_hash_clean(HashTable *ht);
void zend_hash_destroy(HashTable *ht);

前者用于将 HashTable 中的元素全部删除,而后者是将这个 HashTable 自身也毁灭掉。现在让我们来完整的回顾一下 HashTable 的创建、添加和删除操作:

int sample_strvec_handler(int argc, char **argv TSRMLS_DC)
{
    HashTable *ht;

    //分配内存
    ALLOC_HASHTABLE(ht);

    //初始化
    if (zend_hash_init(ht, argc, NULL, ZVAL_PTR_DTOR, 0) == FAILURE) {
        FREE_HASHTABLE(ht);
        return FAILURE;
    }

    //填充数据
    while (argc) {
        zval *value;
        MAKE_STD_ZVAL(value);
        ZVAL_STRING(value, argv[argc], 1);
        argv++;
        if (zend_hash_next_index_insert(ht, (void**)&value,
                            sizeof(zval*)) == FAILURE) {
            /* 添加失败则跳过 */
            zval_ptr_dtor(&value);
        }
    }

    //完成工作
    process_hashtable(ht);

    //毁尸灭迹
    zend_hash_destroy(ht);

    FREE_HASHTABLE(ht);
    return SUCCESS;
}

排序比较、临界值

针对 HashTable 操作的 Zend API 中有很多都需要回调函数。首先让我们来处理一下对 HashTable 中元素大小比较的问题:

typedef int (*compare_func_t)(void *a, void *b TSRMLS_DC);

这很像PHP语言中 usort 函数需要的参数,它将比较两个值 ab,如果*a > *b,则返回 1,相等则返回 0,否则返回 -1。下面是 zend_hash_minmax 函数的声明,它就需要我们上面声明的那个类型的函数作为回调函数:int zend_hash_minmax(HashTable *ht, compare_func_t compar, int flag, void **pData TSRMLS_DC);,这个函数的功能我们从它的名称中便能肯定,它用来比较HashTable 中的元素大小。如果 flag==0 则返回最小值,否则返回最大值!

下面让我们来利用这个函数来对用户端定义的所有函数根据函数名找到最大值与最小值(大小写不敏感):

// 先定义一个比较函数,作为 zend_hash_minmax 的回调函数
int fname_compare(zend_function *a, zend_function *b TSRMLS_DC)
{
    return strcasecmp(a->common.function_name, b->common.function_name);
}

void php_sample_funcname_sort(TSRMLS_D)
{
    zend_function *fe;
    if (zend_hash_minmax(EG(function_table), fname_compare, 0, (void **)&fe) == SUCCESS)
    {
        php_printf("Min function: %s\n", fe->common.function_name);
    }
    if (zend_hash_minmax(EG(function_table), fname_compare, 1, (void **)&fe) == SUCCESS)
    {
        php_printf("Max function: %s\n", fe->common.function_name);
    }
}

zend_hash_compare() 也需要回调函数,它的功能是将 HashTable 看作一个整体与另一个 HashTable 做比较,如果前者大于后者返回 1,相等返回 0,否则返回 -1:

int zend_hash_compare(HashTable *hta, HashTable *htb, compare_func_t compar, zend_bool ordered TSRMLS_DC);

默认情况下它往往是先判断各个 HashTable 元素的个数,个数多的最大。如果两者的元素个数一样多,就比较它们各自的每一个元素。

另外一个重要的需要回调函数的 API 便是排序函数,它需要的回调函数形式是这样的:

typedef void (*sort_func_t)(void **Buckets, size_t numBuckets, size_t sizBucket, compare_func_t comp TSRMLS_DC);

此回调将被触发一次,并将 HashTable 中所有 Buckets(元素)向量以一系列指针方式接收。根据排序功能自己的逻辑,在使用或不使用比较回调的情况下,这些 Bucket 可能会在向量内进行交换。在实际情况中,sizBucket 将始终是 sizeof(Bucket *)

除非你计划实现自己的方法来替代 bubbleort,否则您不需要自己实现排序功能。一个预定义的排序方法zend_qsort 已经存在,用作 zend_hash_sort() 的回调,你只需要实现比较功能即可:

int zend_hash_sort(HashTable *ht, sort_func_t sort_func, compare_func_t compare_func, int renumber TSRMLS_DC);

最后一个参数如果为 TRUE,则会抛弃 HashTable 中原有的索引-键关系,将对排列好的新值赋予新的数字键值。PHP 语言中的 sort 函数实现如下:

zend_hash_sort(target_hash, zend_qsort, array_data_compare, 1 TSRMLS_CC);

array_data_compare 是一个返回 compare_func_t 类型数据的函数,它将按照 HashTable 中zval* 值的大小进行排序。

学院君 has written 703 articles

资深PHP工程师,Laravel学院院长

发表评论

标记为*的字段是必填项(邮箱地址不会被公开)

你可以使用这些HTML 标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>