跳至主要内容

全部都是物件(下)

為你自己學 Python

在 CPython 的核心實作中,PyTypeObject 扮演著很重要的角色。

大家應該聽說過在 Python 中所有東西都是物件(Everything is an object)這樣的說法,每個物件都有它的型別,而 PyTypeObject 就是這些型別的核心描述結構。它是 Python 的核心結構之一,負責描述所有的 Python 型別(types),負責定義物件的行為、屬性和方法。這個章節就讓我們繼續看看裡面還有什麼有趣的設計。

PyTypeObject

在 CPython 中,PyTypeObject 是所有 Python 物件的基礎。它定義了物件的行為、屬性和方法。以下是 PyTypeObject 的定義:

檔案:Include/object.h
struct _typeobject {
PyObject_VAR_HEAD
const char *tp_name; /* For printing, in format "<module>.<name>" */
Py_ssize_t tp_basicsize, tp_itemsize; /* For allocation */

// ... 略 ...
};

為避免佔用過多篇幅,這裡只列出了部分資料,完整的 PyTypeObject 結構的可在 Include/object.h 原始碼裡找到。這個結構包含了關於一個型別所需的所有資訊,例如型別的名稱、大小、方法、屬性,以及該型別如何處理記憶體分配、屬性讀取、物件建立和銷毀等。

稍微簡單整理並分類如下:

基本成員

  • tp_name:型別的名稱,例如使用 type() 印出來的 intlist 就是它。
  • tp_basicsizetp_itemsize:在原始碼的註解裡寫著 /* For allocation */,表示這兩個欄位是用來描述物件在記憶體中的佔用的大小。
  • tp_dict:屬性字典,用來存放型別的屬性,這就是之前提到的 __dict__ 屬性,但因為我們現在講的是型別,所以跟一般操作的實體或物件的 .__dict__ 不太一樣。說是這樣說,型別本身也是物件,所以...這裡就是腦袋容易打結的地方。
  • tp_base:型別的基底型別,用來描述型別的繼承關係。

方法與運算子

  • tp_new:物件要被建立的時候會呼叫的函數。
  • tp_init:物件被建立之後會呼叫的函數。
  • tp_alloc:物件的記憶體分配函數。
  • tp_dealloc:物件要被 GC 收走的時候會呼叫的函數。
  • tp_call:在物件加上小括號 (),想要把物件當函數呼叫的時候會被呼叫的函數。
  • tp_strtp_repr:從名字不難猜出這跟我們在物件導向進階編介紹到的魔法方法 __str__ 以及 __repr__ 有關,想知道這兩個魔術方法的差別可以參考這裡

存取方法

  • tp_as_number:定義了物件作為數值型別時的行為,例如加減乘除四則運算、位元運算等。
  • tp_as_sequence:定義了物件作為序列型別時的行為,例如索引、切片、連接等。
  • tp_as_mapping:定義了物件作為映射型別時的行為,例如 Key/Value 的存取、長度計算等。

這部份滿有趣的,待會再詳細說明。

其他

  • tp_flags:型別的標記,用於描述型別的特性。
  • tp_doc:這個滿好猜的,就是這個型別的文件。
  • tp_methods:用於定義類型所提供的各種方法。這些方法可以在 Python 中像內建方法一樣被調用,它會指向一個 PyMethodDef 結構。
  • tp_members:型別的成員變數定義。
  • tp_getset:這個跟 Python 的屬性存取有關。
  • tp_descr_settp_descr_set:這兩個跟 Python 的描述器(Descriptor)有關。
  • tp_richcompare:要做「比較」時候會用到它。

光是這樣看 PyTypeObject 的定義可能還是覺得有點抽象,我們就借內建的串列型別來看看這個結構的實際應用。

串列型別

串列型別在 CPython 原始碼的定義長的像這樣:

檔案:Objects/listobject.c
PyTypeObject PyList_Type = {
PyVarObject_HEAD_INIT(&PyType_Type, 0)
"list",
sizeof(PyListObject),
0,
(destructor)list_dealloc, /* tp_dealloc */
0, /* tp_vectorcall_offset */
0, /* tp_getattr */
0, /* tp_setattr */
0, /* tp_as_async */
(reprfunc)list_repr, /* tp_repr */
0, /* tp_as_number */
&list_as_sequence, /* tp_as_sequence */
&list_as_mapping, /* tp_as_mapping */
PyObject_HashNotImplemented, /* tp_hash */
0, /* tp_call */
0, /* tp_str */
PyObject_GenericGetAttr, /* tp_getattro */
0, /* tp_setattro */
0, /* tp_as_buffer */
Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC |
Py_TPFLAGS_BASETYPE | Py_TPFLAGS_LIST_SUBCLASS |
_Py_TPFLAGS_MATCH_SELF | Py_TPFLAGS_SEQUENCE, /* tp_flags */
list___init____doc__, /* tp_doc */
(traverseproc)list_traverse, /* tp_traverse */
(inquiry)_list_clear, /* tp_clear */
list_richcompare, /* tp_richcompare */
0, /* tp_weaklistoffset */
list_iter, /* tp_iter */
0, /* tp_iternext */
list_methods, /* tp_methods */
0, /* tp_members */
0, /* tp_getset */
0, /* tp_base */
0, /* tp_dict */
0, /* tp_descr_get */
0, /* tp_descr_set */
0, /* tp_dictoffset */
(initproc)list___init__, /* tp_init */
PyType_GenericAlloc, /* tp_alloc */
PyType_GenericNew, /* tp_new */
PyObject_GC_Del, /* tp_free */
.tp_vectorcall = list_vectorcall,
};

這裡是 0 的,表示這個結構的成員變數是沒有實作的。

印出串列

先來看看 PyList_Typetp_repr 在做什麼事:

檔案:Objects/listobject.c
static PyObject *
list_repr(PyListObject *v)
{
// ... 略 ...
if (Py_SIZE(v) == 0) {
return PyUnicode_FromString("[]");
}

// ... 略 ...

_PyUnicodeWriter_Init(&writer);
writer.overallocate = 1;
/* "[" + "1" + ", 2" * (len - 1) + "]" */
writer.min_length = 1 + 1 + (2 + 1) * (Py_SIZE(v) - 1) + 1;

if (_PyUnicodeWriter_WriteChar(&writer, '[') < 0)
goto error;

for (i = 0; i < Py_SIZE(v); ++i) {
if (i > 0) {
if (_PyUnicodeWriter_WriteASCIIString(&writer, ", ", 2) < 0)
goto error;
}

// ... 略 ...
}

writer.overallocate = 0;
if (_PyUnicodeWriter_WriteChar(&writer, ']') < 0)
goto error;

Py_ReprLeave((PyObject *)v);
return _PyUnicodeWriter_Finish(&writer);
// ... 略 ...
}

可以看的出來如果是個空的串列,會印出 [],如果有元素的話,會把元素一個一個印出來,中間用逗號隔開,最後再加上中括號。中間有一段在計算字串長度的公式看起來滿有趣的:

    writer.min_length = 1 + 1 + (2 + 1) * (Py_SIZE(v) - 1) + 1;

舉例來說,如果是 [1, 2, 3] 的話,這裡總共會需要:

  • "[":開頭字元
  • "1":第一個元素
  • ", 2":後續每個元素前面的逗號和空格
  • "]":結尾字元

不得不說,第一次看的時候覺得有點莫名其妙,但像這樣提前計算出組合完成後的最小字串長度,後續 PyUnicodeWriter() 就能一次分配足夠的記憶體空間,避免多次字串組合而需要重新分配記憶體,用以提高效能。

使用中括號的時候

剛才前面有講到 tp_as_sequence 以及 tp_as_mapping 這兩個成員,一個處理序列型別,像是串列或 Tuple,一個處理映射型別,像是字典。但請大家看看以下這段程式碼範例:

