iT邦幫忙

2024 iThome 鐵人賽

DAY 24
0
Python

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

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

  • 分享至 

  • xImage
  •  

本文同步刊載於 「為你自己學 Python - 類別繼承與家族紛爭(上))

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

為你自己學 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 - 類別繼承與家族紛爭(上))


上一篇
Day 23 - 類別與它們的產地
下一篇
Day 25 - 類別繼承與家族紛爭(中)
系列文
為你自己讀 CPython 原始碼31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言