[譯] 理解數組在 PHP 內部的實現(給PHP開發者的PHP源碼-第四部分)

文章來自:http://www.hoohack.me/2016/02/15/understanding-phps-internal-array-implementation-chphp

原文:https://nikic.github.io/2012/03/28/Understanding-PHPs-internal-array-implementation.htmlhtml

歡迎來到"給PHP開發者的PHP源碼"系列的第四部分,這一部分咱們會談論PHP數組在內部是如何表示和在代碼庫裏使用的。git

爲了防止你錯過了以前的文章,如下是連接:github

全部的東西都是哈希表

基本上,PHP裏面的全部東西都是哈希表。不只僅是在下面的PHP數組實現中,它們還用來存儲對象屬性,方法,函數,變量還有幾乎全部東西。函數

由於哈希表對PHP來講太基礎了,所以很是值得深刻研究它是如何工做的。性能

那麼,什麼是哈希表?

記住,在C裏面,數組是內存塊,你能夠經過下標訪問這些內存塊。所以,在C裏面的數組只能使用整數且有序的鍵值(那就是說,你不能在鍵值0以後使用1332423442的鍵值)。C裏面沒有關聯數組這種東西。優化

哈希表是這樣的東西:它們使用哈希函數轉換字符串鍵值爲正常的整型鍵值。哈希後的結果能夠被做爲正常的C數組的鍵值(又名爲內存塊)。如今的問題是,哈希函數會有衝突,那就是說,多個字符串鍵值可能會生成同樣的哈希值。例如,在PHP,超過64個元素的數組裏,字符串"foo"和"oof"擁有同樣的哈希值。

這個問題能夠經過存儲可能衝突的值到鏈表中,而不是直接將值存儲到生成的下標裏。

HashTable和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;

快速過一下:

nNumOfElements標識如今存儲在數組裏面的值的數量。這也是函數count($array)返回的值。

nTableSize表示哈希表的容量。它一般是下一個大於等於nNumOfElements的2的冪值。好比,若是數組存儲了32元素,那麼哈希表也是32大小的容量。但若是再多一個元素添加進來,也就是說,數組如今有33個元素,那麼哈希表的容量就被調整爲64。

這是爲了保持哈希表在空間和時間上始終有效。很明顯,若是哈希表過小,那麼將會有不少的衝突,並且性能也會下降。另外一方面,若是哈希表太大,那麼浪費內存。2的冪值是一個很好的折中方案。

nTableMask是哈希表的容量減一。這個mask用來根據當前的表大小調整生成的哈希值。例如,"foo"真正的哈希值(使用DJBX33A哈希函數)是193491849。若是咱們如今有64容量的哈希表,咱們明顯不能使用它做爲數組的下標。取而代之的是經過應用哈希表的mask,而後只取哈希表的低位。

hash           |        193491849 |     0b1011100010000111001110001001
& mask         | &             63 | &   0b0000000000000000000000111111
--------------------------------------------------------- = index | = 9 | = 0b0000000000000000000000001001 

nNextFreeElement是下一個可使用的數字鍵值,當你使用$array[] = xyz是被使用到。

pInternalPointer 存儲數組當前的位置。這個值在foreach遍歷時可以使用reset(),current(),key(),next(),prev()和end()函數訪問。

pListHeadpListTail標識了數組的第一個和最後一個元素的位置。記住:PHP的數組是有序集合。好比,['foo' => 'bar', 'bar' => 'foo']和['bar' => 'foo', 'foo' => 'bar']這兩個數組包含了相同的元素,但卻有不一樣的順序。

arBuckets是咱們常常談論的「哈希表(internal C array)」。它用Bucket **來定義,所以它能夠被看做數組的bucket指針(咱們會立刻談論Bucket是什麼)。

pDestructor是值的析構器。若是一個值從HT中移除,那麼這個函數會被調用。常見的析構函數是zval_ptr_dtor。zval_ptr_dtor會減小zval的引用數量,並且,若是它遇到o,它會銷燬和釋放它。

最後的四個變量對咱們來講不是那麼重要。因此簡單地說persistent標識哈希表能夠在多個請求裏存活,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是一個哈希值(沒有應用mask值映射以前的值)。

