跳至主要内容

虛擬機器五部曲(五)

為你自己學 Python

上個章節介紹了 LEGB 四種 Scope 的設計,但在這過程中有看到一個特別的物件叫做 Cell Object,在 Python 這個物件是用來實現「閉包(Closure)」效果的。不少程式語言都有「閉包(Closure)」這個的設計,在「為你自己學 Python」的函數 - 進階篇有介紹過,不過這個章節要直接從 CPython 的原始碼來看看 Cell Object 是怎麼回事,以及閉包是怎麼設計的。

建立 Cell Object

先看看 Cell 的結構:

檔案:Include/cpython/cellobject.h
typedef struct {
PyObject_HEAD
PyObject *ob_ref;
} PyCellObject;

跟其它型態相比,PyCellObject 的結構單純很多,除了標準的 PyObject_HEAD 之外就只有一個 ob_ref 成員,這個 ob_ref 是個 PyObject 型別,所以這讓 PyCellObject 可以儲存對任何一種 Python 的物件的指標。來看看它是怎麼建立的,以這段程式碼為例:

def hi():
a = 1
b = 2

def hey():
print(a)

先來看看定義 hi() 函數的部份 Bytecode:

// ... 略 ...
0 MAKE_CELL 2 (a)

2 4 LOAD_CONST 1 (1)
6 STORE_DEREF 2 (a)

3 8 LOAD_CONST 2 (2)
10 STORE_FAST 0 (b)
// ... 略 ...

在正式建立 hey() 函數之前,有好幾個沒看過的新指令,我們一行一行來看,首先看看 MAKE_CELL 2

檔案:Python/bytecodes.c
inst(MAKE_CELL, (--)) {
PyObject *initial = GETLOCAL(oparg);
PyObject *cell = PyCell_New(initial);
if (cell == NULL) {
goto resume_with_error;
}
SETLOCAL(oparg, cell);
}

GETLOCAL(oparg) 在上個章節看過,這會根據 oparg 的值從目前的 Frame 的 localsplus 這個陣列上取值,接著把它傳給 PyCell_New() 函數:

檔案:Objects/cellobject.c
PyObject *
PyCell_New(PyObject *obj)
{
PyCellObject *op;

op = (PyCellObject *)PyObject_GC_New(PyCellObject, &PyCell_Type);
if (op == NULL)
return NULL;
op->ob_ref = Py_XNewRef(obj);

_PyObject_GC_TRACK(op);
return (PyObject *)op;
}

在這個函數建立了一個 PyCellObject 物件,然後把傳進來的 obj 設定給了這個 Cell 的 ob_ref 成員。然後再把這個 Cell 透過 SETLOCAL(oparg, cell) 巨集擺回區域變數,也就是 Frame 的 localsplus 陣列的指定位置。也就是說,還沒做到 a = 1 的設定之前,已經在區域變數 localsplus 陣列裡幫變數 a 準備好一個 Cell 了。

接下來的 STORE_DEREF 2 我們在上個章節就看過了,這裡的 2 跟剛剛的 MAKE_CELL 2 也就是 oparg 是一樣的,表示是把值存放在剛剛準備好的 Cell 裡。接下來的變數 b 就沒這待遇了,它就只是一般的區域變數,所以這裡就是用 STORE_FAST 0 來處理。

閉包

再接著往下看:

// ... 略 ...
5 12 LOAD_CLOSURE 2 (a)
14 BUILD_TUPLE 1
16 LOAD_CONST 3 (<code object)
18 MAKE_FUNCTION 8 (closure)
20 STORE_FAST 1 (hey)
22 RETURN_CONST 0 (None)
// ... 略 ...

從編譯出來的 Bytecode 看的出來,雖然對於內層的 hey() 函數也是用我們之前看過的 MAKE_FUNCTION 指令,但這之前有個 LOAD_CLOSURE 2 指令,這是在做什麼?

檔案:Python/bytecodes.c
inst(LOAD_CLOSURE, (-- value)) {
value = GETLOCAL(oparg);
ERROR_IF(value == NULL, unbound_local_error);
Py_INCREF(value);
}

