iT邦幫忙

2024 iThome 鐵人賽

DAY 15
0
Python

為你自己讀 CPython 原始碼系列 第 15

Day 15 - 字典的整理收納課(上)

  • 分享至 

  • xImage
  •  

本文同步刊載於 「為你自己學 Python - 字典的整理收納課(上)

字典的整理收納課(上)

為你自己學 Python

跟串列一樣,Python 的字典(dict)是很常使用的資料型態,字典透過 Key & Value 的方式存取,效能相當好,這個章節我們就來看看 Python 裡的字典是怎麼實作的。

字典的內部結構

大家從前面看到現在這個章節,大概可以不用看原始碼就能猜到在 CPython 裡字典的物件叫什麼名字,是的,就是 PyDictObject。來看看這傢伙的結構長什麼樣子:

在 CPython 裡,字典的內部結構定義在 Include/dictobject.h 檔案中,我把結構裡的一些條件編譯拿掉,看起來像這樣:

// 檔案:Include/cpython/dictobject.h

typedef struct {
    PyObject_HEAD
    Py_ssize_t ma_used;
    uint64_t ma_version_tag;
    PyDictKeysObject *ma_keys;
    PyDictValues *ma_values;
} PyDictObject;

先不論這些欄位是什麼用途,可以看到在這個結構裡有好幾個成員都是 ma_ 開頭的,這什麼意思?這是 Python 的命名慣例,ma 代表 Mapping,這是 Python 裡的一個通用名詞,表示可以透過 Key / Value 方式存取的資料結構,像是字典、集合等等。其實我們之前也有看過類似的慣例,像是:

  • ob_:用在 PyObject 裡的成員,例如 ob_refcntob_size 等。
  • tp_:用於 PyTypeObject 裡的成員,例如 tp_nametp_init 等。
  • sq_:用於序列(Sequence)的成員,例如 sq_lengthsq_item 等。
  • nb_:用於數字類型的成員,例如 nb_addnb_subtract 等。
  • co_:用於程式碼物件(Code Object)的成員,例如 co_argcountco_consts 等。

回到 PyDictObject 結構,這裡有幾個重要的成員:

  • ma_used:目前這個字典裡的元素數量。
  • ma_keys:一個 PyDictKeysObject 物件。
  • ma_values:一個 PyDictValues 物件。

嗯...初步看起來像是一個字典物件裡的 Key 跟 Value 是分開成不同的物件存放的?感覺有點不太合理,為什麼不直接放在同一個物件裡就好?沒關係,先繼續往下看看這兩個結構裡有什麼,先看 PyDictKeysObject

// 檔案:Include/internal/pycore_dict.h

struct _dictkeysobject {
    Py_ssize_t dk_refcnt;
    uint8_t dk_log2_size;
    uint8_t dk_log2_index_bytes;
    uint8_t dk_kind;
    uint32_t dk_version;
    Py_ssize_t dk_usable;
    Py_ssize_t dk_nentries;
    char dk_indices[];
};

裡面大概有 Reference Count,還有一些目前看不太出來用途的成員,沒關係,再接著看 PyDictValues 結構:

// 檔案:Include/internal/pycore_dict.h

struct _dictvalues {
    PyObject *values[1];
};

裡面只有個簡單的 values 成員,相當簡單,但看到這裡,我的問題就是如果這麼簡單的結構,為什麼不直接擺在 PyDictKeysObject 裡就好?我們先看看 Python 是怎麼建立一個新的字典物件。

建立字典

照著我們之前學過的流程,這應該會去找 PyDict_Type 型別的 tp_new 成員:

// 檔案:Objects/dictobject.c

static PyObject *
dict_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    // ... 略 ...
    PyObject *self = type->tp_alloc(type, 0);
    if (self == NULL) {
        return NULL;
    }

    // ... 略 ...

    return self;
}

果然也是先去找 tp_alloc,結果會發現字典的 tp_alloc 成員也是指向 _PyType_AllocNoTrack() 函數,這跟在上個章節看到的串列物件是一樣的,也就是說串列跟字典雖然看起來結構不太一樣,但在計算以及取得記憶體的方式是一樣的。再回來 dict_new 往下看:

// 檔案:Objects/dictobject.c

static PyObject *
dict_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    // ... 略 ...
    PyDictObject *d = (PyDictObject *)self;

    d->ma_used = 0;
    d->ma_version_tag = DICT_NEXT_VERSION(
            _PyInterpreterState_GET());
    dictkeys_incref(Py_EMPTY_KEYS);
    d->ma_keys = Py_EMPTY_KEYS;
    d->ma_values = NULL;

    // ... 略 ...
}