a = (0, 1, 2)  # 這是 Tuple
b = [0, 1, 2] # 這是串列
c = { 0: 0, 1: 1, 2: 2 } # 這是字典

print(a[0])
print(b[0])
print(c[0])

如果以 Python 的角度來看,使用中括號取值是再自然不過的事情,但是,就以直譯器的角度,怎麼分辨這個中括號要處理的物件,是 Tuple、是串列,還是字典?更精準的問題是,要怎麼知道要找 tp_as_sequence 還是 tp_as_mapping

這裡我使用內建的 dis 模組檢視上面的程式碼編譯之後的指令碼,會發現這三個物件在使用中括號操作的時候,都是執行 BINARY_SUBSCR 指令,來看看這個指令是怎麼做事的:

檔案:Python/bytecodes.c
inst(BINARY_SUBSCR, (unused/1, container, sub -- res)) {
#if ENABLE_SPECIALIZATION
_PyBinarySubscrCache *cache = (_PyBinarySubscrCache *)next_instr;
if (ADAPTIVE_COUNTER_IS_ZERO(cache->counter)) {
next_instr--;
_Py_Specialize_BinarySubscr(container, sub, next_instr);
DISPATCH_SAME_OPARG();
}
STAT_INC(BINARY_SUBSCR, deferred);
DECREMENT_ADAPTIVE_COUNTER(cache->counter);
#endif /* ENABLE_SPECIALIZATION */
res = PyObject_GetItem(container, sub);
DECREF_INPUTS();
ERROR_IF(res == NULL, error);
}

可以看到 BINARY_SUBSCR 指令真正在做事的是 PyObject_GetItem() 函數,再追進去看看:

檔案:Objects/abstract.c
PyObject *
PyObject_GetItem(PyObject *o, PyObject *key)
{
// ... 略 ...
PyMappingMethods *m = Py_TYPE(o)->tp_as_mapping;
if (m && m->mp_subscript) {
PyObject *item = m->mp_subscript(o, key);
return item;
}

PySequenceMethods *ms = Py_TYPE(o)->tp_as_sequence;
if (ms && ms->sq_item) {
// ... 略 ...
}
// ... 略 ...
}

從這段程式碼可以看的出來,當使用中括號進行操作的時候,會先找 tp_as_mapping 成員變數,看看裡面的 mp_subscript 成員有沒有實作。如果沒有,接著會再找 tp_as_sequence 成員裡的 sq_item

也就是說,就算 mp_subscriptsq_item 這兩個都有實作,透過中括號操作物件的時候,在順序上會先找 mp_subscript 再找 sq_item。如果都沒有實作的話,就會出現 object is not subscriptable 的錯誤訊息,像這樣:

>>> user = 9527
>>> user[123]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'int' object is not subscriptable

串列的方法

接著來看看 tp_methods 這個成員變數,這個變數是用來定義型別所提供的各種方法的,這裡是串列型別的方法:

檔案:Objects/listobject.c
static PyMethodDef list_methods[] = {
{"__getitem__", (PyCFunction)list_subscript, METH_O|METH_COEXIST,
PyDoc_STR("__getitem__($self, index, /)\n--\n\nReturn self[index].")},
LIST___REVERSED___METHODDEF
LIST___SIZEOF___METHODDEF
LIST_CLEAR_METHODDEF
LIST_COPY_METHODDEF
LIST_APPEND_METHODDEF
LIST_INSERT_METHODDEF
LIST_EXTEND_METHODDEF
LIST_POP_METHODDEF
LIST_REMOVE_METHODDEF
LIST_INDEX_METHODDEF
LIST_COUNT_METHODDEF
LIST_REVERSE_METHODDEF
LIST_SORT_METHODDEF
{"__class_getitem__", Py_GenericAlias, METH_O|METH_CLASS, PyDoc_STR("See PEP 585")},
{NULL, NULL} /* sentinel */
};

