iT邦幫忙

2024 iThome 鐵人賽

DAY 17
0
Python

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

Day 17 - 不動如山的 Tuple

  • 分享至 

  • xImage
  •  

本文同步刊載於 「為你自己學 Python - 不動如山的 Tuple

不動如山的 Tuple

為你自己學 Python

Python 裡的 Tuple 是一種不可變的(Immutable)資料結構,它可以用來儲存多個元素,並且可以透過索引值來存取這些元素。Tuple 跟串列有點像,但是 Tuple 是不可變的,也就是說,一旦 Tuple 被建立之後,就不能再新增、刪除或修改裡面的元素。這個章節就來看看它是怎麼設計的,還有哪些有趣的特性。

Tuple 的設計

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

typedef struct {
    PyObject_VAR_HEAD
    PyObject *ob_item[1];
} PyTupleObject;

如果各位是從前面看到這個章節的話,應該對這個結構不陌生了。PyTupleObject 結構的一開頭也有像串列一樣的 PyObject_VAR_HEAD,然後還有一個 ob_item 成員,主要用來存放 Tuple 中的元素。只是,為什麼這裡是寫 ob_item[1] 而不像 PyListObject**ob_items?這個 [1] 又是什麼意思?

這是一個 C 語言的技巧,稱為「彈性陣列成員(Flexible Array Member)」,這樣的寫法可以讓結構的最後一個成員是一個未知大小的陣列,雖然看起來像是一個只有一個元素,但實際上它可以容納任意數量的元素,而且所有元素都是連續儲存的。這樣的設計可以讓 Tuple 在記憶體中佔用連續的空間,這樣一來,Python 就可以更有效率地存取 Tuple 裡的元素。大概看起來像這樣:

+-------------------+
| PyObject_VAR_HEAD |
+-------------------+
| ob_item[0]        |
+-------------------+
| ob_item[1]        |
+-------------------+
| ob_item[2]        |
+-------------------+
| ...               |
+-------------------+
| ob_item[size-1]   |
+-------------------+

而不像串列一樣用 **ob_items 的寫法,最主要是因為 Tuple 的設計不需要支援動態增減元素,所以可以用這種更簡單的方式來實現,執行的效率也會更好。

建立 Tuple

建立 Tuple,應該就是去找 PyTuple_Type 上的 tp_new 成員,它指向 tuple_new() 函數:

// 檔案:Objects/clinic/tupleobject.c.h

static PyObject *
tuple_new(PyTypeObject *type, PyObject *args, PyObject *kwargs)
{
    PyObject *return_value = NULL;
    PyTypeObject *base_tp = &PyTuple_Type;
    PyObject *iterable = NULL;

    // ... 略 ...
    if (PyTuple_GET_SIZE(args) < 1) {
        goto skip_optional;
    }
    iterable = PyTuple_GET_ITEM(args, 0);
skip_optional:
    return_value = tuple_new_impl(type, iterable);

exit:
    return return_value;
}

扣掉一些參數判斷的,首先第一個重點在 PyTuple_GET_ITEM() 巨集,展開後是這樣的:

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

#define PyTuple_GET_ITEM(op, index) (_PyTuple_CAST(op)->ob_item[(index)])

這個巨集是用來取得 Tuple 裡的元素,它會直接存取 Tuple 的 ob_item 陣列,並且回傳指定索引的元素。這個巨集在滿多地方都會用節,算是 Tuple 的重點操作之一。

下一個重點,就是 tuple_new_impl() 函數,這個函數是真正用來建立 Tuple 的:

// 檔案:Objects/tupleobject.c

static PyObject *
tuple_new_impl(PyTypeObject *type, PyObject *iterable)
{
    if (type != &PyTuple_Type)
        return tuple_subtype_new(type, iterable);

    if (iterable == NULL) {
        return tuple_get_empty();
    }
    else {
        return PySequence_Tuple(iterable);
    }
}

