PHP 开发者的PHP源代码—第四部分 理解 PHP 数组实现

6 篇文章 0 订阅
4 篇文章 0 订阅

每一件事都是哈希表(HashTable)

基本上,PHP中的所有内容都是一个哈希表。不仅是在PHP数组的底层实现中使用的哈希表,它们还用于存储对象属性和方法、函数、变量以及其他所有内容。
由于哈希表对PHP来说是如此的重要,因此值得深入研究一下它是如何工作的?

哈希表是什么?

记住,在C数组中,数组基本上是内存块,可以通过索引访问。因此,C中的数组只有整数键值,必须是连续的(也就是说,不可能有键0,它下一个键是1332423442)。没有关联数组这样的东西。

这就是哈希表进来的地方:它们使用哈希函数将字符串键转换成普通的整数键。结果可以作为一个索引加入一个普通的C数组(也就是内存块)。这里的有新的问题,哈希函数可以有冲突,也就是说,多个字符串键可以产生相同的哈希值。例如,在一个有64个元素的PHP数组中,字符串“foo”和“oof”将具有相同的散列。

这个问题的解决方法是,不直接将值存储在生成的索引中,而是存储可能的值的链表。

哈希表和桶(HashTable and Bucket)

既然哈希表的概念已经清楚了,我们来看看 PHP 之中 哈希表是如何实现的。

typedef struct _hashtable {
    uint nTableSize;
    uint nTableMask;
    uint nNumOfElements;
    ulong nNextFreeElement;
    Bucket *pInternalPointer;
    Bucket *pListHead;
    Bucket *pListTail;
    Bucket **arBuckets;
    dtor_func_t pDestructor;
    zend_bool persistent;
    unsigned char nApplyCount;
    zend_bool bApplyProtection;
#if ZEND_DEBUG
    int inconsistent;
#endif
} HashTable;

我们来快速看看它:

• nTableSize指定内部C数组的大小。它总是2下一次方,大于或等于nNumOfElements。如果一个数组存储32个元素,那么内部C数组也有32个元素。但是,如果添加了一个元素,即数组中包含33个元素,那么内部C数组将被调整为64个元素。
这样做是为了在空间和时间上保持哈希表的效率。很明显,如果内部数组太小,就会有很多冲突,性能也会降低。另一方面,如果内部数组太大,我们就会浪费内存。2次方是一个很好的折中方案。
• nNumOfElements指定当前在数组中存储了多少值。这也是count() 函数返回的数字。
• nTableMask是表的大小减1。这个掩码用于对当前表大小的生成的散列进行调整。例如,“foo”(通过DJBX33A哈希函数)的实际哈希值是193491849。如果我们当前的表大小为64,那么显然不能将其作为数组中的索引。取而代之的是,我们通过小一些的数作为哈希表的掩码。
• hash | 193491849 | 0b1011100010000111001110001001
• & mask | & 63 | & 0b0000000000000000000000111111
• ———————————————————
• = index | = 9 | = 0b0000000000000000000000001001
• nNextFreeElement是下一个自由可用整数键,当您使用$array[]=xyz附加到一个数组时,它将被使用。
• pInternalPointer 指针在数组中存储当前位置。用于for循环。
• pListHead和pListTail指定数组的第一个和最后一个元素。记住:PHP数组有一个顺序。两个数组都包含相同的元素,但顺序不同。例如: [‘foo’ => ‘bar’, ‘bar’ => ‘foo’] 和 [‘bar’ => ‘foo’, ‘foo’ => ‘bar’]
• arBuckets是我们一直在讨论的“内部C数组”。它被定义为一个Bucket ** ,因此它可以被看作是一个Bucket指针数组(我们将在稍后详细说明一个桶是什么)。
• pDestructor 是值的析构。如果从HT中移除一个值,这个函数就会被调用。对于一个正常的数组,析构函数是zval_ptr_dtor,zval_ptr_dtor将减少zval的引用计数,如果它达到0,就会销毁并释放它。

最后四个属性对我们来说并不是很重要。因此,我们假设哈希表可以在多个请求之间持久化生存,在某些地方使用nApplyCount和bApplyProtection来防止无限递归,inconsistent 用于在调试模式中来捕获哈希表的不正确用法

我们来看看第二个重要的结构:Bucket:

typedef struct bucket {
    ulong h;
    uint nKeyLength;
    void *pData;
    void *pDataPtr;
    struct bucket *pListNext;
    struct bucket *pListLast;
    struct bucket *pNext;
    struct bucket *pLast;
    const char *arKey;
} Bucket;

