跳至主要内容

虛擬機器大冒險(一)

為你自己學 Python

Python 虛擬機器(Python Virtual Machine, PVM)是 Python 程式碼背後運行的核心,負責解讀並執行我們所寫的每一行 Python 程式碼。從 Bytecode 轉換成具體操作的指令、物件的建立與銷毀、記憶體管理等等都算是它的守備範圍。因此,接下來的幾個章節我會試著用幾行簡單的程式,理解 Python 虛擬機器的運作原理。

我們在前面已經介紹過直譯器啟動的過程,從把檔案讀進來、轉換成 AST 再轉換成 Bytecode,最後再交給虛擬機器執行,所以接下來就讓我們從函數開始吧!

在 Python 裡,函數是使用 def 關鍵字定義的程式碼區塊,至於為什麼要寫函數或是寫函數有什麼好處,可參閱「為你自己學 Python」的函數 - 基礎篇章節介紹。這個章節主要要來看看在 CPython 是怎麼定義一個函數,函數物件裡又藏了哪些好玩的東西,以及函數執行的時候發生了什麼事。

函數也是物件

在 Python 裡函數也是物件,既然是物件,那應該就能找到對應的型別結構:

PyTypeObject PyFunction_Type = {
PyVarObject_HEAD_INIT(&PyType_Type, 0)
"function",
// ... 略 ...
(reprfunc)func_repr, /* tp_repr */
0, /* tp_as_number */
0, /* tp_as_sequence */
0, /* tp_as_mapping */
0, /* tp_hash */
PyVectorcall_Call, /* tp_call */
0, /* tp_str */
// ... 略 ...
0, /* tp_dict */
func_descr_get, /* tp_descr_get */
0, /* tp_descr_set */
offsetof(PyFunctionObject, func_dict), /* tp_dictoffset */
0, /* tp_init */
0, /* tp_alloc */
func_new, /* tp_new */
};

看的出來這個型別實作的功能不多,像是 tp_as_ 這三個成員變數都是空的,表示它不會被當數字、序列或是對映類型的資料來操作。這也合理的確函數沒有也不需要像字串、串列、字典或 Tuple 一樣的行為,它只要做好它的本分工作,就是接收參數、執行函數並且回傳應該回傳的值就好。

雖然是這樣,這個 PyFunctionObject 型別結構裡的東西倒是不少:

檔案:Include/cpython/funcobject.h
typedef struct {
PyObject_HEAD
_Py_COMMON_FIELDS(func_)
PyObject *func_doc; /* The __doc__ attribute, can be anything */
PyObject *func_dict; /* The __dict__ attribute, a dict or NULL */
PyObject *func_weakreflist; /* List of weak references */
PyObject *func_module; /* The __module__ attribute, can be anything */
PyObject *func_annotations; /* Annotations, a dict or NULL */
PyObject *func_typeparams; /* Tuple of active type variables or NULL */
vectorcallfunc vectorcall;
uint32_t func_version;
} PyFunctionObject;

如果把 _Py_COMMON_FIELDS(func_) 展開,整個 PyFunctionObject 看起來像這樣:

檔案:Include/cpython/funcobject.h
typedef struct {
PyObject_HEAD

// Py_COMMON_FIELDS
PyObject *func_globals;
PyObject *func_builtins;
PyObject *func_name;
PyObject *func_qualname;
PyObject *func_code; /* A code object, the __code__ attribute */
PyObject *func_defaults; /* NULL or a tuple */
PyObject *func_kwdefaults; /* NULL or a dict */
PyObject *func_closure; /* NULL or a tuple of cell objects */

PyObject *func_doc; /* The __doc__ attribute, can be anything */
PyObject *func_dict; /* The __dict__ attribute, a dict or NULL */
PyObject *func_weakreflist; /* List of weak references */
PyObject *func_module; /* The __module__ attribute, can be anything */
PyObject *func_annotations; /* Annotations, a dict or NULL */
PyObject *func_typeparams; /* Tuple of active type variables or NULL */
vectorcallfunc vectorcall;
uint32_t func_version;
} PyFunctionObject;