看起來是在做初始化,把 ma_used 設定成 0,表示目前字典裡沒有元素,然後把 ma_values 設定成 NULL 表示現在沒有值,這些比較容易理解。但 ma_keys 指向的 Py_EMPTY_KEYS 是什麼:

// 檔案:Objects/dictobject.c
static PyDictKeysObject empty_keys_struct = {
        _Py_IMMORTAL_REFCNT, /* dk_refcnt */
        0, /* dk_log2_size */
        0, /* dk_log2_index_bytes */
        DICT_KEYS_UNICODE, /* dk_kind */
        1, /* dk_version */
        0, /* dk_usable (immutable) */
        0, /* dk_nentries */
        {DKIX_EMPTY, DKIX_EMPTY, DKIX_EMPTY, DKIX_EMPTY,
         DKIX_EMPTY, DKIX_EMPTY, DKIX_EMPTY, DKIX_EMPTY}, /* dk_indices */
};

Py_EMPTY_KEYS 其實就是一個 PyDictKeysObject 物件,不過比較特別是這個空字典不能被改變,也是個不會被銷毀的不死身物件,這樣可以讓所有的空字典共用這個物件,這樣可以節省記憶體,也可以避免重複建立空字典物件。

另外,在這個空的 Key 物件的最後放了 8 個 DKIX_EMPTYDKIX_EMPTY 的值是 -1,用來表示這個位置是空的,如果在查找的過程中得到這個值,表示這個位置是空的,不用再繼續往下找。

也就是說,即使是空的字典,也還是會分配一些空間給它,的確這樣會造成一些浪費,但站在效能的角度來看,對於不到 8 個 Key/Value 的字典來說,可以直接使用這個預分配的空間,可以省下一些記憶體的分配時間,就是有點用空間換取時間的概念。

看到這裡,我好像稍微可以理解為什麼字典的 Key 跟 Value 要分開成不同的物件了。

新增元素

知道大概字典物件的結構後,接著來看看新增元素的流程。假設我有個空的字典,然後試著在這個字典裡新增一個 Key,並且把 "悟空" 指定給它:

hero = {}
hero["name"] = "悟空"

字典透過中括號存取元素的過程,根據我們看到現在,應該能猜出來是放在 PyDict_Typetp_as_mapping 成員裡:

// 檔案:Objects/dictobject.c

static PyMappingMethods dict_as_mapping = {
    (lenfunc)dict_length, /*mp_length*/
    (binaryfunc)dict_subscript, /*mp_subscript*/
    (objobjargproc)dict_ass_sub, /*mp_ass_subscript*/
};

hero["name"] = "悟空" 這個行為,應該會觸發 dict_ass_sub 方法:

// 檔案:Objects/dictobject.c

static int
dict_ass_sub(PyDictObject *mp, PyObject *v, PyObject *w)
{
    if (w == NULL)
        return PyDict_DelItem((PyObject *)mp, v);
    else
        return PyDict_SetItem((PyObject *)mp, v, w);
}

這段滿容易懂的,接下來應該就是呼叫 PyDict_SetItem() 函數,不過追進 PyDict_SetItem() 會看到又呼叫了 _PyDict_SetItem_Take2(),我猜 _Take2 這名字大概是想不到好名字所以就先這樣命名的吧。

// 檔案:Objects/dictobject.c

int
_PyDict_SetItem_Take2(PyDictObject *mp, PyObject *key, PyObject *value)
{
    // ... 略 ...
    Py_hash_t hash;
    if (!PyUnicode_CheckExact(key) || (hash = unicode_get_hash(key)) == -1) {
        hash = PyObject_Hash(key);
        if (hash == -1) {
            Py_DECREF(key);
            Py_DECREF(value);
            return -1;
        }
    }
    // ... 略 ...
}

首先會先檢查這個 key 是不是 Unicode 字串,如果是的話就執行 unicode_get_hash() 計算雜湊值,否則就執行 PyObject_Hash() 函數計算雜湊值。我個人其實沒很喜歡這麼「簡潔」的寫法,但也許是我自己能力不足所以得多花一點時間看懂它。先看看 unicode_get_hash() 怎麼計算:

// 檔案:Objects/dictobject.c

static inline Py_hash_t
unicode_get_hash(PyObject *o)
{
    assert(PyUnicode_CheckExact(o));
    return _PyASCIIObject_CAST(o)->hash;
}