• h 是哈希表(没有掩码)
• arKey 用于保存 字符串键值, nKeyLength 表示 arKey 数组长度,对于整数键值,这两个字段无用。
• pData或pDataPtr用于存储实际的值。对于PHP数组,该值是zval(但它也用于内部其他事务)。不要担心有两个属性这个事实。他们之间的区别是谁负责释放值。
• pListNext和pListLast指定数组元素的顺序。如果PHP想要遍历这个数组,它从pListHead桶开始,然后总是使用pListNext桶。同样的如果反向遍历,从pListTail开始,总是跟随pListLast桶方向。
• pNext和pLast形成了我上面提到的“可能的值链表”。arBuckets array存储一个指向第一个可能的桶的指针。如果这个桶没有正确的键,PHP将查看pNext指向的桶,直到找到正确的键值。pLast 可以做相同的事情,进行反向查询。

正如您所看到的,PHP的散列表实现相当复杂。这是为其超灵活的数组类型所必须付出的代价。

哈希表是如何使用的?

Zend Engine为处理散列表的工作定义了大量API函数。在zend_hash.h中可以找到低级哈希表函数的概述。另外,Zend 在zend_API.h中定义了一组稍高级的api。
我们没有时间来讨论所有这些,但是我们可以看看一个样本函数,至少可以看到其中的一些函数。我们将使用array_fill_keys作为示例函数。
使用第二部分中描述的技术,您应该能够在ext/standard/array.c 中找到函数定义,现在让我们快速浏览一下。
总是有一组变量声明和一个zend_parse_parameters调用在顶部:

zval *keys, *val, **entry;
HashPosition pos;

if (zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "az", &keys, &val) == FAILURE) {
    return;
}

az显然意味着第一个参数是一个数组(获得 keys 变量),第二个参数是一个任意的zval( 获得val变量)。
在解析参数之后,返回的数组将被初始化:

/* Initialize return array */
array_init_size(return_value, zend_hash_num_elements(Z_ARRVAL_P(keys)));

这一行包含了数组API的三个重要部分:
1. Z_ARRVAL_P 宏从zval中获取哈希表。
2. zend_hash_num_elements 获取一个哈希表中元素的数量(nNumOfElements属性)。
3. array_init_size初始化一个数组。
因此,这一行将一个数组初始化保存到 return_value中, 其大小与键数组相同。
这里的大小提示只是一个优化。该函数也可以调用array_init(return_value),在这种情况下,随着越来越多的元素被添加到数组中,PHP将不得不进行多次调整。通过指定一个显式大小,PHP从一开始就分配正确的内存。
在返回数组初始化之后,函数循环通过一个while循环,遍历 keys 数组 大致的结构:

zend_hash_internal_pointer_reset_ex(Z_ARRVAL_P(keys), &pos);
while (zend_hash_get_current_data_ex(Z_ARRVAL_P(keys), (void **)&entry, &pos) == SUCCESS) {
    // some code

    zend_hash_move_forward_ex(Z_ARRVAL_P(keys), &pos);
}

上面的代码如果用 PHP 来表示:

while (null !== $entry = current($keys)) {
    // some code

    next($keys);
}
same as:
foreach ($keys as $entry) {
    // some code
}

唯一真正的区别是C迭代不使用内部数组指针,而是使用它自己的pos变量来存储当前位置。
循环中的代码有两个分支:一个用于整数键,另一个用于其他键。整数键分支只包含两行:

zval_add_ref(&val);
zend_hash_index_update(Z_ARRVAL_P(return_value), Z_LVAL_PP(entry), &val, sizeof(zval *), NULL);

这很简单:首先对refcount 值增加 (将值添加到哈希表中,这意味着为它添加另一个引用),然后将值插入到哈希表中。zend_hash_index_update 参数是 宏Z_ARRVAL_P(return_value), 整数索引 Z_LVAL_PP(entry), &val, sizeof(zval *) , 方向指针 (这里方向无所谓,所以是 NULL).
非整数键分支稍微复杂一些:

zval key, *key_ptr = *entry;

if (Z_TYPE_PP(entry) != IS_STRING) {
    key = **entry;
    zval_copy_ctor(&key);
    convert_to_string(&key);
    key_ptr = &key;
}

zval_add_ref(&val);
zend_symtable_update(Z_ARRVAL_P(return_value), Z_STRVAL_P(key_ptr), Z_STRLEN_P(key_ptr) + 1, &val, sizeof(zval *), NULL);

if (key_ptr != *entry) {
    zval_dtor(&key);
}