這些字看起來應該都能猜到是什麼意思,例如我們順著 LIST_APPEND_METHODDEF 這個巨集往下追:

檔案:Objects/clinic/listobject.c.h
#define LIST_APPEND_METHODDEF    \
{"append", (PyCFunction)list_append, METH_O, list_append__doc__},

就會看到 list_append 這個函數,這個函數就是用來在串列後面加上一個元素的,其它的方法也是依此類推。

串列相加

如果是兩個串列用 + 號加在一起的話:

[1, 2, 3] + [4, 5, 6]

剛才有提到一個叫做 tp_as_sequence 的成員變數,它定義了物件作為序列型別時的行為,像是索引、切片、連接等,PyList_Typetp_as_sequence 指向 list_as_sequence 這個結構:

檔案:Objects/listobject.c
static PySequenceMethods list_as_sequence = {
(lenfunc)list_length, /* sq_length */
(binaryfunc)list_concat, /* sq_concat */
(ssizeargfunc)list_repeat, /* sq_repeat */
(ssizeargfunc)list_item, /* sq_item */
0, /* sq_slice */
(ssizeobjargproc)list_ass_item, /* sq_ass_item */
0, /* sq_ass_slice */
(objobjproc)list_contains, /* sq_contains */
(binaryfunc)list_inplace_concat, /* sq_inplace_concat */
(ssizeargfunc)list_inplace_repeat, /* sq_inplace_repeat */
};

在這個結構裡,負責串接工作的是 sq_concat, 它指向 list_concat() 函數,繼續追一下:

檔案:Objects/listobject.c
static PyObject *
list_concat(PyListObject *a, PyObject *bb)
{
// ... 略 ...
size = Py_SIZE(a) + Py_SIZE(b);
if (size == 0) {
return PyList_New(0);
}
np = (PyListObject *) list_new_prealloc(size);

// ... 略 ...
return (PyObject *)np;
}

從原始碼可以看的出來,當把兩個串列用 + 組合在一起的時候,會產生並回傳一個新的串列,就算兩個串列都是空的也一樣會產生一個新的空串列。

在成員變數 sq_concat 下面一點的地方有另一個跟它有點像的傢伙叫做 sq_inplace_concat,它會在使用 += 寫法的時候被觸發,目前這個變數指向 list_inplace_concat() 函數:

檔案:Objects/listobject.c
static PyObject *
list_inplace_concat(PyListObject *self, PyObject *other)
{
PyObject *result;

result = list_extend(self, other);
if (result == NULL)
return result;
Py_DECREF(result);
return Py_NewRef(self);
}

裡面會呼叫 list_extend() 函數,這個函數會把 other 串列的元素加到 self 串列的後面,所以在串列的 += 操作它不會像 + 會產生一個新的串列。

元素個數

在 Python 如果想要計算串列的元素個數,可以使用 len() 函數:

len([1, 2, 3])  # 3

這也是小菜一疊,這又是怎麼做到的?為什麼好像 len() 函數能作用在很多不同的型別上?

我們先來追一下 len() 函數,它是個內建函數,你可以在 Python/bltinmodule.c 這個檔案裡找到它的實作:

檔案:Python/bltinmodule.c
static PyObject *
builtin_len(PyObject *module, PyObject *obj)
{
Py_ssize_t res;

res = PyObject_Size(obj);
if (res < 0) {
assert(PyErr_Occurred());
return NULL;
}
return PyLong_FromSsize_t(res);
}

這個實作滿簡單的,就是呼叫 PyObject_Size() 函數而已,再往下追 PyObject_Size() 函數:

檔案:Objects/abstract.c
Py_ssize_t
PyObject_Size(PyObject *o)
{
// ... 略 ...
PySequenceMethods *m = Py_TYPE(o)->tp_as_sequence;
if (m && m->sq_length) {
Py_ssize_t len = m->sq_length(o);
assert(_Py_CheckSlotResult(o, "__len__", len >= 0));
return len;
}

return PyMapping_Size(o);
}