這個函數用了 inline 的寫法,表示這個函數會被直接插入到呼叫的地方,雖然編譯之後的檔案可能會大一點,但可以省下一些函數呼叫的時間,看起來是要拼速度的樣子,也是用空間換時間的做法。而這個函數的結果就只是回傳我們之前介紹過的 PyASCIIObjecthash 成員而已,這個 hash 是在建立 Unicode 物件時就計算好的。那另一個 PyObject_Hash() 函數又是怎麼計算的呢?

// 檔案:Objects/object.c

Py_hash_t
PyObject_Hash(PyObject *v)
{
    PyTypeObject *tp = Py_TYPE(v);
    if (tp->tp_hash != NULL)
        return (*tp->tp_hash)(v);

    // ... 略 ...
    return PyObject_HashNotImplemented(v);
}

就是直接回傳這個物件的型別的 tp_hash 成員而已。知道雜湊值怎麼算之後,再回來往下看:

// 檔案:Objects/dictobject.c

int
_PyDict_SetItem_Take2(PyDictObject *mp, PyObject *key, PyObject *value)
{
    // ... 略 ...
    PyInterpreterState *interp = _PyInterpreterState_GET();
    if (mp->ma_keys == Py_EMPTY_KEYS) {
        return insert_to_emptydict(interp, mp, key, hash, value);
    }

    return insertdict(interp, mp, key, hash, value);
}

如果這個字典是空的,也就是 ma_keysPy_EMPTY_KEYS,會呼叫 insert_to_emptydict() 函數,否則就執行 insertdict() 函數,這兩個函數應該就是這整串流程的重點了。先看 insert_to_emptydict() 函數,不過這個函數做的事比較多,我們先看第一部份:

// 檔案:Objects/dictobject.c

static int
insert_to_emptydict(PyInterpreterState *interp, PyDictObject *mp,
                    PyObject *key, Py_hash_t hash, PyObject *value)
{
    // ... 略 ...
    PyDictKeysObject *newkeys = new_keys_object(
            interp, PyDict_LOG_MINSIZE, unicode);

    // ... 略 ...
    mp->ma_keys = newkeys;
    mp->ma_values = NULL;

    MAINTAIN_TRACKING(mp, key, value);

    size_t hashpos = (size_t)hash & (PyDict_MINSIZE-1);
    dictkeys_set_index(mp->ma_keys, hashpos, 0);
}

這裡呼叫 new_keys_object() 函數建立一個新的 PyDictKeysObject 物件,然後把這個物件指定給 ma_keys 成員,然後把 ma_values 設定成 NULL

然後我們剛剛不是有算出這個 Key 對應到的雜湊值嗎?在這裡會再利用這個雜湊值來計算它的「位置」。這裡 PyDict_MINSIZE 的值是 8,所以 & (PyDict_MINSIZE-1) 的效果等同於 % 8,也就是取 8 的餘數,只是用二進位的方式運算比較快而已。要算什麼位置?就是它待會在這顆 PyDictKeysObject 物件,也就是 newkeys 裡的 dk_indices 陣列裡的哪個位置。

你可能會想,如果是取 8 的餘數,那不是只有 0 到 7 共 8 個位置嗎?格子夠不夠是一回事,晚點我們可以再想辦法增加格子,但這樣不會重複嗎?當然有機會重複的,當發生重複的 hashpos 時,又稱為「雜湊碰撞(Hash Collision)」。Python 使用「開放定址法(Open Addressing)」的策略來解決,這策略聽起來很厲害,但其實就是「找下一個沒人用的空間」而已。舉個例子,例如原本有 8 個格子,全部都是空的:

dk_indices:
   0    1    2    3    4    5    6    7
+----+----+----+----+----+----+----+----+
|    |    |    |    |    |    |    |    |
+----+----+----+----+----+----+----+----+

如果現在來個 hero["aa"] = "Hello",表示要新增一個 Key 叫做 "aa",假設這個 "aa" 字串的 hash 值 81761723,那麼 hashpos 就是 81761723 % 8,也就是 3,這時候就會把這個 key 放在索引值 3 的位置:

dk_indices:
   0    1    2    3    4    5    6    7
+----+----+----+----+----+----+----+----+
|    |    |    | aa |    |    |    |    |
+----+----+----+----+----+----+----+----+

同理,hero["bb"] = "World",表示要加一個 Key "bb",假設 "bb" 字串的 hash 值是 28716210,那麼它的 hashpos 就是 28716210 % 8,也是 2,它就會被放在索引值 2 的位置:

dk_indices:
   0    1    2    3    4    5    6    7
+----+----+----+----+----+----+----+----+
|    |    | bb | aa |    |    |    |    |
+----+----+----+----+----+----+----+----+

