局部變量是怎么實現靜態查找的,它和 local 名字空間又有什么聯系呢?
楔子
前面我們剖析了字節碼的執行流程,本來應該接著介紹一些常見指令的,但因為有幾個指令涉及到了局部變量,所以我們單獨拿出來說。與此同時,我們還要再度考察一下 local 名字空間,它的背后還隱藏了很多內容。
我們知道函數的參數和函數內部定義的變量都屬于局部變量,均是通過靜態方式訪問的。
x = 123
def foo1():
global x
a = 1
b = 2
# co_nlocals 會返回局部變量的個數
# a 和 b 是局部變量,x 是全局變量,因此是 2
print(foo1.__code__.co_nlocals) # 2
def foo2(a, b):
pass
print(foo2.__code__.co_nlocals) # 2
def foo3(a, b):
a = 1
b = 2
c = 3
print(foo3.__code__.co_nlocals) # 3
無論是參數還是內部新創建的變量,本質上都是局部變量。
按照之前的理解,當訪問一個全局變量時,會去訪問 global 名字空間(也叫全局名字空間)。
那么問題來了,當操作函數的局部變量時,是不是也等價于操作其內部的 local 名字空間(局部名字空間)呢?我們往下看。
如何訪問(創建)一個局部變量
之前我們說過 Python 變量的訪問是有規則的,會按照本地、閉包、全局、內置的順序去查找,也就是 LEGB 規則,所以在查找變量時,local 名字空間應該是第一選擇。
但不幸的是,虛擬機在為調用的函數創建棧幀對象時,這個至關重要的 local 名字空間并沒有被創建。因為棧幀的 f_locals 字段和 f_globals 字段分別指向了局部名字空間和全局名字空間,而創建棧幀時 f_locals 被初始化成了 NULL,所以并沒有創建局部名字空間。
我們通過源碼來進行驗證,不過要先補充一個知識點,就是當調用一個 Python 函數時,底層會調用哪些 C 函數呢?
我們看一下源碼:
// Objects/call.c
/*
* Python 函數也是一個對象,當調用 Python 函數時
* 底層會將 Python 函數對象作為參數,調用 _PyFunction_Vectorcall
* 關于函數,我們后續會剖析
*/
PyObject *
_PyFunction_Vectorcall(PyObject *func, PyObject* const* stack,
size_t nargsf, PyObject *kwnames)
{
// ...
// 參數 func 指向函數對象,它內部的 func_code 指向 PyCodeObject 對象
// 如果 co_flags & CO_OPTIMIZED 為真,表示 PyCodeObject 是被優化過的
// 那么對應的函數在調用時,會靜態查找本地局部變量
if (((PyCodeObject *)f->func_code)->co_flags & CO_OPTIMIZED) {
// 在這種情況下,會給 _PyEval_Vector 的第三個參數傳遞 NULL
return _PyEval_Vector(tstate, f, NULL, stack, nargs, kwnames);
}
// ...
}
// Python/ceval.c
/*
* 創建棧幀,調用 _PyEval_EvalFrame,最終執行幀評估函數
* 注意該函數的第三個參數,顯然它表示局部名字空間
* 而 _PyFunction_Vectorcall 在調用時傳遞的是 NULL
*/
PyObject *
_PyEval_Vector(PyThreadState *tstate, PyFunctionObject *func,
PyObject *locals,
PyObject* const* args, size_t argcount,
PyObject *kwnames)
{
// ...
// 執行幀評估函數之前,要先創建棧幀
// 這個過程由 _PyEvalFramePushAndInit 負責
_PyInterpreterFrame *frame = _PyEvalFramePushAndInit(
tstate, func, locals, args, argcount, kwnames);
// ...
return _PyEval_EvalFrame(tstate, frame, 0);
}
/*
* 在當前棧幀之上創建新的棧幀,并推入虛擬機為其準備的 C Stack 中
*/
static _PyInterpreterFrame *
_PyEvalFramePushAndInit(PyThreadState *tstate, PyFunctionObject *func,
PyObject *locals, PyObject* const* args,
size_t argcount, PyObject *kwnames)
{
// ...
// 棧幀創建之后,調用 _PyFrame_Initialize,進行初始化
_PyFrame_Initialize(frame, func, locals, code, 0);
// ...
}
// Include/internal/pycore_frame.h
/*
* 對 frame 進行初始化
*/
staticinlinevoid
_PyFrame_Initialize(
_PyInterpreterFrame *frame, PyFunctionObject *func,
PyObject *locals, PyCodeObject *code, int null_locals_from)
{
frame->f_funcobj = (PyObject *)func;
frame->f_code = (PyCodeObject *)Py_NewRef(code);
frame->f_builtins = func->func_builtins;
frame->f_globals = func->func_globals;
// 將 f_locals 字段初始化為參數 locals
// 而參數 locals 是從 _PyFunction_Vectorcall 一層層傳過來的
// 由于 _PyFunction_Vectorcall 傳的是 NULL
// 所以棧幀的 f_locals 字段最終會被初始化為 NULL
frame->f_locals = locals;
// ...
}
所以我們驗證了在調用函數時,棧幀的局部名字空間確實被初始化為 NULL,當然也明白了 C 函數的調用鏈路。
我們用 Python 代碼演示一下:
import inspect
# 模塊的棧幀
frame = inspect.currentframe()
# 對于模塊而言,局部名字空間和全局名字空間是同一個字典
print(frame.f_locals is frame.f_globals) # True
# 當然啦,局部名字空間和全局名字空間也可以通過內置函數獲取
print(
frame.f_locals is locals() is frame.f_globals is globals()
) # True
# 但對于函數而言就不一樣了
def foo():
name = "古明地覺"
return inspect.currentframe()
frame = foo()
# global 名字空間全局唯一
# 無論是獲取棧幀的 f_globals,還是調用 globals()
# 得到的都是同一份字典
print(frame.f_globals is globals()) # True
# 但每個函數都有自己獨立的局部名字空間
print(frame.f_locals) # {'name': '古明地覺'}
# 咦,不是說局部名字空間被初始化為 NULL 嗎?
# 那么在 Python 里面獲取的話,結果應該是個 None 才對啊
# 關于這一點,我們稍后會解釋
總之對于函數而言,在創建棧幀時,它的 f_locals 被初始化為 NULL。那么問題來了,局部變量到底存儲在什么地方呢?當然,由于變量只是一個名字(符號),而局部變量的名字都存儲在符號表中,所以更嚴謹的說法是,局部變量的值存儲在什么地方?
在介紹虛擬機執行字節碼的時候我們說過,當函數被調用時,虛擬機會為其創建一個棧幀。棧幀是虛擬機的執行環境,包含了執行時所依賴的上下文,而棧幀內部有一個字段叫 f_localsplus,它是一個數組。
圖片
這個數組雖然是一段連續內存,但在邏輯上被分成了 4 份,其中局部變量便存儲在 f_localsplus 的第一份空間中。現在我們明白了,局部變量是靜態存儲在數組中的。
我們舉個例子。
def foo(a, b):
c = a + b
print(c)
它的字節碼如下:
注意里面的 LOAD_FAST 和 STORE_FAST,這兩個指令對應的邏輯如下。
TARGET(LOAD_FAST) {
PyObject *value;
#line 192 "Python/bytecodes.c"
// 通過宏 GETLOCAL 獲取局部變量的值
value = GETLOCAL(oparg);
assert(value != NULL);
Py_INCREF(value);
#line 90 "Python/generated_cases.c.h"
// 將值壓入運行時棧,等價于 PUSH(value)
STACK_GROW(1);
stack_pointer[-1] = value;
DISPATCH();
}
TARGET(STORE_FAST) {
// 獲取棧頂元素
PyObject *value = stack_pointer[-1];
#line 209 "Python/bytecodes.c"
// 通過宏 SETLOCAL 創建局部變量
SETLOCAL(oparg, value);
#line 124 "Python/generated_cases.c.h"
// 將 stack_pointer 向棧底移動一個位置,即彈出棧頂元素
// 如果和第一行組合起來的話,等價于 TOP()
STACK_SHRINK(1);
DISPATCH();
}
所以 LOAD_FAST 和 STORE_FAST 分別負責加載和創建局部變量,而核心就是里面的兩個宏:GETLOCAL、SETLOCAL。
// Python/ceval_macros.h
#define GETLOCAL(i) (frame->localsplus[i])
#define SETLOCAL(i, value) do { PyObject *tmp = GETLOCAL(i); \
GETLOCAL(i) = value; \
Py_XDECREF(tmp); } while (0)
/* 這里額外再補充一個關于 C 語言的知識點
* 我們看到宏 SETLOCAL 展開之后的結果是 do {...} while (0)
* do while 循環會先執行 do 里面的循環體,然后再判斷條件是否滿足
* 因此從效果上來說,執行 do {...} while (0) 和直接執行 ... 是等價的
* 那么問題來了,既然效果等價,為啥還要再套一層 do while 呢
* 其實原因很簡單,如果宏在展開之后會生成多條語句,那么這些語句要成為一個整體
* 另外由于 C 程序的語句要以分號結尾,所以在調用宏時,我們也會習慣性地在結尾加上分號
* 因此我們希望有這樣一種結構,能同時滿足以下要求:
* 1)可以將多條語句包裹起來,作為一個整體;
* 2)程序的語義不能發生改變;
* 3)在語法上,要以分號結尾;
* 顯然 do while 完美滿足以上三個要求,只需將 while 里的條件設置為 0 即可
* 并且當編譯器看到 while (0) 時,也會進行優化,去掉不必要的循環控制結構
* 因此以后看到 do {...} while (0) 時,不要覺得奇怪,這是宏的一個常用技巧
*/
我們看到操作局部變量,就是在基于索引操作數組 f_localsplus,顯然這個過程比操作字典要快。盡管字典是經過高度優化的,但顯然再怎么優化,也不可能快過數組的靜態操作。
所以此時我們對局部變量的藏身之處已經了然于心,它們就存放在棧幀的 f_localsplus 字段中,而之所以沒有使用 local 名字空間的原因也很簡單。因為函數內部的局部變量在編譯時就已經確定了,個數是不會變的,因此編譯時也能確定局部變量占用的內存大小,以及訪問局部變量的字節碼指令應該如何訪問內存。
def foo(a, b):
c = a + b
print(c)
print(
foo.__code__.co_varnames
) # ('a', 'b', 'c')
比如變量 c 位于符號表中索引為 2 的位置,這在編譯時就已確定。
- 當創建變量 c 時,只需修改數組 f_localsplus 中索引為 2 的元素即可。
- 當訪問變量 c 時,只需獲取數組 f_localsplus 中索引為 2 的元素即可。
這個過程是基于數組索引實現的靜態查找,所以操作局部變量和操作全局變量有著異曲同工之妙。操作全局變量本質上是基于 key 操作字典的 value,其中 key 是變量的名稱,value 是變量的值;而操作局部變量本質上是基于索引操作數組 f_localsplus 的元素,這個索引就是變量名在符號表中的索引,對應的數組元素就是變量的值。
所以我們說 Python 的變量其實就是個名字,或者說符號,到這里是不是更加深刻地感受到了呢?
但對于局部變量來說,如果想實現靜態查找,顯然要滿足一個前提:變量名在符號表中的索引和與之綁定的值在 f_localsplus 中的索引必須是一致的。毫無疑問,兩者肯定是一致的,并且索引是多少在編譯階段便已經確定,會作為指令參數保存在字節碼指令序列中。
好,到此可以得出結論,雖然虛擬機為函數實現了 local 名字空間(初始為 NULL),但在操作局部變量時卻沒有使用它,原因就是為了更高的效率。當然還有所謂的 LEGB,都說變量查找會遵循這個規則,但我們心里清楚,局部變量其實是靜態訪問的,不過完全可以按照 LEGB 的方式來理解。
解密 local 名字空間
先來看一下全局名字空間:
x = 1
def foo():
globals()["x"] = 2
foo()
print(x) # 2
global 空間全局唯一,在 Python 層面上就是一個字典,在任何地方操作該字典,都相當于操作全局變量,即使是在函數內部。
因此在執行完 foo() 之后,全局變量 x 就被修改了。但 local 名字空間也是如此嗎?我們嘗試一下。
def foo():
x = 1
locals()["x"] = 2
print(x)
foo() # 1
我們按照相同的套路,卻并沒有成功,這是為什么?原因就是上面解釋的那樣,函數內部有哪些局部變量在編譯時就已經確定了,查詢的時候是從數組 f_localsplus 中靜態查找的,而不是從 local 名字空間中查找。
然后我們打印一下 local 名字空間,看看里面都有哪些內容。
def foo():
name = "satori"
print(locals())
age = 17
print(locals())
gender = "female"
print(locals())
foo()
"""
{'name': 'satori'}
{'name': 'satori', 'age': 17}
{'name': 'satori', 'age': 17, 'gender': 'female'}
"""
我們看到打印 locals() 居然也會顯示內部的局部變量,相信聰明如你已經猜到 locals() 是怎么回事了。因為局部變量不是從局部名字空間里面查找的,所以它初始為空,但當我們執行 locals() 的時候,會動態構建一個字典出來。
符號表里面存儲了局部變量的符號(或者說名字),f_localsplus 里面存儲了局部變量的值,當執行 locals() 的時候,會基于符號表和 f_localsplus 創建一個字典出來。
def foo():
name = "satori"
age = 17
gender = "female"
print(locals())
# 符號表:保存了函數中創建的局部變量的名字
print(foo.__code__.co_varnames)
"""
('name', 'age', 'gender')
"""
# 調用函數時會創建棧幀,局部變量的值都保存在 f_localsplus 里面
# 并且符號表中變量名的順序和 f_localsplus 中變量值的順序是一致的
f_localsplus = ["satori", 17, "female"]
# 這里就用一個列表來模擬了
我們來看一下變量的創建。
- 由于符號 name 位于符號表中索引為 0 的位置,那么執行 name = "satori" 時,就會將 "satori" 放在 f_localsplus 中索引為 0 的位置。
- 由于符號 age 位于符號表中索引為 1 的位置,那么執行 age = 17 時,就會將 17 放在 f_localsplus 中索引為 1 的位置。
- 由于符號 gender 位于符號表中索引為 2 的位置,那么執行 gender = "female" 時,就會將 "female" 放在 f_localsplus 中索引為 2 的位置。
后續在訪問變量的時候,比如訪問變量 age,由于它位于符號表中索引為 1 的位置,那么就會通過 f_localsplus[1] 獲取它的值,這些符號對應的索引都是在編譯階段確定的。所以在運行時才能實現靜態查找,指令 LOAD_FAST 和 STORE_FAST 都是基于索引來靜態操作底層數組。
我們用一張圖來描述這個過程:
符號表負責存儲局部變量的名字,f_localsplus 負責存儲局部變量的值(里面的元素初始為 NULL),而在給局部變量賦值的時候,本質上就是將值寫在了 f_localsplus 中。并且變量名在符號表中的索引,和變量值在 f_localsplus 中的索引是一致的,因此操作局部變量本質上就是在操作 f_localsplus 數組。
至于 locals() 或者說局部名字空間,它是基于符號表和 f_localsplus 動態創建的。為了方便我們獲取已存在的局部變量,執行 locals() 會臨時創建一個字典。
所以我們通過 locals() 獲取局部名字空間之后,訪問里面的局部變量是可以的,只不過此時將靜態訪問變成了動態訪問。
def foo():
name = "satori"
# 會從 f_localsplus 中靜態查找
print(name)
# 先基于已有的變量和值創建一個字典
# 然后通過字典實現變量的動態查找
print(locals()["name"])
foo()
"""
satori
satori
"""
兩種方式都是可以的,但基于 locals() 來訪問,在效率上明顯會低一些。
另外基于 locals() 訪問一個變量是可以的,但無法創建一個變量。
def foo():
name = "satori"
locals()["age"] = 17
try:
print(age)
except NameError as e:
print(e)
foo()
"""
name 'age' is not defined
"""
局部變量是靜態存儲在數組里的,locals() 只是做了一個拷貝而已。往局部名字空間里面添加一個鍵值對,不等于創建一個局部變量,因為局部變量不是從它這里查找的,因此代碼中打印 age 報錯了。但如果外部還有一個全局變量 age 的話,那么會打印全局變量 age。
然后再補充一點,我們說全局名字空間在任何地方都是唯一的,而對于函數而言,它的局部名字空間在整個函數內部也是唯一的。不管調用 locals 多少次,拿到的都是同一個字典。
def foo():
name = "satori"
# 執行 locals() 的時候,內部只有一個鍵值對
d = locals()
print(d) # {'name': 'satori'}
# 再次獲取,此時有兩個鍵值對
print(locals()) # {'name': 'satori', 'd': {...}}
# 但兩者的 id 相同,因為一個函數只有一個局部名字空間
# 不管調用多少次 locals(),拿到的都是同一個字典
print(id(d) == id(locals())) # True
foo()
所以 locals() 和 globals() 指向的名字空間都是唯一的,只不過 locals() 是在某個函數內部唯一,而 globals() 在所有地方都唯一。
因此局部名字空間初始為 NULL,但在第一次執行 locals() 時,會以符號表中的符號作為 key,f_localsplus 中的值作為 value,創建一個字典作為函數的局部名字空間。而后續再執行 locals() 的時候,由于名字空間已存在,就不會再次創建了,直接基于當前的局部變量對字典進行更新即可。
def foo():
# 創建一個字典,由于當前還沒有定義局部變量,因此是空字典
print(locals())
"""
{}
"""
# 往局部名字空間添加一個鍵值對
locals()["a"] = "b"
print(locals())
"""
{'a': 'b'}
"""
# 定義一個局部變量
name = "satori"
# 由于局部名字空間已存在,因此不會再次創建
# 直接將局部變量的名字作為 key、值作為 value,拷貝到字典中
print(locals())
"""
{'a': 'b', 'name': 'satori'}
"""
foo()
注意:雖然局部名字空間里面存在 "a" 這個 key,但 a 這個局部變量是不存在的。
local 名字空間的創建過程
目前我們已經知道 local 名字空間是怎么創建的了,也熟悉了它的特性,下面通過源碼來看一下它的構建過程。
// Python/bltinmodule.c
static PyObject *
builtin_locals_impl(PyObject *module)
{
// Python 內置函數的源碼實現位于 bltinmodule.c 中
// 這里又調用了 _PyEval_GetFrameLocals
return _PyEval_GetFrameLocals();
}
// Python/ceval.c
PyObject *
_PyEval_GetFrameLocals(void)
{
PyThreadState *tstate = _PyThreadState_GET();
_PyInterpreterFrame *current_frame = _PyThreadState_GetFrame(tstate);
if (current_frame == NULL) {
_PyErr_SetString(tstate, PyExc_SystemError, "frame does not exist");
return NULL;
}
// 調用了 _PyFrame_GetLocals
return _PyFrame_GetLocals(current_frame, 1);
}
所以核心邏輯位于 _PyFrame_GetLocals 函數中,來看一下它的邏輯。
// Object/frameobject.c
PyObject *
_PyFrame_GetLocals(_PyInterpreterFrame *frame, int include_hidden)
{
// 獲取局部名字空間
PyObject *locals = frame->f_locals;
// 如果為 NULL,那么創建一個新字典,作為名字空間
// 所以局部名字空間只會創建一次,后續不會再創建
if (locals == NULL) {
locals = frame->f_locals = PyDict_New();
if (locals == NULL) {
return NULL;
}
}
PyObject *hidden = NULL;
// 在 Include/internal/pycore_code.h 里面有 4 個宏
/* #define CO_FAST_HIDDEN 0x10
* #define CO_FAST_LOCAL 0x20
* #define CO_FAST_CELL 0x40
* #define CO_FAST_FREE 0x80
*/
// 它們分別對應隱藏變量、局部變量、cell 變量、free 變量
// 所謂隱藏變量,指的就是解析式里的臨時變量,比如列表解析式
// 解析式具有獨立的作用域,里面的臨時變量不會污染外部的作用域
// 所以一般我們也不會關注這些隱藏變量,locals() 也不會返回它
// 但如果你真的關注,那么可以將 include_hidden 指定為真
// 那么調用 locals() 時,這些隱藏變量也會一塊兒返回
if (include_hidden) {
// 單獨創建一個字典,負責保存隱藏變量
hidden = PyDict_New();
if (hidden == NULL) {
return NULL;
}
}
// 初始化 free 變量,這個和閉包有關
// 關于閉包,等剖析完函數之后會說,這里暫時先不關注
frame_init_get_vars(frame);
PyCodeObject *co = frame->f_code;
// co_nlocalsplus 等于局部變量、cell 變量、free 變量的個數之和
// 這些變量都要拷貝到 local 名字空間中
for (int i = 0; i < co->co_nlocalsplus; i++) {
PyObject *value;
// 獲取 f_localsplus[i],在函數內部會對 value 進行修改
if (!frame_get_var(frame, co, i, &value)) {
continue;
}
// f_localsplus[i] 對應局部名字空間的 value
// 那么 co_localsplusnames[i] 顯然對應局部名字空間的 key
// 估計有人已經忘記 co_localsplusnames 字段的含義了,我們再解釋一下
/* co_localsplusnames:包含所有局部變量、cell 變量、free 變量的名稱
* co_nlocalsplus:co_localsplusnames 的長度,或者說這些變量的個數之和
* co_varnames:包含所有局部變量的名稱,co_nlocals:局部變量的個數
* co_cellvars:包含所有 cell 變量的名稱,co_ncellvars:cell 變量的個數
* co_freevars:包含所有 free 變量的名稱,co_nfreevars:free 變量的個數
* 因此不難得出它們之間的關系:
* co_localsplusnames = co_varnames + co_cellvars + co_freevars
* co_nlocalsplus = co_nlocals + co_ncellvars + co_nfreevars
*/
// 所以 co_localsplusnames 也是符號表,并且是 co_varnames 的超集
PyObject *name = PyTuple_GET_ITEM(co->co_localsplusnames, i);
// 到此局部名字空間的 key 和 value 便有了,但還要做一個判斷
_PyLocals_Kind kind = _PyLocals_GetKind(co->co_localspluskinds, i);
// 如果變量的類型是隱藏變量,那么添加到 hidden 中
// 所以 co_localsplusnames 其實還包含了隱藏變量的名稱
// 但我們基本不會遇到這種情況,因此關于隱藏變量直接忽略掉即可
if (kind & CO_FAST_HIDDEN) {
if (include_hidden && value != NULL) {
if (PyObject_SetItem(hidden, name, value) != 0) {
goto error;
}
}
continue;
}
// 如果不是隱藏變量,那么拷貝到 locals 中,但這里有一個判斷很重要
// 當 value 為 NULL 時,如果 key 已存在,那么會將它刪掉
// 關于這里的玄機,稍后會解釋
if (value == NULL) {
if (PyObject_DelItem(locals, name) != 0) {
if (PyErr_ExceptionMatches(PyExc_KeyError)) {
PyErr_Clear();
}
else {
goto error;
}
}
}
// 到這里說明 value 指向了一塊合法的內存
// 也就是變量名和變量值已經完成了綁定,那么將它們添加到 locals 中
else {
if (PyObject_SetItem(locals, name, value) != 0) {
goto error;
}
}
// 繼續遍歷下一個符號
}
// 隱藏變量保存在 hidden 中,它不會污染 f_locals
if (include_hidden && PyDict_Size(hidden)) {
// 創建一個新字典
PyObject *innerlocals = PyDict_New();
if (innerlocals == NULL) {
goto error;
}
// 合并 locals
if (PyDict_Merge(innerlocals, locals, 1) != 0) {
Py_DECREF(innerlocals);
goto error;
}
// 合并 hidden
if (PyDict_Merge(innerlocals, hidden, 1) != 0) {
Py_DECREF(innerlocals);
goto error;
}
// 重新賦值給 locals,所以返回的結果會包含 hidden 里的鍵值對
// 但 f_locals 里面是沒有隱藏變量的
locals = innerlocals;
}
else {
Py_INCREF(locals);
}
Py_CLEAR(hidden);
// 返回 locals
return locals;
error:
Py_XDECREF(hidden);
return NULL;
}
所以邏輯非常簡單,如果不考慮隱藏變量(也不需要考慮),那么整個過程就是我們剛才說的:遍歷符號表和 f_localsplus,將變量名和變量值組成鍵值對拷貝到字典中。
但里面有一處細節非常關鍵。
當變量值為 NULL 時,說明在獲取名字空間時,該變量還沒有被賦值。要是此時變量已經在局部名字空間中,那么會將它從名字空間中刪掉。這一處非常關鍵,在介紹 exec 的時候你就會明白。
local 名字空間與 exec 函數
我們再來搭配 exec 關鍵字,結果會更加明顯。首先 exec 函數可以將一段字符串當成代碼來執行,并將執行結果體現在當前的名字空間中。
def foo():
print(locals()) # {}
exec("x = 1")
print(locals()) # {'x': 1}
try:
print(x)
except NameError as e:
print(e) # name 'x' is not defined
foo()
盡管 locals() 變了,但是依舊訪問不到 x,因為虛擬機并不知道 exec("x = 1") 是創建一個局部變量,它只知道這是一個函數調用。
事實上 exec 會作為一個獨立的編譯單元來執行,并且有自己的作用域。
所以 exec("x = 1") 執行完之后,效果就是改變了局部名字空間,里面多了一個 "x": 1 鍵值對。但關鍵的是,局部變量 x 不是從局部名字空間中查找的,exec 終究還是錯付了人。
由于函數 foo 對應的 PyCodeObject 對象的符號表中并沒有 x 這個符號,所以報錯了。
補充:exec 默認影響的是 local 名字空間,如果在執行時發現 local 名字空間為 NULL,那么會自動創建一個。所以調用 exec 也可以創建名字空間(當它為 NULL 時)。
exec("x = 1")
print(x) # 1
如果放在模塊里面是可以的,因為模塊的 local 名字空間和 global 名字空間指向同一個字典,所以 global 名字空間會多一個 key 為 "x" 的鍵值對。而全局變量是從 global 名字空間中查找的,所以這里沒有問題。
def foo():
# 此時 exec 影響的是全局名字空間
exec("x = 123", globals())
# 這里不會報錯, 但此時的 x 不是局部變量, 而是全局變量
print(x)
foo()
print(x)
"""
123
123
"""
可以給 exec 指定要影響的名字空間,代碼中 exec 影響的是全局名字空間,打印的 x 也是全局變量。
以上幾個例子都比較簡單,接下來我們開始上強度了。
def foo():
exec("x = 1")
print(locals()["x"])
foo()
"""
1
"""
def bar():
exec("x = 1")
print(locals()["x"])
x = 123
bar()
"""
Traceback (most recent call last):
File .....
bar()
File .....
print(locals()["x"])
KeyError: 'x'
"""
這是什么情況?函數 bar 只是多了一行賦值語句,為啥就報錯了呢?其實背后的原因我們之前分析過。
1)函數的局部變量在編譯的時候已經確定,并存儲在對應的 PyCodeObject 對象的符號表中,這是由語法規則所決定的;
2)函數內的局部變量在其整個作用域范圍內都是可見的;
對于 foo 函數來說,exec 執行完之后相當于往 local 名字空間中添加一個鍵值對,這沒有問題。對于 bar 函數而言也是如此,在執行完 exec("x = 1") 之后,local 名字空間也會存在 "x": 1 這個鍵值對,但問題是下面執行 locals() 的時候又把字典更新了。
因為局部變量可以在函數的任意位置創建,或者修改,所以每一次執行 locals() 的時候,都會遍歷符號表和 f_localsplus,然后組成鍵值對拷貝到名字空間中。
在 bar 函數里面有一行 x = 123,所以知道函數里面存在局部變量 x,符號表里面也會有 "x" 這個符號,這是在編譯時就確定的。但我們是在 x = 123 之前調用的 locals,所以此時符號 x 在 f_localsplus 中對應的值還是一個 NULL,沒有指向一個合法的 PyObject。換句話說就是,知道里面存在局部變量 x,但此時尚未賦值。
然后在更新名字空間的時候,如果發現值是個 NULL,那么就把名字空間中該變量對應的鍵值對給刪掉。
圖片
所以 bar 函數執行 locals()["x"] 的時候,會先獲取名字空間,原本里面是有 "x": 1 這個鍵值對的。但因為賦值語句 x = 123 的存在,導致符號表里面存在 "x" 這個符號,可執行 locals() 的時候又尚未完成賦值,因此值為 NULL,于是又把這個鍵值對給刪掉了。所以執行 locals()["x"] 的時候,出現了 KeyError。
因為局部名字空間體現的是局部變量的值,而調用 locals 的時候,局部變量 x 還沒有被創建。所以 locals() 里面不應該存在 key 為 "x" 的鍵值對,于是會將它刪除。
我們將名字空間打印一下:
def foo():
# 創建局部名字空間,并寫入鍵值對 "x": 1
# 此時名字空間為 {"x": 1}
exec("x = 1")
# 獲取名字空間,會進行更新
# 但當前不存在局部變量,所以名字空間仍是 {"x": 1}
print(locals())
def bar():
# 創建局部名字空間,并寫入鍵值對 "x": 1
# 此時名字空間為 {"x": 1}
exec("x = 1")
# 獲取名字空間,會進行更新
# 由于里面存在局部變量 x,但尚未賦值
# 于是將字典中 key 為 "x" 的鍵值對給刪掉
# 所以名字空間變成了 {}
print(locals())
x = 123
foo() # {'x': 1}
bar() # {}
上面代碼中,局部變量的創建發生在 exec 之后,如果發生在 exec 之前也是類似的結果。
def foo():
exec("x = 2")
print(locals())
foo() # {'x': 2}
def bar():
x = 1
exec("x = 2")
print(locals())
bar() # {'x': 1}
在 exec("x = 2") 執行之后,名字空間也變成了 {"x": 2}。但每次調用 locals,都會對字典進行更新,所以在 bar 函數里面獲取名字空間的時候,又把 "x" 對應的 value 給更新回來了。
當然這是在變量沖突的情況下,會保存真實存在的局部變量的值。如果不沖突,比如 bar 函數里面是 exec("y = 2"),那么 locals() 里面就會存在兩個鍵值對,但只有 x 才是真正的局部變量,而 y 則不是。
將 exec("x = 2") 換成 locals()["x"] = 2 也是一樣的效果,它們都是往局部名字空間中添加一個鍵值對,但不會創建一個局部變量。
薛定諤的貓
# 例 0
def foo():
exec('y = 1 + 1')
z = locals()['y']
print(z)
foo()
# 輸出:2
# 例 1
def foo():
exec('y = 1 + 1')
y = locals()['y']
print(y)
foo()
# 報錯:KeyError: 'y'
以上是貓哥文章中舉的示例,首先例 0 很簡單,因為 exec 影響了所在的局部名字空間,里面存在 "y": 2 這個鍵值對,所以 locals()["y"] 會返回 2。
但例 1 則不同,因為 Python 在語法解析的時候發現了 y = ... 這樣的賦值語句,那么它在編譯的時候就知道函數里面存在 y 這個局部變量,并寫入符號表中。既然符號表中存在,那么調用 locals 的時候就會寫入到名字空間中。但問題是變量 y 的值是多少呢?由于對 y 賦值是發生在調用 locals 之后,所以在調用 locals 的時候,y 的值還是一個 NULL,也就是變量還沒有賦值。所以會將名字空間中的 "y": 2 這個鍵值對給刪掉,于是報出 KeyError 錯誤。
再來看看貓哥文章的例 2:
# 例 2
def foo():
y = 1 + 1
y = locals()['y']
print(y)
foo()
# 2
locals() 是對真實存在的局部變量的一個拷貝,在調用 locals 之前 y 就已經創建好了。符號表里面有 "y",數組 f_localsplus 里面有數值 2,所以調用 locals() 的時候,會得到 {"y": 2},因此函數執行正常。
貓哥文章的例 3:
# 例3
def foo():
exec('y = 1 + 1')
boc = locals()
y = boc['y']
print(y)
foo()
# KeyError: 'y'
這個例3 和例 1 是一樣的,只不過用變量 boc 將局部名字空間保存起來了。執行 exec 的時候,會創建局部名字空間,寫入鍵值對 "y": 2。
但調用 locals 的時候,發現函數內部存在局部變量 y 并且還尚未賦值,于是又會將 "y": 2 這個鍵值對給刪掉,因此 boc 變成了一個空字典。于是執行 y = boc["y"] 的時候會出現 KeyError。
貓哥文章的例 4:
# 例4
def foo():
boc = locals()
exec('y = 1 + 1')
y = boc['y']
print(y)
foo()
# 2
顯然在調用 locals 的時候,會返回一個空字典,因為此時的局部變量都還沒有賦值。但需要注意的是:boc 已經指向了局部名字空間(字典),而局部名字空間在一個函數里面也是唯一的。
然后執行 exec("y = 1 + 1"),會往局部名字空間中寫入一個鍵值對,而變量 boc 指向的字典也會發生改變,因為是同一個字典,所以程序正常執行。
貓哥文章的例 5:
# 例5
def foo():
boc = locals()
exec('y = 1 + 1')
print(locals())
y = boc['y']
print(y)
foo()
# {'boc': {...}}
# KeyError: 'y'
首先在執行 boc = locals() 之后,boc 會指向一個空字典,然后 exec 函數執行之后會往字典里面寫入一個鍵值對 "y": 2。如果在 exec 執行之后,直接執行 y = boc["y"],那么代碼是沒有問題的,但問題是在中間插入了一個 print(locals())。
我們說過,當調用 locals 的時候,會對名字空間進行更新,然后返回更新之后的名字空間。由于函數內部存在 y = ... 這樣的賦值語句,所以符號表中就存在 "y" 這個符號,于是會進行更新。但更新的時候,發現 y 還沒有被賦值,于是又將字典中的鍵值對 "y": 2 給刪掉了。
由于局部名字空間只有一份,所以 boc 指向的字典也會發生改變,換句話說在 print(locals()) 之后,boc 就指向了一個空字典,因此出現 KeyError。
小結
以上我們就探討了局部變量的存儲原理以及它和 local 名字空間的關系。
- 局部變量在編譯時就已經確定,所以會采用數組靜態存儲,并且在整個作用域內都是可見的。
- f_localsplus 的內存被分成了四份,局部變量的值便存儲在第一份空間中。
- 局部名字空間是對真實存在的局部變量的拷貝,調用 locals() 時,會遍歷得到每一個符號和與之綁定的值,然后拷貝到局部名字空間。
- 如果遍歷時發現變量值為 NULL,這就說明獲取名字空間時,該變量尚未賦值,那么要將它從名字空間中刪掉。