跳至主要内容

虛擬機器五部曲(四)

為你自己學 Python

在 Python 的作用域(Scope)有分 LEGB(Local、Enclosing、Global、Built-in)這四種,如果想知道細節可參閱「為你自己學 Python」的函數 - 基礎篇的介紹,這個章節要來看看在 CPython 裡是怎麼實現這幾種作用域的。

變數 Scope

在上個章節有提到,每當執行或呼叫一個函數的時候,會建立一個 Frame,而這個 Frame 裡會包

a = 9527
b = 1450

def hello():
x = 520
y = 1314
print(a, b, x, y)

hello()

這裡有兩個全域變數 ab 以及兩個區域變數 xy,當執行 hello() 函數之後應該會印出這些變數的值,這沒什麼太大問題,不過我們來看看這段程式碼執行時候的 Bytecode 指令:

  // ... 略 ...
5 0 RESUME 0
6 2 LOAD_CONST 1 (520)
4 STORE_FAST 0 (x)

7 6 LOAD_CONST 2 (1314)
8 STORE_FAST 1 (y)

8 10 LOAD_GLOBAL 1 (NULL + print)
20 LOAD_GLOBAL 2 (a)
30 LOAD_GLOBAL 4 (b)
40 LOAD_FAST 0 (x)
42 LOAD_FAST 1 (y)
44 CALL 4
// ... 略 ...

可以看到在讀取全域變數 a 是用 LOAD_GLOBAL,而讀取區域變數 x 以及 y 的時候是 LOAD_FAST,表示在 Python 裡讀取全域變數跟區域變數是有不同的方式...

區域變數(L)

先來看看讀取區域變數的 LOAD_FAST 指令在做什麼事:

檔案:Python/bytecodes.c
inst(LOAD_FAST, (-- value)) {
value = GETLOCAL(oparg);
assert(value != NULL);
Py_INCREF(value);
}

挺簡單的,再看看 GETLOCAL 巨集怎麼定義的:

檔案:Python/ceval_macros.h
#define GETLOCAL(i)     (frame->localsplus[i])

我們在上個章節看過的 localsplus,它就是在 Frame 裡的一個成員,而且還是一個彈性陣列成員,用來存放區域變數,而 i 就是區域變數的索引,這樣就可以取得區域變數的值了,所以剛才上面的這兩行:

         40 LOAD_FAST                0 (x)
42 LOAD_FAST 1 (y)

LOAD_FAST 0 就是取得 x 的值,LOAD_FAST 1 就是取得 y 的值。但這是什麼時候把區域變數放進去的呢?看來可能是前面的 STORE_FAST 指令幹的好事,來追看看:

檔案:Python/bytecodes.c
inst(STORE_FAST, (value --)) {
SETLOCAL(oparg, value);
}

來看看 SETLOCAL 巨集怎麼定義的:

檔案:Python/ceval_macros.h
#define SETLOCAL(i, value)      do { PyObject *tmp = GETLOCAL(i); \
GETLOCAL(i) = value; \
Py_XDECREF(tmp); } while (0)

這也挺簡單的,透過剛剛看到的 GETLOCAL 巨集先把原本在 localsplus 的值拿出來,然後把要設定的值擺在指定的位置,最後再把原本的值釋放掉,這樣就完成了區域變數的存取。這裡用了 do { ... } while (0) 的寫法,第一次看可能會覺得有點怪,但這麼寫的目的主要是可以安全的在裡面放多個陳述句而不會引起語法錯誤。

所以,果然 STORE_FAST 0 就是把 x 的值存進 localsplus 的第 0 個位置,STORE_FAST 1 就是把 y 的值存進 localsplus 的第 1 個位置,依此類推,這樣後續要讀取區域變數的時候就可以透過 LOAD_FAST 搭配索引值就能取得。

區域變數挺單純的,接下來看看全域變數。

全域及內建變數(G、B)

我們先從全域變數的儲存來看:

1           2 LOAD_CONST               0 (9527)
4 STORE_NAME 0 (a)

2 6 LOAD_CONST 1 (1450)
8 STORE_NAME 1 (b)

使用的指令是 STORE_NAME,來看看這個指令在做什麼事:

檔案:Python/bytecodes.c
inst(STORE_NAME, (v -- )) {
PyObject *name = GETITEM(frame->f_code->co_names, oparg);
PyObject *ns = LOCALS();
int err;
// ... 略 ...
if (PyDict_CheckExact(ns))
err = PyDict_SetItem(ns, name, v);
else
err = PyObject_SetItem(ns, name, v);
DECREF_INPUTS();
ERROR_IF(err, error);
}

