iT邦幫忙

2024 iThome 鐵人賽

DAY 4
1

本文同步刊載於 「為你自己學 Python - 物件生成全紀錄

物件生成全紀錄

為你自己學 Python

class Cat:
    pass

kitty = Cat()

這段 Python 程式碼看起來很簡單,像這樣在 Python 中,定義一個類別並建立實體(instance)是很常見的操作。不過,你知道透過 Cat 類別產生 kitty 實體的過程中,背後發生了什麼事嗎?在「物件導向程式設計 - 進階篇」裡曾經跟大家介紹過會經歷 __new____init__ 這兩個階段,以及這兩個方法的差別,接下來我們就從 CPython 原始碼的角度來看看發生什麼事。

程式跑起來!

Step 0 程式碼解析

Python 在執行我們的程式碼之前雖然不需要像 C 語言一樣進行編譯,但 Python 直譯器總得先讀懂我們寫的程式碼才行。首先第一步是進行 Tokenization。Tokenization 的過程我找不到比較合適的中文翻譯,簡單來說就是把我們寫的程式碼轉拆成一個個的 Token,我用個最簡單的例子:

a = 1 + 2

這段程式碼會被拆解成如下的 Token:

  • NAME (a)
  • EQUAL (=)
  • NUMBER (1)
  • PLUS (+)
  • NUMBER (2)

這個過程的目的是將文字形式的原始程式碼,轉換成語法意圖清晰且可理解的片段。在 CPython 裡負責做這些事的原始碼在 Parser/tokenizer.c,在這裡主要負責做這件事的是 _PyTokenizer_Get() 函數:

// 檔案:Parser/tokenizer.c

int _PyTokenizer_Get(struct tok_state *tok, struct token *token) {
  int result = tok_get(tok, token);
  if (tok->decoding_erred) {
    result = ERRORTOKEN;
    tok->done = E_DECODE;
  }
  return result;
}

裡面的 tok_get() 函數就是負責把原始碼轉換成 Token 的函數,這個函數裡面有很多細節,包括處理一般的文字跟「F 字串(F-String)」。而 Token 的列表是在 Grammar/Tokens 裡,所以如果想要幫 Python 加入新的關鍵字或是語法的話,可以從這兩個地方下手。

Step 1 轉換成 AST

原本的純文字被轉換成一個個的 Token 之後,接著會把 Token 再轉換為「抽象語法樹(Abstract Syntax Tree, AST)」。轉換成 AST 的目的,是為了讓直譯器能夠理解我們的程式碼。

在前兩章曾經介紹過整個 CPython 的專案結構中,有個叫做 Grammar 以及 Parser 這兩個目錄,裡面的程式就是負責這個工作的。不過把語法轉換成 AST 的細節有點複雜,超過我目前的能力範圍,這裡我們只要先知道會發生這件事就好。

Step 2 編譯成 Bytecode

接下來,剛才解析好的 AST 會被編譯為 Bytecode。咦?編譯?Python 不是直譯語言嗎?沒錯,Python 是直譯語言,但是 Python 直譯器會把我們寫的程式碼編譯成一種叫做 Bytecode 的中間碼,然後再執行這些 Bytecode。

單一個 .py 檔案可能沒感覺,舉個例子,我有 a.pyb.py 兩個檔案,如果 a.py 裡有 import b 的話,b.py 裡的程式碼會被編譯成 Bytecode,然後被存儲在 __pycache__ 目錄裡的 b.cpython-312.pyc 檔案裡,這個 .pyc 檔案的命名規則還滿好猜的,cpython-312 這個部分就是 Python 直譯器的版本號,312 就是 Python 3.12 的意思。

所以如果你曾經在專案裡看到像是 __pycache__ 這樣的目錄的話,這些就是編譯過的 Bytecode 啦。當再次執行同樣的 Python 程式時,Python 直譯器會先檢查是否有對應的 .pyc 檔案以及是否需要重新再編譯一次,如果有的話而且不用重新編譯就會直接載入這些 Bytecode,由 Python 虛擬機器(Virtual Machine)解譯、執行。

