iT邦幫忙

2024 iThome 鐵人賽

DAY 22
0
Python

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

Day 22 - 虛擬機器五部曲(五)

  • 分享至 

  • xImage
  •  

本文同步刊載於 「為你自己學 Python - 虛擬機器五部曲(五)

虛擬機器五部曲(五)

為你自己學 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 - 虛擬機器五部曲(五)


上一篇
Day 21 - 虛擬機器五部曲(四)
下一篇
Day 23 - 類別與它們的產地
系列文
為你自己讀 CPython 原始碼31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言