首先,使用convert_to_string将键转换为字符串(除非它已经是1)。但是在此之前,key == **entry, 条目必须被复制到一个新的 键 变量中。 另外,必须调用zval_copy_ctor,否则复杂的结构(如字符串或数组)将无法正确复制。

副本是必要的,以确保转换不会更改原始数组。如果没有这个副本,转换不仅会修改我们的局部变量,还会修改键数组中的元素(显然这对用户来说是不可以接受的)。

显然,复本必须在循环之后再次删除,这就是zval_dtor(&key)行所做的。

zval_ptr_dtor和zval_dtor的不同之处是,zval_ptr_dtor 只会在如果refcount达到0才销毁zval,,而zval_dtor总是会销毁它,而不考虑refcount。这就是为什么您会发现zval_ptr_dtor用于一般的形式而zval_dtor用于临时性变量。

同样,zval_ptr_dtor在销毁zval后释放zval 空间,而zval_dtor则不这样做。由于我们从不使用malloc(),所以我们也不需要free(),所以zval_dtor在这方面也是正确的选择。
现在,让我们看看剩下的最后两行(重要的):

zval_add_ref(&val);
zend_symtable_update(Z_ARRVAL_P(return_value), Z_STRVAL_P(key_ptr), Z_STRLEN_P(key_ptr) + 1, &val, sizeof(zval *), NULL);

这些和在整数键分支中所做的非常相似。不同之处在于,现在zend_symtable_update被调用,而不是zend_hash_index_update被调用,字符串键和它的长度被传递进来。

看看 Symtable 哈希表

在哈希表中插入字符串键的“常用”函数是zend_hash_update,但是这里使用了zend_symtable_update函数,有什么区别呢?

一个symtable基本上是一种特殊的散列表,它用于数组。与普通哈希表的不同之处在于它是如何处理数字字符串键的:在一个symtable 哈希中,键“123”和123被认为是相同的。因此,如果您将一个值存储在 array["123"]使 array[123]来检索它。

PHP底层实现可以使用两种方式:要么使用 “123”键保存123和”123”,要么使用 123 键保存123和”123” 。PHP显然选择了前者(因为整数比字符串更小,速度也更快)。
你可以用symtable来做一些有趣的事情,如果你设法插入”123”键,而不把它转换成123,一种方法是将数组转换为对象:

$obj = new stdClass;
$obj->{123} = "foo";
$arr = (array) $obj;
var_dump($arr[123]);   // Undefined offset: 123
var_dump($arr["123"]); // Undefined offset: 123

对象属性总是在字符串键下保存,即使它们是数字。所以 obj>123=foo"123""foo"123 arr[123]和$arr[“123”]都是试图访问123 这个整数索引(而不是真正存在的”123”索引)时,都抛出了一个错误。因此,祝贺您,您已经创建了一个隐藏的数组元素!

接下来是什么

下一部分将在ircmaxell的博客上发表。它将分析对象和类如何在PHP内部工作。
如果你喜欢这篇文章,你可以浏览我的其他文章,或者在Twitter上关注我。

本文 PHP 开发者的PHP源代码—第四部分 理解 PHP 数组实现,翻译自 Nikita Popov 博客

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
PHP5+MySQL 网站开发实例精讲》全面、详细地介绍了基于PHP和MySQL的动态网络开发技术的原理和基础编程知识。全书共分为四篇18章,以“PHP基础知识→MySQL数据库基础知识→PHP高级开发→常用模块编程与综合案例开发”为线索具体展开,不仅包括PHP开发环境的搭建、PHP的基本语法、PHP中的常用函数、在MySQL中创建数据库和数据表,以及对MySQL数据库进行查询、删除、更新和排序等基础开发知识,还包括在PHP中创建图像、使用会话等较深入的开发内容,并在“常用模块编程和综合案例开发”篇中给出了常用模块(如通信录模块、计数器模块、文件上传模块、图片浏览模块和聊天室模块等)的开发方法,以及文件管理系统、投票系统、影碟管理系统、新闻发布系统和网上购物商城5个综合案例的开发全过程。  全书内容由浅入深,充分考虑了PHP学习者的特点,并在配套光盘中提供了书中实例的全部源代码,以方便读者举一反三,编写出适合自己的程序。 《PHP5+MySQL 网站开发实例精讲》不仅合适PHP技术的初学者,还能够帮助有一定编程经验的PHP开发人员解决开发过程中遇到的实际问题。《PHP5+MySQL 网站开发实例精讲》可作为广大PHP学习者的自学用书,或高等院校相关专业的教材和辅导用书。 显示更多 显示更少 --------------------------------------------------------------------------------
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值