這個函數裡面有兩個分支,第一個是檢查是否是自 Tuple 的子類別,例如在 Python 程式可能會這樣寫:

class HelloTuple(tuple):
    pass

如果是這樣的話,就會進入這個分支呼叫 tuple_subtype_new() 函數,但如果追進去看就會發現這個函數本質上還是呼叫 tuple_new_impl(),只是傳入不一樣的參數,這麼做的原因是為了讓 Tuple 的子類別可以自由地擴充或修改建立 Tuple 的行為。

如果是建立一般的 Tuple,就會進入第二個分支,看是要建立個空的 Tuple,還是建立有料的 Tuple。先看看建立空 Tuple 的 tuple_get_empty() 函數...

空的 Tuple

// 檔案:Objects/tupleobject.c

static inline PyObject *
tuple_get_empty(void)
{
    return Py_NewRef(&_Py_SINGLETON(tuple_empty));
}

這裡的 _Py_SINGLETON() 巨集會定義一個全域的物件,這種全域物件建立之後就不會被 GC 回收的,這樣可以確保每次要建立空 Tuple 的時候直接伸手去空中抓一份回來重複使用,不用每次都重新建立。正因為都是同一份物件,加上 Tuple 是不可變的,所以如果我們在 Python 裡使用 is 關鍵字比較兩個空的 Tuple 的時候會得到 True

>>> a = ()
>>> b = ()
>>> a is b
True

因為它們根本就是同一顆物件,而且在 Python 直譯器一啟動的時候就會先幫我們建立一份了。

有料的 Tuple

我這裡指的「有料」,是指在 Python 呼叫 tuple() 類別建立 Tuple 的時候額外帶資料給它:

>>> t1 = tuple([1, 2, 3])
>>> t1
(1, 2, 3)
>>> t2 = tuple("hello")
>>> t2
('h', 'e', 'l', 'l', 'o')
>>>

只要傳進來的是個可迭代的物件,Python 就會把它轉換成 Tuple。來看看 PySequence_Tuple() 是怎麼做到這件事,不過這個函數行數比較多,我們分段來看:

// 檔案:Objects/abstract.c

PyObject *
PySequence_Tuple(PyObject *v)
{
    // ... 略 ...
    if (PyTuple_CheckExact(v)) {
        return Py_NewRef(v);
    }
    if (PyList_CheckExact(v))
        return PyList_AsTuple(v);

    // ... 略 ...
}

這裡有兩個檢查,如果傳入的本身就是一個 Tuple 的話,就直接回傳它自己,不用再重新建立,所以:

>>> a = (1, 2, 3)
>>> b = tuple(a)
>>> a is b
True

如果是串列的話,就會呼叫 PyList_AsTuple() 函數,這個函數會把串列轉換成 Tuple,而這個轉換過程也滿單純的,最後實作的是這個 _PyTuple_FromArray() 函數:

// 檔案:Objects/tupleobject.c

PyObject *
_PyTuple_FromArray(PyObject *const *src, Py_ssize_t n)
{
    if (n == 0) {
        return tuple_get_empty();
    }

    PyTupleObject *tuple = tuple_alloc(n);
    if (tuple == NULL) {
        return NULL;
    }
    PyObject **dst = tuple->ob_item;
    for (Py_ssize_t i = 0; i < n; i++) {
        PyObject *item = src[i];
        dst[i] = Py_NewRef(item);
    }
    _PyObject_GC_TRACK(tuple);
    return (PyObject *)tuple;
}

這裡所謂的轉換,其實也就是跑個 for 迴圈,然後把值一個一個放到 ob_item 陣列裡,這樣就完成了 Tuple 的建立。

回收機制?

當建立的 Tuple 不再使用的時候,例如它的 Reference Count 降到 0 的時候,系統應該會來收回佔用的記憶體空間。想要知道是怎麼做到這件事的話,就得翻一下 PyTuple_Typetp_dealloc 成員對應到的函數 tupledealloc()

// 檔案:Objects/tupleobject.c

