解密 Python 元組的實(shí)現(xiàn)原理
楔子
本篇文章來(lái)聊一聊元組,元組可以簡(jiǎn)單理解為不支持元素添加、修改、刪除等操作的列表,也就是在列表的基礎(chǔ)上移除了增刪改操作。
所以從功能上來(lái)講,元組只是列表的子集,那元組存在的意義是什么呢?首先元組可以作為字典的 key 以及集合的元素,因?yàn)樽值浜图鲜褂玫臄?shù)據(jù)結(jié)構(gòu)是哈希表,它存儲(chǔ)的元素一定是可哈希的,關(guān)于字典和集合我們后續(xù)章節(jié)會(huì)說(shuō)。
而列表可以動(dòng)態(tài)改變,所以列表不支持哈希。因此當(dāng)我們希望字典的 key 是一個(gè)序列時(shí),顯然元組再適合不過(guò)了。比如要根據(jù)年齡和身高統(tǒng)計(jì)人數(shù),那么就可以將年齡和身高組成元組作為字典的 key,人數(shù)作為字典的 value。所以元組可哈希,能夠作為哈希表的 key,是元組存在的意義之一。當(dāng)然元組還有其它作用,我們稍后再說(shuō)。
元組如果可哈希,那么元組存儲(chǔ)的元素必須都是可哈希的。只要有一個(gè)元素不可哈希,那么元組就會(huì)不可哈希。比如元組里面存儲(chǔ)了一個(gè)列表,由于列表不可哈希,導(dǎo)致存儲(chǔ)了列表的元組也會(huì)變得不可哈希。
元組的底層結(jié)構(gòu)
根據(jù)我們使用元組的經(jīng)驗(yàn),可以得出元組是一個(gè)變長(zhǎng)對(duì)象,但同時(shí)又是一個(gè)不可變對(duì)象。
// Include/cpython/tupleobject.h
typedef struct {
PyObject_VAR_HEAD
PyObject *ob_item[1];
} PyTupleObject;
以上是元組在底層對(duì)應(yīng)的結(jié)構(gòu)體,包含引用計(jì)數(shù)、類(lèi)型、ob_size、指針數(shù)組。然后數(shù)組聲明的長(zhǎng)度雖然是 1,但我們可以當(dāng)成 n 來(lái)用。
然后我們?cè)偻ㄟ^(guò)結(jié)構(gòu)體的定義,來(lái)對(duì)比一下它和列表的區(qū)別。首先元組沒(méi)有 allocated、也就是容量的概念,這是因?yàn)樗遣豢勺兊?,不支?resize 操作。
另一個(gè)區(qū)別就是元組對(duì)應(yīng)的指針數(shù)組是定義在結(jié)構(gòu)體里面的,可以直接對(duì)數(shù)組進(jìn)行操作。而列表對(duì)應(yīng)的指針數(shù)組是定義在結(jié)構(gòu)體外面的,兩者通過(guò)二級(jí)指針進(jìn)行關(guān)聯(lián),也就是通過(guò)二級(jí)指針來(lái)間接操作指針數(shù)組。
至于為什么要這么定義,我們?cè)谧铋_(kāi)始介紹對(duì)象模型的時(shí)候也說(shuō)得很詳細(xì)了??勺儗?duì)象的具體元素不會(huì)保存在結(jié)構(gòu)體內(nèi)部,而是會(huì)維護(hù)一個(gè)指針,指針指向的內(nèi)存區(qū)域負(fù)責(zé)存儲(chǔ)元素。當(dāng)發(fā)生擴(kuò)容時(shí),只需改變指針指向即可,從而方便內(nèi)存管理。
基于結(jié)構(gòu)體的定義,我們也能分析出元組所占的內(nèi)存大小,顯然它等于 24 + 8 * 元組長(zhǎng)度。
圖片
結(jié)果沒(méi)有問(wèn)題。
元組是怎么創(chuàng)建的?
元組支持的操作我們就不看了,因?yàn)樗恢С植樵?xún)操作,并且和列表是高度相似的。這里我們直接來(lái)看元組的創(chuàng)建過(guò)程。
正如列表一樣,解釋器為創(chuàng)建 PyTupleObject 也提供了類(lèi)似的初始化方法,即 PyTuple_New。
// Objects/tupleobject.c
PyObject *
PyTuple_New(Py_ssize_t size)
{
// 參數(shù) size 表示元組的長(zhǎng)度
PyTupleObject *op;
// 如果 size 為 0,返回空數(shù)組
if (size == 0) {
return tuple_get_empty();
}
// 調(diào)用 tuple_alloc 為元組申請(qǐng)內(nèi)存
op = tuple_alloc(size);
// 如果返回的指針為 NULL,表示申請(qǐng)失敗
if (op == NULL) {
return NULL;
}
// 將指針數(shù)組中所有元素設(shè)置為 NULL
for (Py_ssize_t i = 0; i < size; i++) {
op->ob_item[i] = NULL;
}
// 讓 GC 進(jìn)行跟蹤
_PyObject_GC_TRACK(op);
// 轉(zhuǎn)成泛型指針之后返回
return (PyObject *) op;
}
相信這種代碼邏輯現(xiàn)在對(duì)你來(lái)說(shuō)已經(jīng)沒(méi)有任何難度了,然后我們看到里面調(diào)用了 tuple_alloc,該函數(shù)實(shí)際負(fù)責(zé)元組的內(nèi)存申請(qǐng)過(guò)程,來(lái)看看它的內(nèi)部實(shí)現(xiàn)。
// Objects/tupleobject.c
static PyTupleObject *
tuple_alloc(Py_ssize_t size)
{
// size 必須大于等于 0
if (size < 0) {
PyErr_BadInternalCall();
return NULL;
}
// 優(yōu)先從緩存池中獲取,所以元組也有緩存池
// 關(guān)于元組的緩存池稍后再聊
PyTupleObject *op = maybe_freelist_pop(size);
// 如果 op 為 NULL,說(shuō)明緩存池?zé)o可用元素
if (op == NULL) {
// size * sizeof(PyObject *) + sizeof(PyTupleObject) 便是元組大小
// 該值不能超過(guò) PY_SSIZE_T_MAX,否則報(bào)錯(cuò)
if ((size_t)size > ((size_t)PY_SSIZE_T_MAX - (sizeof(PyTupleObject) -
sizeof(PyObject *))) / sizeof(PyObject *)) {
return (PyTupleObject *)PyErr_NoMemory();
}
// 為 PyTupleObject 和長(zhǎng)度為 size 的指針數(shù)組申請(qǐng)內(nèi)存
// 然后將它的類(lèi)型設(shè)置為 &PyTuple_Type,將 ob_size 設(shè)置為 size
op = PyObject_GC_NewVar(PyTupleObject, &PyTuple_Type, size);
if (op == NULL)
return NULL;
}
// 返回 op
return op;
}
tuple_alloc 負(fù)責(zé)申請(qǐng)內(nèi)存,當(dāng)內(nèi)存申請(qǐng)完畢之后,PyTuple_New 再將它的 ob_item 里面的元素設(shè)置為 NULL,即初始化。
以上就是元組創(chuàng)建的過(guò)程,但里面隱藏了很多的細(xì)節(jié)沒(méi)有說(shuō),下面我們來(lái)介紹元組的緩存池,然后將細(xì)節(jié)一一揭開(kāi)。
元組的緩存池
元組的緩存池也是通過(guò)數(shù)組來(lái)實(shí)現(xiàn)的。
// Include/internal/pycore_tuple.h
#define PyTuple_MAXSAVESIZE 20
#define PyTuple_NFREELISTS PyTuple_MAXSAVESIZE
#define PyTuple_MAXFREELIST 2000
struct _Py_tuple_state {
PyTupleObject *free_list[PyTuple_NFREELISTS];
int numfree[PyTuple_NFREELISTS];
};
里面出現(xiàn)了三個(gè)宏:
- PyTuple_MAXSAVESIZE:緩存池的大小,默認(rèn)為 20;
- PyTuple_NFREELISTS:緩存池的每個(gè)元素都對(duì)應(yīng)一條鏈表(稍后解釋?zhuān)摵瓯硎炬湵淼臄?shù)量,因此它和 PyTuple_MAXSAVESIZE 是等價(jià)的,也表示緩存池的大?。?/li>
- PyTuple_MAXFREELIST:每個(gè)鏈表最多容納多少個(gè)節(jié)點(diǎn)(稍后解釋?zhuān)?/li>
從定義中可以看到,元組的緩存池大小是 20,而我們之前介紹的列表的緩存池大小是 80。但這里的 20 和 80 還稍微有些不同,80 指的是列表緩存池的大小,除此之外沒(méi)有別的含義。而 20 除了表示元組緩存池的大小之外,它還表示只有當(dāng)元組的長(zhǎng)度不超過(guò) 20,回收時(shí)才會(huì)被放入緩存池。
當(dāng)元組的長(zhǎng)度為 n 時(shí)(其中 n <= 20),那么在回收的時(shí)候該元組就會(huì)放在緩存池中索引為 n - 1 的位置。假設(shè)回收的元組長(zhǎng)度為 6,那么就會(huì)放在緩存池索引為 5 的位置。
但是問(wèn)題來(lái)了,如果要回收兩個(gè)長(zhǎng)度為 6 的元組該怎么辦?很簡(jiǎn)單,像鏈表一樣串起來(lái)就好了。所以 free_list 里面雖然存儲(chǔ)的是 PyTupleObject *,但每個(gè) (PyTupleObject *)->ob_item[0] 都存儲(chǔ)了下一個(gè) PyTupleObject *。
因此你可以認(rèn)為 free_list 存儲(chǔ)了 20 條鏈表的頭結(jié)點(diǎn)的指針,每條鏈表上面掛著具有相同 ob_size 的 PyTupleObject。比如 free_list[n - 1] 便指向了長(zhǎng)度為 n 的 PyTupleObject 組成的鏈表的頭結(jié)點(diǎn)。
至于每條鏈表的節(jié)點(diǎn)個(gè)數(shù)由 numfree 維護(hù),并且最大不能超過(guò) PyTuple_MAXFREELIST,默認(rèn)是 2000。
圖片
這里再來(lái)重新捋一下,元組的緩存池是一個(gè)數(shù)組,并且索引為 n - 1 的位置回收的是元素個(gè)數(shù)(ob_size)為 n 的元組,并且 n 不超過(guò) 20。但這樣的話(huà),具有相同長(zhǎng)度的元組不就只能緩存一個(gè)了嗎?比如我們有很多個(gè)長(zhǎng)度為 2 的元組都要緩存怎么辦呢?
顯然將它們以鏈表的形式串起來(lái)即可,正如圖中顯示的那樣。至于長(zhǎng)度為 n 的元組究竟緩存了多少個(gè),則由 numfree[n-1] 負(fù)責(zé)維護(hù)。假設(shè) free_list[2] 這條鏈表上掛了 1000 個(gè) PyTupleObject,那么 numfree[2] 就等于 1000,即長(zhǎng)度為 3 的元組被緩存了 1000 個(gè)。
當(dāng)再回收一個(gè)長(zhǎng)度為 3 的元組時(shí),那么會(huì)讓該元組的 ob_item[0] 等于 free_list[2],然后 free_list[2] 等于該元組、numfree[2]++。所以這里的每一條鏈表和浮點(diǎn)數(shù)緩存池是類(lèi)似的,也是采用的頭插法。
我們看一下放入緩存池的具體過(guò)程,顯然這一步發(fā)生在元組銷(xiāo)毀的時(shí)候。
// Objects/tupleobject.c
static void
tupledealloc(PyTupleObject *op)
{
// 緩存池存放的是長(zhǎng)度為 1 ~ 20 的元組
// 如果是空元組,那么它是單例的永恒對(duì)象,會(huì)永遠(yuǎn)存在
// 因此這里會(huì)直接返回,不做任何額外操作
if (Py_SIZE(op) == 0) {
if (op == &_Py_SINGLETON(tuple_empty)) {
return;
}
// 讓 GC 不再跟蹤
PyObject_GC_UnTrack(op);
// 延遲釋放,和列表是類(lèi)似的
Py_TRASHCAN_BEGIN(op, tupledealloc)
// 獲取銷(xiāo)毀的元組的長(zhǎng)度
Py_ssize_t i = Py_SIZE(op);
// 減少內(nèi)部元素指向?qū)ο蟮囊糜?jì)數(shù),因?yàn)樵M不再持有對(duì)它們的引用
while (--i >= 0) {
Py_XDECREF(op->ob_item[i]);
}
// 嘗試放入緩存池
if (!maybe_freelist_push(op)) {
// 如果元組長(zhǎng)度大于 20,或者緩存池已滿(mǎn)
// 那么釋放內(nèi)存
Py_TYPE(op)->tp_free((PyObject *)op);
}
Py_TRASHCAN_END
}
/* 在前面的章節(jié)中我們說(shuō)了,在 3.12 之前
對(duì)象的緩存池是以靜態(tài)全局變量的形式定義在文件中,以 3.8 的元組緩存池為例
static PyTupleObject *free_list[PyTuple_MAXSAVESIZE];
static int numfree[PyTuple_MAXSAVESIZE];
但在 3.12 的時(shí)候則被封裝在了一個(gè)結(jié)構(gòu)體(_Py_tuple_state)中
在解釋器啟動(dòng)之后會(huì)靜態(tài)初始化好,并綁定在進(jìn)程狀態(tài)對(duì)象上面 */
// 所以這里的 STATE 便負(fù)責(zé)獲取元組的緩存池,即 _Py_tuple_state 結(jié)構(gòu)體實(shí)例
// 它里面包含了 numfree 和 free_list
#define STATE (interp->tuple)
// 將元組放入緩存池
static inline int
maybe_freelist_push(PyTupleObject *op)
{
#if PyTuple_NFREELISTS > 0
// 獲取進(jìn)程狀態(tài)對(duì)象,interp->tuple 便是元組的緩存池
PyInterpreterState *interp = _PyInterpreterState_GET();
// 如果元組長(zhǎng)度為 0,不做處理
if (Py_SIZE(op) == 0) {
return0;
}
// free_list 里面的每個(gè)元素都指向了一個(gè)鏈表的頭結(jié)點(diǎn)
// 每條鏈表存放的元組都具有相同的長(zhǎng)度,如果元組長(zhǎng)度為 n
// 那么它會(huì)放在 free_list[n - 1] 對(duì)應(yīng)的鏈表中
Py_ssize_t index = Py_SIZE(op) - 1;
// index 必須小于 20,即元組長(zhǎng)度不超過(guò) 20
// STATE.numfree[index] 必須小于 2000,即每條鏈表最多緩存 2000 個(gè)元組
if (index < PyTuple_NFREELISTS
&& STATE.numfree[index] < PyTuple_MAXFREELIST
&& Py_IS_TYPE(op, &PyTuple_Type))
{
// ob_item[0] 充當(dāng)了鏈表的 next 指針
// 這里讓 op->ob_item[0] 等于 free_list[index]
// 然后讓 free_list[index] 等于 op
// 這樣元組就緩存起來(lái)了,并成為鏈表新的頭結(jié)點(diǎn),即 free_list[index]
op->ob_item[0] = (PyObject *) STATE.free_list[index];
STATE.free_list[index] = op;
// 然后維護(hù)一下鏈表的節(jié)點(diǎn)個(gè)數(shù)
STATE.numfree[index]++;
OBJECT_STAT_INC(to_freelist);
return1;
}
#endif
return0;
}
tupledealloc 函數(shù)在銷(xiāo)毀元組時(shí),會(huì)調(diào)用 maybe_freelist_push 函數(shù),嘗試放入緩存池中。那么同理,tuple_alloc 函數(shù)在創(chuàng)建元組時(shí),也會(huì)調(diào)用 maybe_freelist_pop 函數(shù),嘗試從緩存池中獲取。
// Objects/tupleobject.c
static inline PyTupleObject *
maybe_freelist_pop(Py_ssize_t size)
{
#if PyTuple_NFREELISTS > 0
// 獲取進(jìn)程狀態(tài)對(duì)象
PyInterpreterState *interp = _PyInterpreterState_GET();
// size 不可能為 0
// 如果 size 為 0,那么在 PyTuple_New 中會(huì)直接返回空元組
if (size == 0) {
return NULL;
}
assert(size > 0);
// 緩存池中每個(gè)元素都指向一個(gè)鏈表的頭結(jié)點(diǎn)
// PyTuple_NFREELISTS 表示鏈表的個(gè)數(shù),PyTuple_MAXSAVESIZE 表示緩存池的大小
// 所以這兩個(gè)宏是等價(jià)的,默認(rèn)都是 20
// 只有元組的長(zhǎng)度不超過(guò) 20 的時(shí)候,才會(huì)被緩存
if (size <= PyTuple_MAXSAVESIZE) {
Py_ssize_t index = size - 1;
// 獲取鏈表的頭節(jié)點(diǎn)
PyTupleObject *op = STATE.free_list[index];
if (op != NULL) {
// 獲取之后,它的下一個(gè)節(jié)點(diǎn)要成為新的頭結(jié)點(diǎn)
STATE.free_list[index] = (PyTupleObject *) op->ob_item[0];
// 鏈表節(jié)點(diǎn)個(gè)數(shù)減 1
STATE.numfree[index]--;
// 增加引用計(jì)數(shù)之后返回
_Py_NewReference((PyObject *)op);
OBJECT_STAT_INC(from_freelist);
return op;
}
}
#endif
return NULL;
}
到此,相信你已經(jīng)明白元組的緩存池到底是怎么一回事了,說(shuō)白了就是有 20 條鏈表,分別負(fù)責(zé)緩存長(zhǎng)度為 1 ~ 20 的元組,它們的頭結(jié)點(diǎn)指針會(huì)保存在緩存池中。
然后每條鏈表的長(zhǎng)度不超過(guò) 2000,也就是具有相同長(zhǎng)度的元組最多回收 2000 個(gè)。至于鏈表的 next 指針,則由元組的 ob_item[0] 來(lái)充當(dāng),通過(guò) ob_item[0] 來(lái)獲取下一個(gè)節(jié)點(diǎn)。
圖片
我們看到打印的地址是一樣的,因?yàn)榈谝淮蝿?chuàng)建的元組被重復(fù)利用了。
那么問(wèn)題來(lái)了,為什么元組緩存池可以緩存的元組個(gè)數(shù)會(huì)這么多,每個(gè)鏈表緩存 2000 個(gè),有 20 條鏈表,總共可以緩存 40000 個(gè)。這么做的原因就是,元組的使用頻率遠(yuǎn)比我們想象的廣泛,主要是它大量使用在我們看不到的地方。比如多元賦值:
a, b, c, d = 1, 2, 3, 4
在編譯時(shí),上面的 1, 2, 3, 4 實(shí)際上是作為元組被加載的,整個(gè)賦值相當(dāng)于元組的解包。再比如函數(shù)、方法的返回值,如果是多返回值,本質(zhì)上也是包裝成一個(gè)元組之后再返回。
所以元組緩存池能緩存的對(duì)象個(gè)數(shù),要遠(yuǎn)大于其它對(duì)象的緩存池??梢韵胂笠粋€(gè)大型項(xiàng)目,里面的函數(shù)、方法不計(jì)其數(shù),只要是多返回值,就會(huì)涉及到元組的創(chuàng)建,因此每種長(zhǎng)度的元組緩存 2000 個(gè)是很合理的。當(dāng)然如果長(zhǎng)度超過(guò) 20,就不會(huì)緩存了,這種元組的使用頻率沒(méi)有那么高。
然后再回顧一下元組的回收過(guò)程,會(huì)發(fā)現(xiàn)它和列表有一個(gè)很大的不同。列表在被回收時(shí),它的指針數(shù)組會(huì)被釋放;但元組不同,它在被回收時(shí),底層的指針數(shù)組會(huì)保留,并且還巧妙地通過(guò)索引來(lái)記錄了回收的元組的大小規(guī)格。
元組的這項(xiàng)技術(shù)也被稱(chēng)為靜態(tài)資源緩存,因?yàn)樵M在執(zhí)行析構(gòu)函數(shù)時(shí),不僅對(duì)象本身沒(méi)有被回收,連底層的指針數(shù)組也被緩存起來(lái)了。那么當(dāng)再次分配時(shí),速度就會(huì)快一些。
from timeit import timeit
t1 = timeit(stmt="x1 = [1, 2, 3, 4, 5]", number=1000000)
t2 = timeit(stmt="x2 = (1, 2, 3, 4, 5)", number=1000000)
print(round(t1, 2)) # 0.05
print(round(t2, 2)) # 0.01
可以看到耗時(shí),元組只是列表的五分之一。這便是元組的另一個(gè)優(yōu)勢(shì),可以將資源緩存起來(lái)。而緩存的原因還是如上面所說(shuō),因?yàn)樯婕按罅康膭?chuàng)建和銷(xiāo)毀,所以這一切都是為了加快內(nèi)存分配。
由于對(duì)象都在堆區(qū),為了效率,Python 不得不大量使用緩存的技術(shù)。
最后再回答一個(gè)遺漏的部分,就是當(dāng)元組長(zhǎng)度為 0 的情況。我們說(shuō)如果元組長(zhǎng)度為 1 到 20,在回收時(shí)會(huì)被緩存起來(lái),各自對(duì)應(yīng)一條鏈表,鏈表上面能容納的元素個(gè)數(shù)不超過(guò) 2000。
但如果長(zhǎng)度為 0 呢?
圖片
從源碼中可以看到,如果長(zhǎng)度為 0,會(huì)調(diào)用 tuple_get_empty。
// Objects/tupleobject.c
static inline PyObject *
tuple_get_empty(void)
{
return Py_NewRef(&_Py_SINGLETON(tuple_empty));
}
// Include/internal/pycore_global_objects.h
struct _Py_static_objects {
struct {
// ...
PyTupleObject tuple_empty;
// ...
} singletons;
};
// Include/internal/pycore_runtime_init.h
#define _PyRuntimeState_INIT(runtime) \
{ \
.static_objects = { \
.singletons = { \
/* ... */ \
.tuple_empty = { \
.ob_base = _PyVarObject_HEAD_INIT(&PyTuple_Type, 0) \
}, \
/* ... */ \
}, \
}, \
}
所以長(zhǎng)度為 0 的元組是一個(gè)靜態(tài)單例對(duì)象,解釋器啟動(dòng)之后就已經(jīng)初始化好了,并且它也是一個(gè)永恒對(duì)象。
圖片
永恒對(duì)象的引用計(jì)數(shù)為 2 ** 32 - 1,并且不會(huì)發(fā)生變化。
小結(jié)
以上就是元組相關(guān)的內(nèi)容,因?yàn)橛辛肆斜硐嚓P(guān)的經(jīng)驗(yàn),再來(lái)看元組就會(huì)快很多。當(dāng)然啦,元組的一些操作我們沒(méi)有說(shuō),因?yàn)楹蛯?duì)應(yīng)的列表操作是類(lèi)似的。
最后再補(bǔ)充一下,列表是有 __init__ 方法的,而元組沒(méi)有。
圖片
元組的 __init__ 直接繼承 object.__init__。
對(duì)啦,再分享一個(gè)有趣的事情,就是元組的緩存池之前是有 Bug 的,我碰巧發(fā)現(xiàn)并修復(fù)了。具體細(xì)節(jié)可以閱讀這篇文章:我?guī)?CPython 修復(fù)了一個(gè) bug。