跳至主要内容

類別繼承與家族紛爭(上)

為你自己學 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 類別物件>)

這樣就建立了 CatAnimal 之間的繼承關係了。我們再複習一下上個章節看過的 __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 5oparg 是 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_gettp_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 嗎?我教你啊 :)

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