這個函數會先檢查物件的成員變數 tp_as_sequence,如果有的話就會找 PySequenceMethods 的成員變數 sq_length,而它正指向 list_length() 函數:

檔案:Objects/listobject.c
static Py_ssize_t
list_length(PyListObject *a)
{
return Py_SIZE(a);
}

這個函數會回傳物件的長度。但萬一這個型別不是序列型別的話,也就是沒有 tp_as_sequence 的話,在最後一行會繼續呼叫 PyMapping_Size() 函數,這個函數會呼叫物件的 tp_as_mappingmp_length 函數回傳物件的長度。

這也是為什麼我們在看官網文件說明的時候,len() 這個函數的說明的時候是這樣寫的:

Return the length (the number of items) of an object. The argument may be a sequence (such as a string, bytes, tuple, list, or range) or a collection (such as a dictionary, set, or frozen set).

len() 函數可以用在字串、串列、Tuple 這種序列型別或是字典之類的映射型別的原因。

串列比較

最後,我們用一個看起來很簡單但又沒那麼簡單的題目來當做這個章節的收尾:

a = float("nan") # NaN
b = float("nan") # NaN

print([a] == [a]) # A. 這裡會印出什麼?
print([a] == [b]) # B. 這裡會印出什麼?

在很多程式語言的浮點數都是按照 IEEE754 的規格實作的,在這個規格裡,NaN 不會等於任何值,包括它是自己。但如果放到串列裡呢?這答案可能會讓你覺得有點神奇。

串列在做「比較」的時候,會去找 PyList_Typetp_richcompare 成員變數,而它的實作是 list_richcompare() 函數:

檔案:Objects/listobject.c
static PyObject *
list_richcompare(PyObject *v, PyObject *w, int op)
{
// ... 略 ...
}

這個函數比較長一點,摘錄兩個重點。重先,如果兩個串列的元素個數不一樣的話,就不用往下比了:

檔案:Objects/listobject.c
vl = (PyListObject *)v;
wl = (PyListObject *)w;

if (Py_SIZE(vl) != Py_SIZE(wl) && (op == Py_EQ || op == Py_NE)) {
/* Shortcut: if the lengths differ, the lists differ */
if (op == Py_EQ)
Py_RETURN_FALSE;
else
Py_RETURN_TRUE;
}

如果這兩個 List 的元素數量一樣,就會逐一比較串列裡面每個元素是不是同一顆物件,也就是比較記憶體位置:

檔案:Objects/listobject.c
for (i = 0; i < Py_SIZE(vl) && i < Py_SIZE(wl); i++) {
PyObject *vitem = vl->ob_item[i];
PyObject *witem = wl->ob_item[i];
if (vitem == witem) {
continue;
}

// ... 略 ...
}

再回來看原本的題目:

a = float("nan") # NaN
b = float("nan") # NaN

print([a] == [a]) # A. 這裡會印出什麼?
print([a] == [b]) # B. 這裡會印出什麼?

[a] == [a] 來說,元素數量是一樣的,所以接著比記憶體位置。變數 a 跟變數 a 就是同一個變數 a,所以記憶體位置是一樣的,比完之後就會得到 True 而 [a] == [b] 的話雖然元素個數相等,變數 a 跟變數 b 雖然都是 NaN,但它們在 Python 的世界裡是兩個不同的物件,記憶體位置是不同的,所以比完得到的結果是 False

小結

雖然這個章節我們只拿 list 這個型別來舉例,但其他型別的實作也是類似的。在 CPython 裡,所有的型別都是透過 PyTypeObject 這個結構來定義的,這個結構包含了型別的名稱、大小、方法、屬性,以及該型別如何處理記憶體分配、屬性訪問、物件建立和銷毀等。這些方法和屬性的實作,就是 Python 物件的行為和特性。

下個章節,我們就利用我們目前看到的東西,試著在 CPython 裡寫一個簡單的型別,看看這些東西是怎麼串在一起。

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!