其實沒做什麼事,就只是把 Frame 的 localsplus 陣列的值讀出來而已。再來的 BUILD_TUPLE 1 是在做什麼?

檔案:Python/bytecodes.c
inst(BUILD_TUPLE, (values[oparg] -- tup)) {
tup = _PyTuple_FromArraySteal(values, oparg);
ERROR_IF(tup == NULL, error);
}

從指令名字就猜的出來是要建一個 Tuple,並且把 LOAD_CLOSURE 讀出來的值放進去。接著是 MAKE_FUNCTION 8,這個我們在前面就有看過它,它是用來建立函數物件用的,但後面的 8 表示這個函數物件是個閉包:

檔案:Python/bytecodes.c
inst(MAKE_FUNCTION, (defaults    if (oparg & 0x01),
kwdefaults if (oparg & 0x02),
annotations if (oparg & 0x04),
closure if (oparg & 0x08),
codeobj -- func)) {
// ... 略 ...

if (oparg & 0x08) {
assert(PyTuple_CheckExact(closure));
func_obj->func_closure = closure;
}
// ... 略 ...
}

因為 oparg 是 8,所以這裡會做的事就是把剛剛建立的 Tuple 放進去函數物件的 func_closure 成員裡,到這裡就算完成了 hey() 函數的建立了。接著再往下看看執行內層的 hey() 函數的時候發生什麼事。

自由變數

            0 COPY_FREE_VARS           1

6 4 LOAD_GLOBAL 1 (NULL + print)
14 LOAD_DEREF 0 (a)
16 CALL 1
24 POP_TOP
26 RETURN_CONST 0 (None)

這裡有個沒看過的指令 COPY_FREE_VARS 1,這是在做什麼?

檔案:Python/bytecodes.c
inst(COPY_FREE_VARS, (--)) {
PyCodeObject *co = frame->f_code;
assert(PyFunction_Check(frame->f_funcobj));
PyObject *closure = ((PyFunctionObject *)frame->f_funcobj)->func_closure;
assert(oparg == co->co_nfreevars);
int offset = co->co_nlocalsplus - oparg;
for (int i = 0; i < oparg; ++i) {
PyObject *o = PyTuple_GET_ITEM(closure, i);
frame->localsplus[offset + i] = Py_NewRef(o);
}
}

這不太難理解,這裡的 closure 就是剛剛建立並且放在函數物件的 func_closure 成員的那個 Tuple,接著就是把這個 Tuple 裡的值複製到當前這個 Frame 的 localsplus 陣列裡,也就是變成這個 Frame 的區域變數,而且是接在原本的區域變數的後面。這樣在 hey() 函數裡面就可以使用外層函數的區域變數了。所以,所謂的「自由變數(Free Variable)」是指在內層函數本身沒有定義或宣告,而是使用外層函數的區域變數的情況,如果能看懂上面這個流程,自由變數看起來就一點都不神秘了。

從 Python 的角度來看...

前面我們都是從 CPython 的角度來看這些事情,如果想從 Python 的角度來看剛剛介紹的這些東西的話也是有方法的:

>>> hi.__code__.co_varnames
('b', 'hey')
>>> hi.__code__.co_cellvars
('a',)

每個函數都有 __code__ 屬性,它會指向這個函數的 Code Object。在這個 Code Object 上面有 co_varnames 可以取得在這個函數裡定義的區域變數,可以看到目前只有 bhey。那變數 a 呢?它在 Python 的角度來看已經不是單純的區域變數了,而是透過 co_cellvars 來取得,表示它已經是一個 Cell Object 了。

是說,這些 co_ 開頭的屬性是怎麼實作的?其實答案都在 PyCode_Type 結構的 tp_getsettp_members 成員裡:

檔案:Objects/codeobject.c
static PyGetSetDef code_getsetlist[] = {
{"co_lnotab", (getter)code_getlnotab, NULL, NULL},
{"_co_code_adaptive", (getter)code_getcodeadaptive, NULL, NULL},
{"co_varnames", (getter)code_getvarnames, NULL, NULL},
{"co_cellvars", (getter)code_getcellvars, NULL, NULL},
{"co_freevars", (getter)code_getfreevars, NULL, NULL},
{"co_code", (getter)code_getcode, NULL, NULL},
{0}
};

static PyMemberDef code_memberlist[] = {
{"co_argcount", T_INT, OFF(co_argcount), READONLY},
{"co_posonlyargcount", T_INT, OFF(co_posonlyargcount), READONLY},
{"co_kwonlyargcount", T_INT, OFF(co_kwonlyargcount), READONLY},
{"co_stacksize", T_INT, OFF(co_stacksize), READONLY},
{"co_flags", T_INT, OFF(co_flags), READONLY},
{"co_nlocals", T_INT, OFF(co_nlocals), READONLY},
{"co_consts", T_OBJECT, OFF(co_consts), READONLY},
{"co_names", T_OBJECT, OFF(co_names), READONLY},
{"co_filename", T_OBJECT, OFF(co_filename), READONLY},
{"co_name", T_OBJECT, OFF(co_name), READONLY},
{"co_qualname", T_OBJECT, OFF(co_qualname), READONLY},
{"co_firstlineno", T_INT, OFF(co_firstlineno), READONLY},
{"co_linetable", T_OBJECT, OFF(co_linetable), READONLY},
{"co_exceptiontable", T_OBJECT, OFF(co_exceptiontable), READONLY},
{NULL} /* Sentinel */
};

這些 co_ 開頭的方法都在這裡了!這些方法會分成 tp_getset 以及 tp_members 的原因,是因為 tp_members 成員放的大多是比較靜態的屬性,這些屬性直接映射到結構裡的成員變數,它的記憶體指標的偏移值是固定的。讀取或修改這些屬性時比較不需要額外的計算,操作起來速度比較快。而 tp_getset 需要透過 getter 和 setter 函數來回傳或或修改指定的值,雖然比較有彈性,但就沒像像 tp_members 的操作那麼快。

如果想要看到更細部的操作,可以使用 Python 內建的中斷點(Breakpoint)來觀察:

def hi():
a = 1
b = 2

def hey():
print(a)

breakpoint()

執行之後進入互動模式,可以看到 hey 函數的的一些屬性:

$ python -i hi.py
>>> hi()
--Return--
> /Users/kaochenlong/projects/products/books/pythonbook.cc/hi.py(8)hi()->None
-> breakpoint()
(Pdb) hey
<function hi.<locals>.hey>
(Pdb) hey.__closure__
(<cell: int object>,)
(Pdb) hey.__closure__[0]
<cell: int object>
(Pdb) hey.__closure__[0].cell_contents
1

透過函數的 .__closure__ 屬性可以取得內層 hey() 函數所有的 Cell,每個 Cell 都有一個 cell_contents 屬性,可以看到這顆 Cell 裡面包的是什麼東西。除此之外,Python 有個內建模組 inspect,可以讓我們直接取得當前的 Frame:

(Pdb) import inspect
(Pdb) f = inspect.currentframe()
(Pdb) f
<frame>
(Pdb) f.f_code
<code object <module>>
(Pdb) f.f_locals
{
'b': 2,
'hey': <function hi.<locals>.hey>,
'a': 1,
'__return__': None,
'inspect': <module 'inspect'>,
'f': <frame>
}
(Pdb) f.f_locals['hey']
<function hi.<locals>.hey>

透過 inspect.currentframe() 函數可以取得當前的 Frame,之前我們在 Frame 看過的那些屬性就能透過它拿來玩看看了。關於 pdb 的使用方式,可參關「為你自己學 Python」的偵錯工具章節介紹。

不知道大家從一開始看到這個章節,是不是差不多已經能夠抓到假設想要知道某個功能是怎麼實作的,應該從什麼地方下手去追原始碼了呢 :)

工商服務

想學 Python 嗎?我教你啊 :)

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