到這裡都還很順利,但如果算出來的索引值已經有東西了呢?

處理雜湊碰撞

我想要再來個 hero["cc"] = "Kitty",表示要再加一個 Key "cc",假設 "cc" 字串的 hash 值是 14500523,計算出來的 hashpos 是 3,但是這個位置已經有東西了,這就是所謂的碰撞。

發生碰撞的時候,Python 會試著幫我們找下一個可以用的空間。最直覺的想法可能會是 i + 1 找下一個,萬一還是被佔用就 i + 2,依此類推。像這樣的「線性探測(Linear Probing)」的策略是單純,但這樣可能會造成「聚集(Clustering)」的情形,也就是越後面的位置越容易被佔滿,查找的時間就會增加。老實說我想都沒想過這種問題,我也是看到原始碼才知道為了效能,需要考慮的事情這麼多。

Python 遇到碰撞的時候採用的不是 i + 1 的線性探測,而是採用另外的計算公式:

i = ((5 * i) + 1) % 8

不只這樣,Python 為了增加隨機性,還會對 Key 的 hash 下手,讓它一次右移 5 個位元,讓這個右移之後的結果加到原本的公式裡:

i = ((5 * i) + p + 1) % 8

這個 p 就是 hash 右移 5 個位元的結果,目的是讓碰撞的處理更加均勻。當然一直往右移到最後 p 也會變 0,最後還是變回原本的公式,但至少在過程中想辦法增加一些隨機性。如果大家有機會在翻 CPython 原始碼的時候看到這樣的寫法:

perturb >>= PERTURB_SHIFT;
i = mask & (i*5 + perturb + 1);

其實就是在做這件事,其中 PERTURB_SHIFT 在 CPython 的值就是 5。那我們把 "cc" 字串的 hash 值 14500523 代入這個公式:

i = ((5 * 3) + (14500523 >> 5) + 1) % 8

計算之後會得到 5,這樣 "cc" 就會被放在索引值 5 的位置:

dk_indices:
   0    1    2    3    4    5    6    7
+----+----+----+----+----+----+----+----+
|    |    | bb | aa |    | cc |    |    |
+----+----+----+----+----+----+----+----+

用這種方式,在空間足夠的情況下,可以讓平均查找時間接近 O(1),因為大多數空間足夠的情況下,都能透過 hashpos 的計算直接找到正確的位置。知道 Key 放哪裡後,接下來就是把 Key 跟 Value 放進去:

// 檔案:Objects/dictobject.c

static int
insert_to_emptydict(PyInterpreterState *interp, PyDictObject *mp,
                    PyObject *key, Py_hash_t hash, PyObject *value)
{
    // ... 略 ...
    if (unicode) {
        PyDictUnicodeEntry *ep = DK_UNICODE_ENTRIES(mp->ma_keys);
        ep->me_key = key;
        ep->me_value = value;
    }
    else {
        PyDictKeyEntry *ep = DK_ENTRIES(mp->ma_keys);
        ep->me_key = key;
        ep->me_hash = hash;
        ep->me_value = value;
    }
    mp->ma_used++;
    mp->ma_version_tag = new_version;
    mp->ma_keys->dk_usable--;
    mp->ma_keys->dk_nentries++;
    return 0;
}

這裡會根據 Key 是不是 Unicode 字串來決定要放在哪種結構裡,可能是 PyDictKeyEntry 或是 PyDictUnicodeEntry,這兩個的結構如下:

// 檔案:Include/internal/pycore_dict.h

typedef struct {
    Py_hash_t me_hash;
    PyObject *me_key;
    PyObject *me_value;
} PyDictKeyEntry;

typedef struct {
    PyObject *me_key;
    PyObject *me_value;
} PyDictUnicodeEntry;

其實就是個 Key 跟 Value 的組合而已。在函數的最後更新 ma_usedma_version_tagdk_usabledk_nentries 等成員,這樣就完成了一個 Key/Value 的新增操作。

但是這個 Entry 擺在哪裡?其實它是緊跟著剛剛前面建立的 newkeys,也就是 PyDictKeysObject 物件的後面,如果要找到它們的話,就是透過 DK_ENTRIES() 或是 DK_UNICODE_ENTRIES() 這兩個巨集,如果追過去看的話,會發現這兩個巨集都是根據 ma_keys 的記憶體位置算出來在哪裡。是說這怎麼看出來的?在 new_keys_object() 函數最後有這麼一段:

// 檔案:Objects/dictobject.c

