iT邦幫忙

2024 iThome 鐵人賽

DAY 19
0
Python

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

Day 19 - 虛擬機器大冒險(二)

  • 分享至 

  • xImage
  •  

本文同步刊載於 「為你自己學 Python - 虛擬機器大冒險(二)

虛擬機器大冒險(二)

為你自己學 Python

上個章節大概看過包在函數裡的 Code Object,Code Object 是在編譯過程就先建立好,在程式執行的時候透過 LOAD_CONST 指令載入並被包進函數裡,而函數應該就不是了,它是在執行過程中透過 MAKE_FUNCTION 指令建立的,接下來就再來看看函數物件本身是怎麼回事。

建立函數物件

在 Python 裡的函數物件長這個樣子:

// 檔案: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__ 屬性。但是要怎麼建立函數物件呢?在上個章節我們也有看到函數物件是透過 MAKE_FUNCTION 指令產生的,函數物件裡包含了函數的名稱、參數、預設值、程式碼。來看看這個指令做了什麼事:

// 檔案:Python/bytecodes.c

inst(MAKE_FUNCTION, (defaults    if (oparg & 0x01),
                     kwdefaults  if (oparg & 0x02),
                     annotations if (oparg & 0x04),
                     closure     if (oparg & 0x08),
                     codeobj -- func)) {

    PyFunctionObject *func_obj = (PyFunctionObject *)
        PyFunction_New(codeobj, GLOBALS());

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

    // ... 略 ...
}

嗯...看的出來這個指令就是建立一顆 PyFunctionObject 物件,但後面有一連串的 oparg 的位元運算,那是在幹嘛?這個 oparg 又是什麼?

參數長什麼樣子?

其實,這個 oparg 是在編譯期間就已經決定好的,大家回頭來看看編譯出好的 Bytecode 指令:

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

MAKE_FUNCTION 指令後面的那個 0 就是 oparg,主要的用途是告訴虛擬機器這個函數物件的屬性。oparg 是一個 8 位元的整數,不過目前只使用了最低的 4 位。不同的函數可能會有不同的屬性,例如原本的範例只帶了個簡單的參數,它的 oparg0,如果改成這樣:

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

這是個帶有預設參數的函數,這時候 oparg 就是 1,也就是 0001,如果再改成這樣:

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

加上了型別註記(Type Annotation),這時候 oparg 就是 4,也就是 0100,如果再改成這樣:

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

這樣 oparg 就是 5,也就是 0101,這是 00010100 的位元運算結果。透過 oparg 可以告訴虛擬機器這個函數物件大概長什麼樣子。

所以在 MAKE_FUNCTION 指令裡面後半段一連串的位元運算就是在做這件事。

存取函數屬性

是說,為什麼透過函數的 __name__ 可以取得函數的名字,__code__ 可以取得函數裡面包的那顆 Code Object,__annotations__ 可以取得型別註記,這些屬性是怎麼對應到函數物件的?

還記得在上個章節有看過 PyFunction_Type 這個型別嗎?雖然它沒有 tp_as_ 之類的成員,但如果要透過屬性存取的時候,要看的成員是 tp_getset,它對應到 func_getsetlist

// 檔案:Objects/funcobject.c

static PyGetSetDef func_getsetlist[] = {
    {"__code__", (getter)func_get_code, (setter)func_set_code},
    {"__defaults__", (getter)func_get_defaults,
     (setter)func_set_defaults},
    {"__kwdefaults__", (getter)func_get_kwdefaults,
     (setter)func_set_kwdefaults},
    {"__annotations__", (getter)func_get_annotations,
     (setter)func_set_annotations},
    {"__dict__", PyObject_GenericGetDict, PyObject_GenericSetDict},
    {"__name__", (getter)func_get_name, (setter)func_set_name},
    {"__qualname__", (getter)func_get_qualname, (setter)func_set_qualname},
    {"__type_params__", (getter)func_get_type_params,
     (setter)func_set_type_params},
    {NULL} /* Sentinel */
};

這裡看到的 gettersetter,就是用來取得跟設定屬性的函數,翻一下 __code__ 對應到的 func_get_code 函數:

// 檔案:Objects/funcobject.c

static PyObject *
func_get_code(PyFunctionObject *op, void *Py_UNUSED(ignored))
{
    if (PySys_Audit("object.__getattr__", "Os", op, "__code__") < 0) {
        return NULL;
    }

    return Py_NewRef(op->func_code);
}

滿簡單的,就是回傳 PyFunctionObject 結構裡的 func_code 屬性。其他的屬性也是類似的,例如 __name__ 屬性:

// 檔案:Objects/funcobject.c

static PyObject *
func_get_name(PyFunctionObject *op, void *Py_UNUSED(ignored))
{
    return Py_NewRef(op->func_name);
}

是說,既然這裡有 setter 可以用,我是不是可以做到在執行階段動態的改變函數裡的 Code Object,讓函數的行為改變呢?來試試看:

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

greeting("Kitty")  # Hello, Kitty

secret_object = compile('print("Hey Hey!")', __name__, "exec")
greeting.__code__ = secret_object
greeting()