把 AST 編譯成 Bytecode 的原始碼在 CPython 專案裡的位置是 Python/compile.c,有興趣的話可以追一下這個檔案裡的 _PyAST_Compile() 函數,這個函數會把 AST 轉換成可以執行的 Code Object。關於 Code Object,在「為你自己學 Python」 的函數 - 進階篇章節有稍微介紹過,這裡就不再贅述。

這裡我們可以試著用 Python 內建的 dis 模組來看看我們寫的程式碼被編譯成什麼樣的 Bytecode,可以執行以下指令:

$ python -m dis demo.py

印出來的結果如下:

  0           0 RESUME                   0

  1           2 PUSH_NULL
              4 LOAD_BUILD_CLASS
              6 LOAD_CONST               0 (<code object Cat at 0x1045bb5d0, file "demo.py", line 1>)
              8 MAKE_FUNCTION            0
             10 LOAD_CONST               1 ('Cat')
             12 CALL                     2
             20 STORE_NAME               0 (Cat)

  4          22 PUSH_NULL
             24 LOAD_NAME                0 (Cat)
             26 CALL                     0
             34 STORE_NAME               1 (kitty)
             36 RETURN_CONST             2 (None)

Disassembly of <code object Cat at 0x1045bb5d0, file "demo.py", line 1>:
  1           0 RESUME                   0
              2 LOAD_NAME                0 (__name__)
              4 STORE_NAME               1 (__module__)
              6 LOAD_CONST               0 ('Cat')
              8 STORE_NAME               2 (__qualname__)

  2          10 RETURN_CONST             1 (None)

最前面的 01 以及 4,表示這是在原始碼裡的行號。這裡可先看兩個重點,首先 LOAD_BUILD_CLASS 是用來建立類別,再透過 STORE_NAME 把類別存到變數 Cat 裡。而 LOAD_BUILD_CLASS 實際上對應到的 CPython 原始碼是這段:

// 檔案:Python/bltinmodule.c

static PyObject *
builtin___build_class__(PyObject *self, PyObject *const *args, Py_ssize_t nargs,
                        PyObject *kwnames)
{
    PyObject *func, *name, *winner, *prep;

    // ... 略 ...

    return cls;
}

這個函數比較長一點,我先挑一小段出來:

// 檔案:Python/bltinmodule.c

if (meta == NULL) {
    /* if there are no bases, use type: */
    if (PyTuple_GET_SIZE(bases) == 0) {
        meta = (PyObject *) (&PyType_Type);
    }
    /* else get the type of the first base */
    else {
        PyObject *base0 = PyTuple_GET_ITEM(bases, 0);
        meta = (PyObject *)Py_TYPE(base0);
    }
    Py_INCREF(meta);
    isclass = 1;  /* meta is really a class */
}

從這段可以看出來如果沒有指定 meta class,而且沒有指定上層類別的話(例如範例裡的 Cat 類別),那麼它的 Metaclass 就會是 type,也就是 PyType_Type。關於 Metaclass 可參閱「為你自己學 Python」的物件導向程式設計 - 進階篇章節。

這個 builtin___build_class__() 函數裡還有很多東西值得介紹的,不過就等後續細講到物件導向的時候再來說明。簡單的說,這個函數會回傳一個類別,但仔細看就會發現,所謂的類別也就是一個 PyObject,也就是說,在 Python 裡類別也是一種物件。

類別建立好了,再來就是準備建立實體了。

step 2 建立實體

在剛才的 Byteocde 的後半段可以看到這個:

             // ... 略 ...
             10 LOAD_CONST               1 ('Cat')
             12 CALL                     2
             20 STORE_NAME               0 (Cat)

  4          22 PUSH_NULL
             24 LOAD_NAME                0 (Cat)
             26 CALL                     0
             34 STORE_NAME               1 (kitty)
             36 RETURN_CONST             2 (None)
             // ... 略 ...

當執行 kitty = Cat() 的時候,實際上就是「呼叫(Call)」這個類別。同樣在物件導向程式設計 - 進階篇有介紹過幾個魔術方法,只要這個物件身上有 __call__ 這個魔術方法,這顆物件就能被「呼叫」。

雖然我自己寫的 Cat 類別並沒有實作 __call__ 這個魔術方法,但是建立 Cat 類別的 Metaclass 就是 type 類別,所以「呼叫」或「執行」Cat 類別的時候,看的就是 type 類別的 tp_call 成員變數:

// 檔案:Objects/typeobject.c

PyTypeObject PyType_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "type",                                     /* tp_name */
    sizeof(PyHeapTypeObject),                   /* tp_basicsize */
    sizeof(PyMemberDef),                        /* tp_itemsize */
    (destructor)type_dealloc,                   /* tp_dealloc */
    // ... 略 ...
    0,                                          /* tp_hash */
    (ternaryfunc)type_call,                     /* tp_call */
    0,                                          /* tp_str */
    (getattrofunc)_Py_type_getattro,            /* tp_getattro */
    .tp_vectorcall = type_vectorcall,
};

可以看的出來這個結構的成員變數 tp_call 指向一個名為 type_call 的函數,再繼續往下追:

// 檔案:Objects/typeobject.c

static PyObject *
type_call(PyTypeObject *type, PyObject *args, PyObject *kwds)
{
    PyObject *obj;

    // ... 略 ...

    obj = type->tp_new(type, args, kwds);
    obj = _Py_CheckFunctionResult(tstate, (PyObject*)type, obj, NULL);
    if (obj == NULL)
        return NULL;

    // ... 略 ...
    type = Py_TYPE(obj);
    if (type->tp_init != NULL) {
        int res = type->tp_init(obj, args, kwds);
        if (res < 0) {
            assert(_PyErr_Occurred(tstate));
            Py_SETREF(obj, NULL);
        }
        else {
            assert(!_PyErr_Occurred(tstate));
        }
    }
    return obj;
}

在這個 type_call() 函數裡,會呼叫 type->tp_new() 函數來產生物件,如果沒出錯,待會就會呼叫 type->tp_init() 函數來初始化這個物件。這裡的 tp_new()tp_init(),跟我們在 Python 裡學過的 __new__ 以及 __init__ 是對的起來的。

小結

所以,如果是使用我們自己寫的類別來建立實體,背後的流程會經過:

編譯階段:

  1. 把原始碼轉換成 Token。
  2. 把 Token 轉換成 AST。
  3. 把 AST 編譯成 Bytecode。

執行階段:

  1. 呼叫類別,也就是執行 tp_call()
  2. type 類別預設的 tp_call() 會呼叫 tp_new() 函數來建立物件。
  3. 接著呼叫物件的 tp_init() 函數來初始化物件。

順帶一提,type 這傢伙很有趣,它本身是個物件也是個類別,但我們之前常會用 type(123) 這樣的寫法來取得類別名稱,這正是因為在剛才介紹到的 type_call() 函數裡有一段特別的程式碼:

// 檔案:Objects/typeobject.c

    /* Special case: type(x) should return Py_TYPE(x) */
    /* We only want type itself to accept the one-argument form (#27157) */
    if (type == &PyType_Type) {
        assert(args != NULL && PyTuple_Check(args));
        assert(kwds == NULL || PyDict_Check(kwds));
        Py_ssize_t nargs = PyTuple_GET_SIZE(args);

        if (nargs == 1 && (kwds == NULL || !PyDict_GET_SIZE(kwds))) {
            obj = (PyObject *) Py_TYPE(PyTuple_GET_ITEM(args, 0));
            return Py_NewRef(obj);
        }

        /* SF bug 475327 -- if that didn't trigger, we need 3
           arguments. But PyArg_ParseTuple in type_new may give
           a msg saying type() needs exactly 3. */
        if (nargs != 3) {
            PyErr_SetString(PyExc_TypeError,
                            "type() takes 1 or 3 arguments");
            return NULL;
        }
    }

如果是帶 3 個參數給它的話,可以透過 type() 來建立一個新的類別,但是如果只帶 1 個參數的話,type() 就會回傳這個參數的類別,這也就是為什麼 type(123) 會回傳 <class 'int'>type('hello kitty') 會得到 <class 'str'> 的原因。

本文同步刊載於 「為你自己學 Python - 物件生成全紀錄


上一篇
Day 3 - 全部都是物件!(上)
下一篇
Day 5 - 全部都是物件!(下)
系列文
為你自己讀 CPython 原始碼31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言