name 是從目前的 Frame 取得 Code Object 裡的 co_name 成員,這通常是一個 Tuple,所以搭配 GETITEM() 巨集搭配 oparg 可以取得這個 Tuple 的指定位置的資料。

接著,ns 是透過 LOCALS() 巨集是從 Frame 取得 f_locals 成員,這通常是個字典型態的資料,有時候我們在 Python 裡執行 locals() 函數的時候看到的結果就是它,這裡命名叫 ns 的原因是因為它是一個 Namespace。

拿到指定位置的名字以及 Namespace 之後,就可以透過 PyDict_SetItem()PyObject_SetItem() 來把值存進去,這樣就完成了全域變數的儲存。所以:

        2 LOAD_CONST               0 (9527)
4 STORE_NAME 0 (a)

6 LOAD_CONST 1 (1450)
8 STORE_NAME 1 (b)

應該就很清楚知道把 9527 存進 a,把 1450 存進 b,而這些變數跟值,就會存在 Frame 的 f_locals 這個字典裡。

知道怎麼存進去之後,接下來看看怎麼讀取全域變數。全域變數的讀取是透過 LOAD_GLOBAL 指令,跟剛才的 LOAD_FAST 相比,這個指令就複雜多了:

檔案:Python/bytecodes.c
inst(LOAD_GLOBAL, (unused/1, unused/1, unused/1, unused/1 -- null if (oparg & 1), v)) {
#if ENABLE_SPECIALIZATION
_PyLoadGlobalCache *cache = (_PyLoadGlobalCache *)next_instr;
if (ADAPTIVE_COUNTER_IS_ZERO(cache->counter)) {
PyObject *name = GETITEM(frame->f_code->co_names, oparg>>1);
next_instr--;
_Py_Specialize_LoadGlobal(GLOBALS(), BUILTINS(), next_instr, name);
DISPATCH_SAME_OPARG();
}
STAT_INC(LOAD_GLOBAL, deferred);
DECREMENT_ADAPTIVE_COUNTER(cache->counter);
#endif /* ENABLE_SPECIALIZATION */
// ... 略 ...
}

我們一段一段來看,首先,這行:

PyObject *name = GETITEM(frame->f_code->co_names, oparg>>1);

這個操作應該不陌生了,就是要取得當前 Frame 的 Code Object 的 co_names 成員,剛有說到它是個 Tuple 結構,而最後面的 oparg>>1 的用意是把 oparg 向右移一位得到索引值,然後從 co_names 取得相對應的值,例如:

     10 LOAD_GLOBAL              1 (NULL + print)
20 LOAD_GLOBAL 2 (a)
30 LOAD_GLOBAL 4 (b)

這時候在 co_names 的結構應該是 (NULL + print, a, b),而 LOAD_GLOBAL 2 把 2 >> 1 之後得到 1,所以 co_names[1] 會得到 a。而 LOAD_GLOBAL 4 向右移一位得到 2,表示 co_names[2] 會的到 b,依此類推。

接下來:

_Py_Specialize_LoadGlobal(GLOBALS(), BUILTINS(), next_instr, name);

這個 _Py_Specialize_LoadGlobal() 追進去看會發現裡面挺複雜的,不過這個函數在做的事挺有趣的,假設我們有以下 Python 程式碼:

print(x)

在第一次執行的時候,Python 會正常透過 LOAD_GLOBAL 指令來查找 printx。這個查找過程會需要檢查全域以及內建的命名空間,相對比較起來有那麼一點點慢。當 _Py_Specialize_LoadGlobal 介入後,會把這次查找的結果記錄下來:

例如,記錄變數 x 的位置是在全域命名空間中的某個位置,或是記錄這次查找是從內建命名空間中找到的。這些訊息會被儲存在這條 Bytecode 後面的記憶體位置,這樣下次再遇到 print(x) 時,虛擬機器可以跳過完整的查找過程,直接用快取信息來取得 x 的值。

也就是說,如果某個變數被反覆多次查找,比如 print() 這樣的內建函數,Python 會覺得「嗯...這個東西我好像常常會用到,我是不是乾脆就記住它的位置,下次再用的時候就不必再從頭慢慢找?」

傳進這個函數的幾個引數,例如 next_instr 是指下一個 Bytecode 指令,GLOBALS()BUILTINS() 是巨集,分別是取得 Frame 的 f_globals 以及 f_builtins,就是全域變數和內建變數:

檔案:Python/ceval_macros.h
#define GLOBALS() frame->f_globals
#define BUILTINS() frame->f_builtins

好,繼續再往下看 LOAD_GLOBAL 指令的下半部:

檔案:Python/bytecodes.c
inst(LOAD_GLOBAL, (unused/1, unused/1, unused/1, unused/1 -- null if (oparg & 1), v)) {
// ... 略 ...
PyObject *name = GETITEM(frame->f_code->co_names, oparg>>1);
if (PyDict_CheckExact(GLOBALS())
&& PyDict_CheckExact(BUILTINS()))
{
v = _PyDict_LoadGlobal((PyDictObject *)GLOBALS(),
(PyDictObject *)BUILTINS(),
name);
// ... 略 ...
Py_INCREF(v);
}
else {
v = PyObject_GetItem(GLOBALS(), name);
if (v == NULL) {
// ... 略 ...

v = PyObject_GetItem(BUILTINS(), name);
// ... 略 ...
}
}
null = NULL;
}

我把一些錯誤處理的部分省略掉,可以看到 Python 會先判斷 GLOBALS()BUILTINS() 是否是字典型態,如果是的話就會透過 _PyDict_LoadGlobal() 來取得變數的值,否則就是走另一條路,使用 PyObject_GetItem() 函數取值。

但不管是哪一種方式,都可以看到當在查找全域變數的時候,會先從全域變數(G)開始找,如果找不到就會再從內建變數(B)找,這樣就完成了全域變數的讀取。

Enclosing 變數(E)

最後是 Enclosing 變數,這個變數是指在巢狀函數裡的變數,例如:

def outer():
x = 520

def inner():
print(x)

inner()

這裡的 x 就是 Enclosing 變數,如果檢視這段程式碼的 Bytecode,會發現 x = 520 的指令是:

       4 LOAD_CONST               1 (520)
6 STORE_DEREF 1 (x)

print(x) 的指令是:

       4 LOAD_GLOBAL              1 (NULL + print)
14 LOAD_DEREF 0 (x)
16 CALL 1

看起來就是 STORE_DEREFLOAD_DEREF 這兩個指令在處理 Enclosing 變數了。先看看 STORE_DEREF

檔案:Python/bytecodes.c
inst(STORE_DEREF, (v --)) {
PyObject *cell = GETLOCAL(oparg);
PyObject *oldobj = PyCell_GET(cell);
PyCell_SET(cell, v);
Py_XDECREF(oldobj);
}

這裡的 PyCellObject 跟「閉包(Closure)」有關,這個我們留到下個章節再來詳細介紹,目前暫時可把它想像成就是一個用來存放 Enclosing 變數的容器。那 LOAD_DEREF 又是怎麼回事:

檔案:Python/bytecodes.c
inst(LOAD_DEREF, ( -- value)) {
PyObject *cell = GETLOCAL(oparg);
value = PyCell_GET(cell);
if (value == NULL) {
format_exc_unbound(tstate, frame->f_code, oparg);
ERROR_IF(true, error);
}
Py_INCREF(value);
}

果然,就是從 PyCellObject 物件裡取值,這樣就完成了 Enclosing 變數的存取。

做個簡單的整理:

  • 區域變數(L)透過 STORE_FAST 指令儲存在 Frame 的 f_localsplus 裡,並透過 LOAD_FAST 指令讀取。
  • Enclosing 變數(E)透過 STORE_DEREF 儲存在 PyCellObject 物件裡,透過 LOAD_DEREF 指令來讀取。
  • 全域變數(G)儲存在 Frame 的 f_globals 成員裡,而內建函數(B)是存在 Frame 的 f_builtins 成員裡,但這兩個都是透過 STORE_NAME 指令進行設定,然後透過 LOAD_GLOBAL 指令進行讀取。

雖然 LEGB 的查找順序是在程式執行階段才進行的,但 x = 520 是全域還是區域,是在編譯階段就決定好了。也就是說,當你寫了 Python 程式碼並開始執行的時候,Python 編譯器會將你寫的文字編譯成 Bytecode,這些 Bytecode 就已經告訴虛擬機器該用什麼方式來查找變數了。如果想要更深入了解 Python 是如何在編譯階段決定 LEGB 作用域的查找順序的話,那麼就需要深入了解 Python 的 Bytecode 的編譯過程了(不想面對)。

工商服務

想學 Python 嗎?我教你啊 :)

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