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