每一件事都是哈希表(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 博客。