static void
tupledealloc(PyTupleObject *op)
{
    if (Py_SIZE(op) == 0) {
        if (op == &_Py_SINGLETON(tuple_empty)) {
            return;
        }
    }

    PyObject_GC_UnTrack(op);
    Py_TRASHCAN_BEGIN(op, tupledealloc)

    Py_ssize_t i = Py_SIZE(op);
    while (--i >= 0) {
        Py_XDECREF(op->ob_item[i]);
    }
    if (!maybe_freelist_push(op)) {
        Py_TYPE(op)->tp_free((PyObject *)op);
    }

    Py_TRASHCAN_END
}

這裡有兩個地方值得看的,第一個是判斷要放掉的這顆物件是不是空的 Tuple,前面有提到空的 Tuple 是一顆全域的物件,它不會參與資源回收的流程,所以就直接跳過。第二個重點是 maybe_freelist_push() 函數,來看看這個函數在做什麼:

// 檔案:Objects/tupleobject.c

static inline int
maybe_freelist_push(PyTupleObject *op)
{
    PyInterpreterState *interp = _PyInterpreterState_GET();
    if (Py_SIZE(op) == 0) {
        return 0;
    }
    Py_ssize_t index = Py_SIZE(op) - 1;
    if (index < PyTuple_NFREELISTS
        && STATE.numfree[index] < PyTuple_MAXFREELIST
        && Py_IS_TYPE(op, &PyTuple_Type))
    {
        op->ob_item[0] = (PyObject *) STATE.free_list[index];
        STATE.free_list[index] = op;
        STATE.numfree[index]++;
        OBJECT_STAT_INC(to_freelist);
        return 1;
    }

    return 0;
}

這裡我把一些條件編譯拿掉了,可以看的出來
還記得我們在浮點數章節也介紹過一個叫做「空閒列表(Free List)」的機制,這個 maybe_freelist_push() 函數就是在處理類似的事情。

如果是空的 Tuple 的話不處理,因為空的 Tuple 是不需要參與這個機制。再來,如果:

  1. Tuple 裡的元素數量沒有超過 PyTuple_NFREELISTS(通常設定 20)的話。
  2. 傳進來的這傢伙是個 Tuple 的話
  3. 空閒列表的空間還沒超過 PyTuple_MAXFREELIST(通常設定 2,000)的話。

上面三個條件都滿足的時候,就會準備把這個 Tuple 加到空閒列表裡。也就是說,如果 Tuple 裡的元素數量小於 20 的話,當這顆物件被放掉的時候,Python 不會馬上釋放記憶體,而是會先把它加到加到空閒列表裡。

我們來寫一段程式碼證明一下:

>>> t1 = tuple(range(20))
>>> id(t1)
4339988768

>>> del t1
>>> t2 = tuple(range(20))
>>> id(t2)
4339988768

>>> t3 = tuple(range(21))
>>> id(t3)
4306827552

>>> del t3
>>> t4 = tuple(range(21))
>>> id(t4)
4306827552

可以看到在 Tuple 元素沒有大於 20 個的時候,使用 del 關鍵字斷開變數跟 Tuple 之間的連結後再建立一個一樣的 Tuple 的時候,它們的 id 還是一樣的,表示這兩個 Tuple 是同一個物件,Python 從 Free List 裡幫我們把 Tuple 撿回來了。但是當 Tuple 元素數量大於 20 的時候,再建立一個一樣的 Tuple 的時候,它們的 id 就不一樣了,這表示這兩個 Tuple 是不同的物件。

常見 Tuple 操作

Tuple 修改

Tuple 是不可變的(Immutable),這個特性跟串列不一樣,所以我們不能直接修改 Tuple 裡的元素。但這個其實也沒什麼神秘的,就跟之前介紹到同樣也是不可修改的字串一樣,說穿了,就是 PyTuple_Typetuple_as_mapping 成員中關於修改的成員函數是空的:

// 檔案:Objects/tupleobject.c

