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

来源:http://www.cnblogs.com/h-hq/archive/2016/02/24/5213767.html
-Advertisement-
Play Games

原文:https://nikic.github.io/2012/03/28/Understanding-PHPs-internal-array-implementation.html 歡迎來到”給PHP開發者的PHP源碼”系列的第四部分,這一部分我們會談論PHP數組在內部是如何表示和在代碼庫里使用...


文章來自:http://www.aintnot.com/2016/02/15/understanding-phps-internal-array-implementation-ch

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

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

為了防止你錯過了之前的文章,以下是鏈接:

所有的東西都是哈希表

基本上,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裡面存在的三步重要的部分:

1、Z_ARRVAL_P巨集從zval裡面提取值到哈希表。

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

3、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的博客發表。下一篇會介紹對象和類在內部是如何工作的。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 很驚訝昨晚寫的第一篇學習筆記竟然有個評論了,只是今天還是對基礎知識提不起精神,還是先看那三本書瞭解一下程式開發的大概流程吧。 今天不知道怎麼閑逛就找到了這個網站,說是專門用於編程練習的,用google賬戶就能夠登錄,簡單整理了一下免費版可以直接使用的!如下表: 編程語言 開源項目 代碼行數 源程式
  • 具體來說cookie機制採用的是在客戶端保持狀態的方案,而session機制採用的是在伺服器端保持狀態的方案。同時我們也看到,由於採用伺服器端保持狀態的方案在客戶端也需要保存一個標識,所以session機制可能需要藉助於cookie機制來達到保存標識的目的,但實際上它還有其他選擇。 cookie機制
  • 用c#編寫window服務常見的幾個事件 protected int i = 0; public Service1() { InitializeComponent(); } //啟動服務時執行 protected override void OnStart(string[] args) { //使時
  • 本人文筆不好,大致說說自己的困惑 隨著2016的到來,一年一度的職業規劃即將而來,因為本人一直從事著C#工作,也接觸了一些前端知識。 但是由於微軟老大哥,技術更新太快,學習成本太高,但是眾所周知C#工資並不高,畢竟人活著要吃一口飯, ,因此產生換個技術的想法。 但是換什麼技術啊?真是糾結啊 比如手機
  • 安裝JDK 選擇安裝目錄 安裝過程中會出現兩次 安裝提示 。第一次是安裝 jdk ,第二次是安裝 jre 。建議兩個都安裝在同一個java文件夾中的不同文件夾中。(不能都安裝在java文件夾的根目錄下,jdk和jre安裝在同一文件夾會出錯) 如下圖所示 1:安裝jdk 隨意選擇目錄 只需把預設安裝目...
  • 半形全形的處理是字元串處理的常見問題,本文嘗試為大家提供一個思路。 一、概念 全形字元unicode編碼從65281~65374 (十六進位 0xFF01 ~ 0xFF5E)半形字元unicode編碼從33~126 (十六進位 0x21~ 0x7E)空格比較特殊,全形為 12288(0x3000),
  • 指針: 一、聲明 一個 int 類型的 指針 然後 賦值。 二、聲明中直接賦值。 三、空指針 四、懸空指針 野指針: 懸空指針本質上就是 聲明瞭一個 指針類型的變數【如:int *p】,並且沒有賦值。在沒有賦初值的情況下,利用這個指針進行修改【如:*p=100】。就相當於這個指針指向了一個未知的地址
  • 之前在[譯]更快的方式實現PHP數組去重討論了使用array_flip後再調用array_keys函數替換直接調用array_unique函數實現數組去重性能較好。由於原文沒有給出源碼分析和測試的結果,導致給讀者造成迷惑,在此說聲抱歉。為瞭解開讀者的疑惑,筆者承諾了會補上源碼的分析,現在此補上詳細的...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...