跳至主要内容

物件生成全紀錄

為你自己學 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 3 建立實體

在剛才的 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 嗎?我教你啊 :)

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