其實並不是隨便什麼東西都能往 __code__ 裡面放的,有興趣可追一下 func_set_code 函數,裡面有一些檢查機制。不管如何,上面這段範例執行之後的確會印出 Hey Hey!,所以的確是做的到的,只是我目前想不太到什麼情境會需要這樣做。

呼叫函數

我們之前在看 PyType_Type 結構的時候有看過一個 tp_call 成員,當試著要對某個物件施展小括號 () 這個動作的時候,會觸發這個成員。函數物件也有這個成員,來看看 PyFunction_Typetp_call 成員,它指向 PyVectorcall_Call() 函數。先不看原始碼,先看看這名字,Vector 通常我們會翻譯成「向量」,但呼叫函數跟向量有什麼關係?

向量呼叫?

早期的 Python 在呼叫函數的時候,會把參數打包成一個 Tuple 跟一個字典,分別表示位置引數(Positional Argument)以及關鍵字引數(Keyword Argument),這個動作可能需要額外的記憶體和操作。Python 3.9 版之後,在 PEP 590 加入了一個新的呼叫機制,稱為「Vectorcall」,它的標題就寫著「a fast calling protocol for CPython」,在 3.9 版的 Changelog 可以看到這個改變可以提昇一些常用的資料型態,像是串列、Tuple、字典、集合等等的呼叫效能。以目前的版本來說,大部份都已經支援 Vectorcall,不過有些第三方套件可能還是使用 tp_call 的方式。

在官方文件裡有寫到這一段:

Changed in version 3.12:

The Py_TPFLAGS_HAVE_VECTORCALL flag is now removed from a class when the class’s call() method is reassigned. (This internally sets tp_call only, and thus may make it behave differently than the vectorcall function.) In earlier Python versions, vectorcall should only be used with immutable or static types.

上面這段意思是,如果我們在我們自己寫的類別有實作 __call__ 方法的時候,Py_TPFLAGS_HAVE_VECTORCALL 的設定就會被移除,表示就不會是 Vectorcall 而是傳統的 tp_call 了。

講的好像很厲害,這個 Vectorcall 是快在哪裡?來看看 PyVectorcall_Call() 函數:

// 檔案:Objects/call.c

PyObject *
PyVectorcall_Call(PyObject *callable, PyObject *tuple, PyObject *kwargs)
{
    PyThreadState *tstate = _PyThreadState_GET();

    Py_ssize_t offset = Py_TYPE(callable)->tp_vectorcall_offset;

    // ... 略 ...

    vectorcallfunc func;
    memcpy(&func, (char *) callable + offset, sizeof(func));
    if (func == NULL) {
        _PyErr_Format(tstate, PyExc_TypeError,
                      "'%.200s' object does not support vectorcall",
                      Py_TYPE(callable)->tp_name);
        return NULL;
    }

    return _PyVectorcall_Call(tstate, func, callable, tuple, kwargs);
}

可以看到這裡是透過 tp_vectorcall_offset 成員變數算出要執行的函數在哪裡。為什麼這個會比較有效率,是因為這是直接從 PyObject_HEAD 開始加上偏移值得到的結果,其實這就只是是移動指標,避免了透過查找雜湊表或其他複雜的資料結構,所以速度還滿快的。接著用 memcpy() 函數對剛剛找到的函數指標複製一份,最後再呼叫內部 API _PyVectorcall_Call() 函數:

// 檔案:Objects/call.c

static PyObject *
_PyVectorcall_Call(PyThreadState *tstate, vectorcallfunc func,
                   PyObject *callable, PyObject *tuple, PyObject *kwargs)
{
    assert(func != NULL);

    Py_ssize_t nargs = PyTuple_GET_SIZE(tuple);

    if (kwargs == NULL || PyDict_GET_SIZE(kwargs) == 0) {
        return func(callable, _PyTuple_ITEMS(tuple), nargs, NULL);
    }

    PyObject *const *args;
    PyObject *kwnames;

    // ... 略 ...
    PyObject *result = func(callable, args,
                            nargs | PY_VECTORCALL_ARGUMENTS_OFFSET, kwnames);
    _PyStack_UnpackDict_Free(args, nargs, kwnames);

    return _Py_CheckFunctionResult(tstate, callable, result, NULL);
}

首先檢查是否有關鍵字參數。如果沒有,它會走一個快速路徑,直接呼叫 func。如果有,就稍微準備一下關鍵字引數,然後再來呼叫 func

對於大多數 Python 開發者來說,這種底層的最佳化是透明的,我們這些凡人只要知道 Python 在不斷改進其性能就行了。在大部份的程式語言,通常一個函數要被執行的時候,都會被擺置到一個堆疊裡,然後再被呼叫,有時候會稱這個堆疊叫做 Call Stack。Python 也有類似的設計,不過就讓我們在下個章節再來跟大家介紹是怎麼實作的。

本文同步刊載於 「為你自己學 Python - 虛擬機器大冒險(二)


上一篇
Day 18 - 虛擬機器大冒險(一)
下一篇
Day 20 - 虛擬機器大冒險(三)
系列文
為你自己讀 CPython 原始碼21
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言