arKey用來保存字符串鍵值。nKeyLength是對應的長度。若是是數字鍵值,那麼這兩個變量都不會被使用。

pDatapDataPtr被用來存儲真正的值。對PHP數組來講,它的值是一個zval結構體(但它也在其餘地方使用到)。不要糾結爲何有兩個屬性。它們二者的區別是誰負責釋放值。

pListNextpListLast標識數組元素的下一個元素和上一個元素。若是PHP想順序遍歷數組它會從pListHead這個bucket開始(在HashTable結構裏面),而後使用pListNext bucket做爲遍歷指針。在逆序也是同樣,從pListTail指針開始,而後使用pListLast指針做爲變量指針。(你能夠在用戶代碼裏調用end()而後調用prev()函數達到這個效果。)

pNextpLast生成我上面提到的「可能衝突的值鏈表」。arBucket數組存儲第一個可能值的bucket。若是該bucket沒有正確的鍵值,PHP會查找pNext指向的bucket。它會一直指向後面的bucket直到找到正確的bucket。pLast在逆序中也是同樣的原理。

你能夠看到,PHP的哈希表實現至關複雜。這是它使用超靈活的數組類型要付出的代價。

哈希表是怎麼被使用的?

Zend Engine定義了大量的API函數供哈希表使用。低級的哈希表函數預覽能夠在zend_hash.h文件裏面找到。另外Zend Engine在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)));

這一行包含了array API裏面存在的三步重要的部分:

一、Z_ARRVAL_P宏從zval裏面提取值到哈希表。

二、zend_hash_num_elements提取哈希表元素的個數(nNumOfElements屬性)。

三、array_init_size使用size變量初始化數組。

所以,這一行使用與鍵值數組同樣大小來初始化數組到return_value變量裏。

這裏的size只是一種優化方案。函數也能夠只調用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代碼:

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

    next($keys);
}

跟下面的同樣:

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); 

這看起來太直接了:首先值的引用增長了(添加值到哈希表意味着增長另外一個指向它的引用),而後值被插入到哈希表中。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將鍵值轉換爲字符串(除非它已是字符串了)。在這以前,entry被複制到新的key變量。key = **entry這一行實現。另外,zval_copy_ctor函數會被調用,否則複雜的結構(好比字符串或數組)不會被正確地複製。

上面的複製操做很是有必要,由於要保證類型轉換不會改變原來的數組。若是沒有copy操做,強制轉換不只僅修改局部的變量,並且也修改了在鍵值數組中的值(顯然,這對用戶來講很是意外)。

顯然,循環結束以後,複製操做須要再次被移除,zval_dtor(&key)作的就是這個工做。zval_ptr_dtorzval_dtor的不一樣是zval_ptr_dtor只會在refcount變量爲0時銷燬zval變量,而zval_dtor會立刻銷燬它,而不是依賴refcount的值。這就爲何你看到zval_pte_dtor使用"normal"變量而zval_dtor使用臨時變量,這些臨時變量不會在其餘地方使用。並且,zval_ptr_dtor會在銷燬以後釋放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,而傳遞的是鍵值字符串和它的長度。

符號表

"正常的"插入字符串鍵值到哈希表的函數是zend_hash_update,但這裏卻使用了zend_symtable_update。它們有什麼不一樣呢?

符號表簡單地說就是哈希表的特殊的類型,這種類型使用在數組裏。它跟原始的哈希表不一樣的是他如何處理數字型的鍵值:在符號表裏,"123"和123被看做是相同的。所以,若是你在$array["123"]存儲一個值,你能夠在後面使用$array[123]獲取它。

底層可使用兩種方式實現:要麼使用"123"來保存123和"123",要麼使用123來保存這兩種鍵值。顯然PHP選擇了後者(由於整型比字符串類型更快和佔用更少的空間)。

若是你不當心使用"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'這行代碼實際上保存'foo'變量到"123"下標裏。當使用數組強制轉換的時候,這個值不會給改變。但當$arr[123]$arr["123"]都想訪問123下標的值(不是已有的"123"下標)時,都拋出了錯誤。所以,恭喜,你建立了一個隱藏的數組元素。

下一部分

下一部分會再次在ircmaxell的博客發表。下一篇會介紹對象和類在內部是如何工做的。

相關文章
相關標籤/搜索