本文同步刊載於 「為你自己學 Python - 類別繼承與家族紛爭(上))」
其實類別的「繼承(Inheritance)」在其它程式語言裡可能沒什麼好講的,大家的設計都差不多,就是把共同的方法寫在上層類別,然後繼承它的下層類別就可以直接使用。不過 Python 的繼承加入了多重繼承的設計,也就是一個類別可以同時有多個上層類別,讓整個故事變的複雜了一點點...,喔,應該不是一點點,如果追原始碼的話,這還滿大一點的。
我們先從最簡單開始:
class Animal:
pass
class Cat(Animal):
pass
來看看這幾行程式碼產生的 Bytecode 長什麼樣子,先看上半部:
1 2 PUSH_NULL
4 LOAD_BUILD_CLASS
6 LOAD_CONST 0 (<code object Animal>)
8 MAKE_FUNCTION 0
10 LOAD_CONST 1 ('Animal')
12 CALL 2
20 STORE_NAME 0 (Animal)
這裡大都是看過的指令,我們一行一行來看,但這回我們順便看一下記憶體堆疊的分解動作。
首先,LOAD_BUILD_CLASS
指令在上個章節看過,就是載入用來建立類別的 __build_class__()
函數,載入之後目前的堆疊狀態是:
+-----------------+
| __build_class__ |
+-----------------+
接下來,LOAD_CONST
指令載入已經編譯好的 Code Object,也就是 Animal
類別的程式碼,這時候堆疊狀態變成:
+-----------------+
| <Code Object> |
+-----------------+
| __build_class__ |
+-----------------+
接著,MAKE_FUNCTION 0
指令建立會拿出目前堆疊的最上面一個當做參數,然後建立一個函數物件並且把這顆 Code Object 給包進去,再擺回堆疊上,這時候堆疊狀態變成:
+-----------------+
| <Animal 函數物件> |
+-----------------+
| __build_class__ |
+-----------------+
再來的 LOAD_CONST 1
載入字串 'Animal'
,這準備用來當做類別的名字,這時候堆疊狀態變成:
+-----------------+
| "Animal" 字串 |
+-----------------+
| <Animal 函數物件> |
+-----------------+
| __build_class__ |
+-----------------+
接下來的 CALL 2
指令是指拿出目前堆疊的最上面兩個當做參數,然後拿第三個來執行,以結果來說,這裡 CALL 2
就等於是 __build_class__(<Animal 函數物件>, "Animal")
的意思。類別 Animal
建立好之後就會把它擺回到堆疊上,這時候堆疊狀態變成:
+-----------------+
| <Animal 類別物件> |
+-----------------+
搞定一個類別,接著來看看繼承是怎麼做的。
5 22 PUSH_NULL
24 LOAD_BUILD_CLASS
26 LOAD_CONST 2 (<code object Cat>)
28 MAKE_FUNCTION 0
30 LOAD_CONST 3 ('Cat')
32 LOAD_NAME 0 (Animal)
34 CALL 3
42 STORE_NAME 1 (Cat)
44 RETURN_CONST 4 (None)
大部份的指令差不多,但 CALL 3
是指要拿出堆疊最上面的三個元素當參數,並且拿第四個的元素來執行,以結果來說就會變成:
__build_class__(<Cat 函數物件>, "Cat", <Animal 類別物件>)
這樣就建立了 Cat
跟 Animal
之間的繼承關係了。我們再複習一下上個章節看過的 __build_class__()
這個內建函數的實作:
// 檔案:Python/bltinmodule.c
static PyObject *
builtin___build_class__(PyObject *self, PyObject *const *args, Py_ssize_t nargs,
PyObject *kwnames)
{
// ... 略 ...
orig_bases = _PyTuple_FromArray(args + 2, nargs - 2);
// ... 略 ...
}
第一個參數是建立類別的函數物件,第二個是類別的名字,後面剩下的參數就是上層類別了,這些上層類別會被擺進一個 Tuple 裡,看想放幾個上層類別都可以。啊...也不是說沒有限制的啦,原則上是沒限制沒錯,但放太多個上層類別的話,在計算繼承關係的時候就得額外花時間,所以通常也不會擺太多。
先來個簡單的例子:
class Cat:
def hi(self):
pass
kitty = Cat()
kitty.hi()
kitty
的 .hi()
方法是怎麼被找到的?來看看 Bytecode 長什麼樣子:
4 LOAD_BUILD_CLASS
6 LOAD_CONST 0 (<code object Cat>)
8 MAKE_FUNCTION 0
10 LOAD_CONST 1 ('Cat')
12 CALL 2
20 STORE_NAME 0 (Cat)
6 22 PUSH_NULL
24 LOAD_NAME 0 (Cat)
26 CALL 0
34 STORE_NAME 1 (kitty)
7 36 LOAD_NAME 1 (kitty)
38 LOAD_ATTR 5 (NULL|self + hi)
58 CALL 0
66 POP_TOP
68 RETURN_CONST 2 (None)
上半段是在建立 Cat
類別,中間那段是在建立 kitty
實體,實體建立後,LOAD_NAME 1 (kitty)
就是載入剛才建立的實體並擺在堆疊上,接著 LOAD_ATTR 5
就是要找到堆疊最上層的 kitty
這顆實體裡的 hi
方法,最後再由 CALL 0
指令執行。
我們來看看這個新的指令 LOAD_ATTR 5
在做些什麼事:
// 檔案:Python/bytecodes.c
inst(LOAD_ATTR, (unused/9, owner -- res2 if (oparg & 1), res)) {
// ... 略 ...
PyObject *name = GETITEM(frame->f_code->co_names, oparg >> 1);
if (oparg & 1) {
PyObject* meth = NULL;
if (_PyObject_GetMethod(owner, name, &meth)) {
assert(meth != NULL); // No errors on this branch
res2 = meth;
res = owner; // Transfer ownership
}
else {
DECREF_INPUTS();
ERROR_IF(meth == NULL, error);
res2 = NULL;
res = meth;
}
}
else {
// ... 略 ...
}
}
因為 LOAD_ATTR 5
的 oparg
是 5,所以會走的路線是 _PyObject_GetMethod(owner, name, &meth)
函數,也就是從 owner
裡面找到 name
,並指定給 meth
。以我們的範例來說,這個 owner
指的就是剛剛建立的 kitty
實體,name
就是 "hi"
。
要從物件裡找到指定的方法。怎麼找呢?來看看原始碼是怎麼做的,這個函數行數比較多,我們一段一段來看:
// 檔案:Objects/object.c
int
_PyObject_GetMethod(PyObject *obj, PyObject *name, PyObject **method)
{
// ... 略 ...
PyTypeObject *tp = Py_TYPE(obj);
// ... 略 ...
PyObject *descr = _PyType_Lookup(tp, name);
// ... 略 ...
}
扣掉前面省略的一些錯誤檢查的程式碼,這個 _PyType_Lookup()
函數從名字大概能猜的出來從指定的型別找到符合 name
的方法。追進 _PyType_Lookup()
函數:
// 檔案:Objects/typeobject.c
/* Internal API to look for a name through the MRO.
This returns a borrowed reference, and doesn't set an exception! */
PyObject *
_PyType_Lookup(PyTypeObject *type, PyObject *name)
{
// ... 略 ...
res = find_name_in_mro(type, name, &error);
// ... 略 ...
return res;
}
註解寫的也滿清楚的,這個方法會從 MRO 裡找東西,find_name_in_mro()
這個函數的命名也很清楚它的目的。MRO 是 Method Resolution Order 的縮寫,這是 Python 用來找方法的順序,這有一套算是有點複雜而且我不太想面對的演算法,但我們在下集再來跟大家詳細介紹,現在只要先知道它是 Python 用來找方法的順序就好。
再回到原本的 _PyObject_GetMethod()
函數,繼續往下看:
descrgetfunc f = NULL;
if (descr != NULL) {
Py_INCREF(descr);
if (_PyType_HasFeature(Py_TYPE(descr), Py_TPFLAGS_METHOD_DESCRIPTOR)) {
meth_found = 1;
} else {
f = Py_TYPE(descr)->tp_descr_get;
if (f != NULL && PyDescr_IsData(descr)) {
*method = f(descr, obj, (PyObject *)Py_TYPE(obj));
Py_DECREF(descr);
return 0;
}
}
}
在「為你自己學 Python」的物件導向程式設計 - 進階篇章節曾經介紹過關於「描述器(Descriptor)」的概念,而 tp_descr_get
跟 tp_descr_set
這兩個成員就是對應到描述器的 __get__
及 __set__
方法。上面這段程式碼就是在檢查 descr
這個物件是不是一個「方法描述器(Method Descriptor)」。
描述器有分資料描述器(Data Descriptor)跟非資料描述器(Non-Data Descriptor),這兩種描述器的細節可再參考上述連結資料。方法描述器是一種的非資料描述器,它沒有 __set__
或 __delete__
方法,只有 __get__
方法。
這裡會判斷是不是一個方法描述器,如果不是的話,會執行 f(descr, obj, (PyObject *)Py_TYPE(obj))
並指定給 method
,這相當於執行 Python 描述器的 __get__
方法。
再繼續往下看:
PyObject *dict;
if ((tp->tp_flags & Py_TPFLAGS_MANAGED_DICT)) {
PyDictOrValues* dorv_ptr = _PyObject_DictOrValuesPointer(obj);
if (_PyDictOrValues_IsValues(*dorv_ptr)) {
PyDictValues *values = _PyDictOrValues_GetValues(*dorv_ptr);
PyObject *attr = _PyObject_GetInstanceAttribute(obj, values, name);
if (attr != NULL) {
*method = attr;
Py_XDECREF(descr);
return 0;
}
dict = NULL;
}
else {
dict = dorv_ptr->dict;
}
}
else {
PyObject **dictptr = _PyObject_ComputedDictPointer(obj);
if (dictptr != NULL) {
dict = *dictptr;
}
else {
dict = NULL;
}
}
if (dict != NULL) {
Py_INCREF(dict);
PyObject *attr = PyDict_GetItemWithError(dict, name);
if (attr != NULL) {
*method = Py_NewRef(attr);
Py_DECREF(dict);
Py_XDECREF(descr);
return 0;
}
Py_DECREF(dict);
if (PyErr_Occurred()) {
Py_XDECREF(descr);
return 0;
}
}
這段程式碼看起來有點囉嗦,但目的是試著從物件的字典或屬性中找到特定屬性值,如果找到就把它存放到 method
中並結束這個函數;如果找不到就會繼續其他解析邏輯,如果到最後都還是找不到,就會出現錯誤。如果方法找到了會再次被擺上堆疊,下個 CALL 0
就會來執行這個方法。
這個 LOAD_ATTR
指令其實做了不少事,當我們在 Python 程式裡寫 kitty.say_goodbye()
的時候,這種 .
方法就是會由 LOAD_ATTR
負責查找,但我們這裡省略了很多細節,就是我前面提到不想面對的 MRO 演算法,這個演算法在多重繼承的情況下會變的有點複雜,就讓我們在下集再來詳細介紹吧!
本文同步刊載於 「為你自己學 Python - 類別繼承與家族紛爭(上))」