dk = PyObject_Malloc(sizeof(PyDictKeysObject)
                     + ((size_t)1 << log2_bytes)
                     + entry_size * usable);

照理說跟系統要記憶體應該就取得夠用的就好,但在這裡卻多要了 entry_size * usable 的空間,多要的這個空間就是用來放這些 Entry 的。

但這些物件們的關連要怎麼串起來?還記得剛才有透過取 8 的餘數計算索引值然後把 Key 擺放在指定的格子裡嗎?

dk_indices:
   0    1    2    3    4    5    6    7
+----+----+----+----+----+----+----+----+
|    |    | bb | aa |    | cc |    |    |
+----+----+----+----+----+----+----+----+

但其實並不是在這個格子裡直接存放 "aa""bb""cc" 這些 Key,而是會存放這個 Key 對應的 Entry 的位置。什麼意思?讓我再畫個圖:

dk_indices:
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
|   |   | 2 | 1 |   | 3 |   |   |
+---+---+---+---+---+---+---+---+

dk_entries:
+---+----------------------------+
| 0 | DKIX_DUMMY                 |
+---+----------------------------+
| 1 | {81761723, "aa", "Hello"}  |
+---+----------------------------+
| 2 | {28716210, "bb", "World"}  |
+---+----------------------------+
| 3 | {14500523, "cc", "Kitty"}  |
+---+----------------------------+

Entry 是按照新增的順序填充進去的,而在 dk_indices 只會存放這些 Entry 的索引值,這樣在查找的時候就可以直接找到這個 Entry 的位置,這樣就可以達到快速查找的效果。

雖然有點複雜,但看起來好像可以對起來沒錯...等等,剛剛有提到如果發生碰撞的時候,會往下找下一個有空位的地方,那這麼不就會造成索引對不起來了嗎?這是個好問題,讓我們來看看查找元素的流程。

查找元素

當在 Python 的字典裡找一個 Key 的時候,例如 hero["aa"],會經過以下步驟:

  • A. Key "aa" 本身是個字串物件,所以首先先取得這個 "aa" 字串的 hash 值,以上面的例子來說會得到 81761723。
  • B. 計算這個 hash 值對應的索引值,也就是前面提到的取 8 的餘數得到的結果,會得到 3。
  • C. 拿著索引值 3 在 dk_indices 找到對應的值,得到 1。
  • D. 再拿著索引值 1 去 dk_entries 陣列拿資料,找到對應的 Entry,最終可以得到 "Hello" 字串。

如果找不到,就會在步驟 C 的時候發現在 dk_indices 的對應格子裡是空的,也就是得到 DKIX_EMPTY,表示這個 Key 不存在,然後丟出 KeyError 的例外。

問題是,如果發生碰撞的時候呢?例如上面例子的 hero["cc"]

  • A. 同樣先取得 "cc" 字串的 hash 值,得到 14500523。
  • B. 餘數計算後得到索引值 3。
  • C. 拿著索引值 3 到 dk_indices 找到對應的值,得到 1。
  • D. 拿著索引值 1 去 dk_entries 陣列拿資料,找到對應的 Entry,但比對之後就會發現這個 Key 的 hash 值對不起來,表示這個 Entry 並不是我要找的那個。
  • E. 退回步驟 C,但這次會用前面提過的計算公式 (5 x 3 + perturb + 1) % 8 計算的結果,得到 5。
  • F. 拿著新的索引值 5 再次進到 dk_entries 找到對應的 Entry,經比對後發現這就是我要找的那個,打完收工!
  • G. 如果在步驟 F 還是找不到,會再退回步驟 C,這次用拿剛剛算出來的 5 帶入公式變成 (5 x 5 + perturb + 1) % 8,計算索引值再進行步驟 D,以此類推。
  • H. 萬一找到最後一個都找不到,或是在找的過程得到 DKIX_EMPTY,就表示這個 Key 不存在這個字典裡,丟出 KeyError 的例外。

字典的查找流程看起來有點複雜,但這些計算的速度都滿快的,可以讓字典在大多數情況下都能達到 O(1) 的查找效率。是說如果遇到碰撞的次數越頻繁,效能就會變差,這表示 dk_indices 的空間不夠用了,是時候該擴建啦,我們下個章節就來看看字典的擴建流程,看看應該擴建應該增加多少容積率... 啊,不是,是多少空間才夠用。

本文同步刊載於 「為你自己學 Python - 字典的整理收納課(上)


上一篇
Day 14 - 串列的排隊人生
下一篇
Day 16 - 字典的整理收納課(下)
系列文
為你自己讀 CPython 原始碼21
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言