static PyMappingMethods tuple_as_mapping = {
    (lenfunc)tuplelength,
    (binaryfunc)tuplesubscript,
    0
};

讀取有 tuplesubscript() 函數沒問題,但修改的成員函數是空的,所以想要對 Tuple 進行修改就會出現錯誤:

>>> t = (9, 5, 2, 7)
>>> t[0] = "x"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

Tuple 開箱

在 Python 裡,我們可以用 Tuple「開箱(Unpacking)」的方式來把 Tuple 裡的元素一次取出來並指定給多個變數:

t = (9, 5, 2, 7)
a, b, c, d = t

這是怎麼做到的?翻一下這一段程式碼的 Bytecode,會發現開箱對應到的指令碼是 UNPACK_SEQUENCE,追看看這個指令做什麼事:

// 檔案:Python/bytecodes.c

inst(UNPACK_SEQUENCE, (unused/1, seq -- unused[oparg])) {
    #if ENABLE_SPECIALIZATION
    _PyUnpackSequenceCache *cache = (_PyUnpackSequenceCache *)next_instr;
    if (ADAPTIVE_COUNTER_IS_ZERO(cache->counter)) {
        next_instr--;
        _Py_Specialize_UnpackSequence(seq, next_instr, oparg);
        DISPATCH_SAME_OPARG();
    }
    STAT_INC(UNPACK_SEQUENCE, deferred);
    DECREMENT_ADAPTIVE_COUNTER(cache->counter);
    #endif  /* ENABLE_SPECIALIZATION */
    PyObject **top = stack_pointer + oparg - 1;
    int res = unpack_iterable(tstate, seq, oparg, -1, top);
    DECREF_INPUTS();
    ERROR_IF(res == 0, error);
}

因為 Tuple 跟串列都可以用同樣的手法進行開箱,而且指令都是 UNPACK_SEQUENCE,所以要分辨是 Tuple 還是串列的開箱應該就是在 _Py_Specialize_UnpackSequence() 函數裡了:

// 檔案:Python/specialize.c

void
_Py_Specialize_UnpackSequence(PyObject *seq, _Py_CODEUNIT *instr, int oparg)
{
    // ... 略 ...
    _PyUnpackSequenceCache *cache = (_PyUnpackSequenceCache *)(instr + 1);
    if (PyTuple_CheckExact(seq)) {
        // ... 略 ...
        if (PyTuple_GET_SIZE(seq) == 2) {
            instr->op.code = UNPACK_SEQUENCE_TWO_TUPLE;
            goto success;
        }
        instr->op.code = UNPACK_SEQUENCE_TUPLE;
        goto success;
    }
    if (PyList_CheckExact(seq)) {
        // ... 略 ...
        instr->op.code = UNPACK_SEQUENCE_LIST;
        goto success;
    }
    // ... 略 ...
}

看的出來這些指令會有不同的 opcode。只是,當 Tuple 的元素只有 2 個的時候,會用 UNPACK_SEQUENCE_TWO_TUPLE 指令,其他的情況都是用 UNPACK_SEQUENCE_TUPLE 指令。這有什麼差別?難道 Python 有專門對 2 個元素的 Tuple 做了什麼最佳化的設計?來看看這兩個指令在做什麼事:

// 檔案:Python/bytecodes.c

inst(UNPACK_SEQUENCE_TUPLE, (unused/1, seq -- values[oparg])) {
    // ... 略 ...
    PyObject **items = _PyTuple_ITEMS(seq);
    for (int i = oparg; --i >= 0; ) {
        *values++ = Py_NewRef(items[i]);
    }
    DECREF_INPUTS();
}

看起來一般的 UNPACK_SEQUENCE_TUPLE 指令做的事就是把 Tuple 物件的 ob_item 拿出來,跑個 for 迴圈然後把 Tuple 裡的元素一個一個取出來放到指定的變數裡。不過你會看到這個 for 迴圈是反著跑的,還記得最一開始在看 Tuple 結構的時候,裡面有個 ob_item[1] 的設計嗎?這個彈性陣列成員的設計就是讓元素就剛好貼在這個物件後面,不過因為這裡是「先進後出(Last In First Out)」的堆疊,舉個例子,t = (9, 5, 2, 7) 在記憶體裡的樣子:

+-----+-----+-----+-----+-----+
|  t  |  9  |  5  |  2  |  7  |
+-----+-----+-----+-----+-----+
^
|
PyTupleObject 本身

所以 for 迴圈反著跑就能一個一個拿出來了。再來看看 UNPACK_SEQUENCE_TWO_TUPLE 指令:

// 檔案:Python/bytecodes.c

inst(UNPACK_SEQUENCE_TWO_TUPLE, (unused/1, seq -- values[oparg])) {
    // ... 略 ...
    values[0] = Py_NewRef(PyTuple_GET_ITEM(seq, 1));
    values[1] = Py_NewRef(PyTuple_GET_ITEM(seq, 0));
    DECREF_INPUTS();
}

因為只有 2 顆元素,所以直接把 Tuple 裡的第 2 個元素放到第 1 個變數裡,第 1 個元素放到第 2 個變數裡,連迴圈都省下來了,真聰明。

最後順便看看串列的開箱:

// 檔案:Python/bytecodes.c

inst(UNPACK_SEQUENCE_LIST, (unused/1, seq -- values[oparg])) {
    // ... 略 ...
    PyObject **items = _PyList_ITEMS(seq);
    for (int i = oparg; --i >= 0; ) {
        *values++ = Py_NewRef(items[i]);
    }
    DECREF_INPUTS();
}

其實做的事差不多,只是拿的是串列的 ob_item 出來而已。

本文同步刊載於 「為你自己學 Python - 不動如山的 Tuple


上一篇
Day 16 - 字典的整理收納課(下)
下一篇
Day 18 - 虛擬機器大冒險(一)
系列文
為你自己讀 CPython 原始碼21
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
highwall
iT邦新手 5 級 ‧ 2024-10-03 01:10:14

很有趣的分享,非常謝謝您~

不過有個小東西確認一下,這個t4是想用 range(21)而不是 range(22)來跟t3的id比較嗎?

>>> del t3
>>> t4 = tuple(range(21))
>>> id(t4)
4341016480
看更多先前的回應...收起先前的回應...
高見龍 iT邦研究生 4 級 ‧ 2024-10-03 05:32:22 檢舉

你要拿 range(21) 或 range(22) 或 range(100) 都行。

因為這是要說明只要 Tuple 的元素沒超過 PyTuple_NFREELISTS 的話,就算它的 reference count = 0 也不會馬上消失而是被放到 Free List 裡的機制。

highwall iT邦新手 5 級 ‧ 2024-10-03 09:18:39 檢舉

是的,不過我以為是要說明
每一次一樣的range(21)會拿到不一樣的id,而不會像 t1<range(20)>, t2<range(20)>,,拿到同樣的id
就是說,為了讓讀者了解這個機制,而演示的例子,當然如果您沒有這個用意,可能是我誤解了演示的目的,謝謝。

高見龍 iT邦研究生 4 級 ‧ 2024-10-03 11:33:28 檢舉

其實這裡主要是要演示「邊界」的效果,像是另一個例子是:

>>> a = 256
>>> b = 256
>>> a is b
True

>>> c = 257
>>> d = 257
>>> c is d
False

明明是一樣的程式碼,但超過邊界就會有不一樣的答案 :)

highwall iT邦新手 5 級 ‧ 2024-10-03 11:37:00 檢舉

對對對,就是邊界的例子,可是上面用的t3 是21,但t4用的是22
是不是應該要讓t4也用21,才能顯現邊界的效果

高見龍 iT邦研究生 4 級 ‧ 2024-10-03 11:49:03 檢舉

啊,我看懂了!

是的,同樣都調整成 21 效果才會明顯,我來改一下,感謝提醒 :)

我要留言

立即登入留言