有些成員光看名字就很好猜,不過這個 func_code 成員變數,後面的註解寫著它是一個 Code Object,這傢伙已經看它出現好多次了,它應該就是函數的本體。在 Python 裡你可以把函數想像成它是一個帶有名字的盒子,當我們連名帶姓的呼喊這個函數的名字的時候,本質上就是把這顆函數物件裡的 Code Object 交給虛擬機器執行而已。問題是,這個 Code Object 是怎麼建立的?或是,它是什麼時候建立的?我們來寫個簡單的函數試試看..

準備建立函數

先來個打招呼的函數:

def greeting(name):
print(f"Hello, {name}")

它的 Bytecode 看起來會是這樣:

  1           2 LOAD_CONST               0 (<code object>)
4 MAKE_FUNCTION 0
6 STORE_NAME 0 (greeting)
8 RETURN_CONST 1 (None)

看起來應該是由 MAKE_FUNCTION 指令負責建立函數,指令的名字也滿直白的,但是在 MAKE_FUNCTION 指令之前有個 LOAD_CONST 指令載入了一顆 Code Object。我們之前也看過類似的指令,表示這顆 Code Object 是在編譯階段,也就是在 AST 轉換成 Bytecode 的過程中先就建立好了,這裡才能被「載入」,並且透過 MAKE_FUNCTION 指令把它包在函數裡。我們在「從準備到起飛!」章節講到 AST 到 Bytecode 的過程中,曾經追過 _PyAST_Compile() 函數,它的回傳值剛好就是一個 Code Object。

看起來想知道函數是怎麼回事,得先花點時間研究這個 Code Object,所以我們就晚點再來看函數,先看看 Code Object 長什麼樣子。

程式碼物件

光看 Code Object 名字就能知道它是一顆物件,照 CPython 的命名慣例,應該不難猜到這個物件叫做 PyCodeObject,翻一下原始碼會發現它被定義成巨集:

檔案:Include/cpython/code.h
#define _PyCode_DEF(SIZE) {                                                    \
PyObject_VAR_HEAD \
\
// ... 略 ...
/* The hottest fields (in the eval loop) are grouped here at the top. */ \
PyObject *co_consts; /* list (constants used) */ \
PyObject *co_names; /* list of strings (names used) */ \
PyObject *co_exceptiontable; /* Byte string encoding exception handling \
table */ \
// ... 略 ...
}

是一般的結構或是巨集都無所謂,但這個結構還不小,裡面有些成員在「為你自己學 Python」的函數 - 進階篇章節裡出現過,像是 co_constsco_names。接著來看看這個物件怎麼建立的。在 Python/compile.c 檔案開頭的有一段解註解是這樣寫的:

The primary entry point is _PyAST_Compile(), which returns a
PyCodeObject. The compiler makes several passes to build the code
object:
1. Checks for future statements. See future.c
2. Builds a symbol table. See symtable.c.
3. Generate an instruction sequence. See compiler_mod() in this file.
4. Generate a control flow graph and run optimizations on it. See flowgraph.c.
5. Assemble the basic blocks into final code. See optimize_and_assemble() in
this file, and assembler.c.

一開始會先檢查有沒有「未來」陳述句,就是 from __future__ import ... 的寫法,所謂的「未來模組」主要是讓 Python 2 的程式碼能夠在 Python 3 上運行,這個步驟主要是為了確保程式碼的相容性。接著會建立符號表(Symbol Table),符號表裡會記錄程式裡會用到的變數、函數或是類別等資訊。

再來是把 AST 轉成中間碼(Intermediate Code),再進行一些流程的分析跟最佳化,這感覺有點複雜,不過最後組裝的過程是在 Python/compile.coptimize_and_assemble() 函數中開始,Code Object 應該也是在這時候組裝起來,我們就從這裡開始追看看:

檔案:Python/compile.c
static PyCodeObject *
optimize_and_assemble(struct compiler *c, int addNone)
{
struct compiler_unit *u = c->u;
PyObject *const_cache = c->c_const_cache;
PyObject *filename = c->c_filename;

int code_flags = compute_code_flags(c);

// ... 略 ...

return optimize_and_assemble_code_unit(u, const_cache, code_flags, filename);
}

果然這個函數就是建立並回傳 PyCodeObject 的地方。順著 optimize_and_assemble_code_unit() 往下追:

檔案:Python/compile.c
static PyCodeObject *
optimize_and_assemble_code_unit(struct compiler_unit *u, PyObject *const_cache,
int code_flags, PyObject *filename)
{
// ... 略 ...
co = _PyAssemble_MakeCodeObject(&u->u_metadata, const_cache, consts,
maxdepth, &optimized_instrs, nlocalsplus,
code_flags, filename);

// ... 略 ...
}

這個函數的行數稍微有點多,前半段大多都是在做一些準備工作,最後的 _PyAssemble_MakeCodeObject() 函數就是把前面準備好的資料進行組裝,並產生 Code Object:

檔案:Python/assemble.c
PyCodeObject *
_PyAssemble_MakeCodeObject(_PyCompile_CodeUnitMetadata *umd, PyObject *const_cache,
PyObject *consts, int maxdepth, instr_sequence *instrs,
int nlocalsplus, int code_flags, PyObject *filename)
{
PyCodeObject *co = NULL;

struct assembler a;
int res = assemble_emit(&a, instrs, umd->u_firstlineno, const_cache);
if (res == SUCCESS) {
co = makecode(umd, &a, const_cache, consts, maxdepth, nlocalsplus,
code_flags, filename);
}
assemble_free(&a);
return co;
}

就是它了,makecode() 函數就是建立 Code Object 的地方:

檔案:Python/assemble.c
static PyCodeObject *
makecode(_PyCompile_CodeUnitMetadata *umd, struct assembler *a, PyObject *const_cache,
PyObject *constslist, int maxdepth, int nlocalsplus, int code_flags,
PyObject *filename)
{
PyCodeObject *co = NULL;
PyObject *names = NULL;
PyObject *consts = NULL;
PyObject *localsplusnames = NULL;

// ... 略 ...
consts = PyList_AsTuple(constslist); /* PyCode_New requires a tuple */

// ... 略 ...
localsplusnames = PyTuple_New(nlocalsplus);

struct _PyCodeConstructor con = {
// ... 略 ...
.consts = consts,
.names = names,
.localsplusnames = localsplusnames,
};

// ... 略 ...
co = _PyCode_New(&con);

// ... 略 ...
return co;
}

這個函數裡有幾個重點,首先,會把在這裡用到的常數跟區域變數用 Tuple 型態來存放,最後再呼叫 _PyCode_New() 函數來建立 Code Object:

檔案:Objects/codeobject.c
PyCodeObject *
_PyCode_New(struct _PyCodeConstructor *con)
{
// ... 略 ...
Py_ssize_t size = PyBytes_GET_SIZE(con->code) / sizeof(_Py_CODEUNIT);
PyCodeObject *co = PyObject_NewVar(PyCodeObject, &PyCode_Type, size);
if (co == NULL) {
Py_XDECREF(replacement_locations);
PyErr_NoMemory();
return NULL;
}
init_code(co, con);
Py_XDECREF(replacement_locations);
return co;
}

可以看到這個函數建立了一顆 PyCodeObject 物件,最後再把前面收集到的資訊透過 init_code() 函數進行初始化:

檔案:Objects/codeobject.c
static void
init_code(PyCodeObject *co, struct _PyCodeConstructor *con)
{
// ... 略 ...
co->co_filename = Py_NewRef(con->filename);
co->co_name = Py_NewRef(con->name);
co->co_qualname = Py_NewRef(con->qualname);
co->co_flags = con->flags;

// ... 略 ...
co->co_consts = Py_NewRef(con->consts);
co->co_names = Py_NewRef(con->names);

// ... 略 ...
}

這樣,就完成了 Code Object 的建立。我們可以進到 REPL 裡試玩看看:

$ python -i hi.py
>>> greeting.__code__
<code object greeting at 0x104f97130>
>>> greeting.__code__.co_name
'greeting'
>>> greeting.__code__.co_consts
(None, 'Hello, ')

在 Python 裡可以透過 __code__ 取得這個函數的 Code Object,剛剛最後進行初始化的那些值,像是 co_nameco_consts 都可以透過這個 Code Object 取得。這樣我們就可以知道這個函數的名字是 greeting,而且在這個函數裡使用到的常數是 None'Hello, '

常數 'Hello, ' 還可以理解,各位可以猜猜看在這個函數裡面什麼時候用到 None 了呢?

工商服務

想學 Python 嗎?我教你啊 :)

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