在本篇文章當中主要給大家深入介紹一下在 cpython 當中字典的實現原理,在本篇文章當中主要介紹在早期 python3 當中的版本字典的實現,現在的字典做了部分優化,我們在後面的文章當中再介紹。
/* The ma_values pointer is NULL for a combined table
* or points to an array of PyObject* for a split table
*/
typedef struct {
PyObject_HEAD
Py_ssize_t ma_used;
PyDictKeysObject *ma_keys;
PyObject **ma_values;
} PyDictObject;
struct _dictkeysobject {
Py_ssize_t dk_refcnt;
Py_ssize_t dk_size;
dict_lookup_func dk_lookup;
Py_ssize_t dk_usable;
PyDictKeyEntry dk_entries[1];
};
typedef struct {
/* Cached hash code of me_key. */
Py_hash_t me_hash;
PyObject *me_key;
PyObject *me_value; /* This field is only meaningful for combined tables */
} PyDictKeyEntry;
上面的各個欄位的含義為:
整個雜湊表的佈局大致如下圖所示:
這個函數還是比較簡單,首先申請記憶體空間,然後進行一些初始化操作,申請雜湊表用於儲存鍵值對。
static PyObject *
dict_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
PyObject *self;
PyDictObject *d;
assert(type != NULL && type->tp_alloc != NULL);
// 申請記憶體空間
self = type->tp_alloc(type, 0);
if (self == NULL)
return NULL;
d = (PyDictObject *)self;
/* The object has been implicitly tracked by tp_alloc */
if (type == &PyDict_Type)
_PyObject_GC_UNTRACK(d);
// 因為還沒有增加資料 因此雜湊表當中 ma_used = 0
d->ma_used = 0;
// 申請儲存鍵值對的陣列 PyDict_MINSIZE_COMBINED 是一個宏定義 值為 8 表示雜湊表陣列的最小長度
d->ma_keys = new_keys_object(PyDict_MINSIZE_COMBINED);
// 如果申請失敗返回 NULL
if (d->ma_keys == NULL) {
Py_DECREF(self);
return NULL;
}
return self;
}
// new_keys_object 函數如下所示
static PyDictKeysObject *new_keys_object(Py_ssize_t size)
{
PyDictKeysObject *dk;
Py_ssize_t i;
PyDictKeyEntry *ep0;
assert(size >= PyDict_MINSIZE_SPLIT);
assert(IS_POWER_OF_2(size));
// 這裡是申請記憶體的位置真正申請記憶體空間的大小為 PyDictKeysObject 的大小加上 size-1 個PyDictKeyEntry的大小
// 這裡你可能會有一位為啥不是 size 個 PyDictKeyEntry 的大小 因為在結構體 PyDictKeysObject 當中已經申請了一個 PyDictKeyEntry 物件了
dk = PyMem_MALLOC(sizeof(PyDictKeysObject) +
sizeof(PyDictKeyEntry) * (size-1));
if (dk == NULL) {
PyErr_NoMemory();
return NULL;
}
// 下面主要是一些初始化的操作 dk_refcnt 設定成 1 因為目前只有一個字典物件使用 這個 PyDictKeysObject 物件
DK_DEBUG_INCREF dk->dk_refcnt = 1;
dk->dk_size = size; // 雜湊表的大小
// 下面這行程式碼主要是表示雜湊表當中目前還能儲存多少個鍵值對 在 cpython 的實現當中允許有 2/3 的陣列空間去儲存資料 超過這個數則需要進行擴容
dk->dk_usable = USABLE_FRACTION(size); // #define USABLE_FRACTION(n) ((((n) << 1)+1)/3)
ep0 = &dk->dk_entries[0];
/* Hash value of slot 0 is used by popitem, so it must be initialized */
ep0->me_hash = 0;
// 將所有的鍵值對初始化成 NULL
for (i = 0; i < size; i++) {
ep0[i].me_key = NULL;
ep0[i].me_value = NULL;
}
dk->dk_lookup = lookdict_unicode_nodummy;
return dk;
}
首先我們先了解一下字典實現當中雜湊表的擴容機制,當我們不斷的往字典當中加入新的資料的時候,很快字典當中的資料就會達到陣列長度的 \(\frac{2}{3}\) ,這個時候就需要擴容,擴容之後的陣列大小計算方式如下:
#define GROWTH_RATE(d) (((d)->ma_used*2)+((d)->ma_keys->dk_size>>1))
新的陣列的大小等於原來鍵值對的個數乘以 2 加上原來陣列長度的一半。
總的來說擴容主要有三個步驟:
計算新的陣列的大小。
建立新的陣列。
將原來的雜湊表當中的資料加入到新的陣列當中(也就是再雜湊的過程)。
具體的實現程式碼如下所示:
static int
insertion_resize(PyDictObject *mp)
{
return dictresize(mp, GROWTH_RATE(mp));
}
static int
dictresize(PyDictObject *mp, Py_ssize_t minused)
{
Py_ssize_t newsize;
PyDictKeysObject *oldkeys;
PyObject **oldvalues;
Py_ssize_t i, oldsize;
// 下面的程式碼的主要作用就是計算得到能夠大於等於 minused 最小的 2 的整數次冪
/* Find the smallest table size > minused. */
for (newsize = PyDict_MINSIZE_COMBINED;
newsize <= minused && newsize > 0;
newsize <<= 1)
;
if (newsize <= 0) {
PyErr_NoMemory();
return -1;
}
oldkeys = mp->ma_keys;
oldvalues = mp->ma_values;
/* Allocate a new table. */
// 建立新的陣列
mp->ma_keys = new_keys_object(newsize);
if (mp->ma_keys == NULL) {
mp->ma_keys = oldkeys;
return -1;
}
if (oldkeys->dk_lookup == lookdict)
mp->ma_keys->dk_lookup = lookdict;
oldsize = DK_SIZE(oldkeys);
mp->ma_values = NULL;
/* If empty then nothing to copy so just return */
if (oldsize == 1) {
assert(oldkeys == Py_EMPTY_KEYS);
DK_DECREF(oldkeys);
return 0;
}
/* Main loop below assumes we can transfer refcount to new keys
* and that value is stored in me_value.
* Increment ref-counts and copy values here to compensate
* This (resizing a split table) should be relatively rare */
if (oldvalues != NULL) {
for (i = 0; i < oldsize; i++) {
if (oldvalues[i] != NULL) {
Py_INCREF(oldkeys->dk_entries[i].me_key);
oldkeys->dk_entries[i].me_value = oldvalues[i];
}
}
}
/* Main loop */
// 將原來陣列當中的元素加入到新的陣列當中
for (i = 0; i < oldsize; i++) {
PyDictKeyEntry *ep = &oldkeys->dk_entries[i];
if (ep->me_value != NULL) {
assert(ep->me_key != dummy);
insertdict_clean(mp, ep->me_key, ep->me_hash, ep->me_value);
}
}
// 更新一下當前雜湊表當中能夠插入多少資料
mp->ma_keys->dk_usable -= mp->ma_used;
if (oldvalues != NULL) {
/* NULL out me_value slot in oldkeys, in case it was shared */
for (i = 0; i < oldsize; i++)
oldkeys->dk_entries[i].me_value = NULL;
assert(oldvalues != empty_values);
free_values(oldvalues);
DK_DECREF(oldkeys);
}
else {
assert(oldkeys->dk_lookup != lookdict_split);
if (oldkeys->dk_lookup != lookdict_unicode_nodummy) {
PyDictKeyEntry *ep0 = &oldkeys->dk_entries[0];
for (i = 0; i < oldsize; i++) {
if (ep0[i].me_key == dummy)
Py_DECREF(dummy);
}
}
assert(oldkeys->dk_refcnt == 1);
DK_DEBUG_DECREF PyMem_FREE(oldkeys);
}
return 0;
}
我們在不斷的往字典當中插入資料的時候很可能會遇到雜湊衝突,字典處理雜湊衝突的方法基本和集合遇到雜湊衝突的處理方法是差不多的,都是使用開發地址法,但是這個開放地址法實現的相對比較複雜,具體程式如下所示:
static void
insertdict_clean(PyDictObject *mp, PyObject *key, Py_hash_t hash,
PyObject *value)
{
size_t i;
size_t perturb;
PyDictKeysObject *k = mp->ma_keys;
// 首先得到 mask 的值
size_t mask = (size_t)DK_SIZE(k)-1;
PyDictKeyEntry *ep0 = &k->dk_entries[0];
PyDictKeyEntry *ep;
i = hash & mask;
ep = &ep0[i];
for (perturb = hash; ep->me_key != NULL; perturb >>= PERTURB_SHIFT) {
// 下面便是遇到雜湊衝突時的處理辦法
i = (i << 2) + i + perturb + 1;
ep = &ep0[i & mask];
}
assert(ep->me_value == NULL);
ep->me_key = key;
ep->me_hash = hash;
ep->me_value = value;
}
在本篇文章當中主要給大家簡單介紹了一下在 cpython 內部字典的實現機制,總的來說整個字典的實現機制還是相當複雜的,本篇文章只是談到了整個字典實現的一小部分,主要設計字典的記憶體佈局以及相關的資料結構,最重要的字典的建立擴容和插入,這對我們理解雜湊表的結構還是非常有幫助的!!!
本篇文章是深入理解 python 虛擬機器器系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython
更多精彩內容合集可存取專案:https://github.com/Chang-LeHung/CSCore
關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演演算法與資料結構)知識。