楔子
上一篇我們看了函數是如何調用的,這一次我們看一下函數中局部變量的訪問、以及閉包相關的知識。
函數中局部變量的訪問
我們說過函數的參數和函數內部定義的變量都屬於局部變量,所以它也一樣是通過靜態的方式進行訪問。
x = 123
def foo():
global x
a = 1
b = 2
# a和b是局部變量,x是全局變量,因此是2
print(foo.__code__.co_nlocals) # 2
def bar(a, b):
pass
print(bar.__code__.co_nlocals) # 2
def bar2(a, b):
a = 1
b = 2
c = 3
print(bar2.__code__.co_nlocals) # 3
因此我們看到,無論是參數還是內部新創建的變量,本質上都是局部變量。並且我們發現如果函數內部定義的變量如果和函數參數一致,那么參數就沒用了,很好理解,因為本質上就相當於重新賦值罷了,此時外面無論給bar2函數的a、b參數傳遞什么,最終都會變成1和2。所以其實局部變量的實現機制和函數參數的實現機制是一致的。
按照我們的理解,當訪問一個全局變量的時候,會去訪問 global 名字空間,而這也確實如此。但是當訪問函數內的局部變量的時候,是不是訪問其內部的 local 名字空間呢? 之前我們說過 Python 變量的訪問是有規則的,按照本地
、閉包
、全局
、內置
的順序去查找,所以首當其沖當然去 local 名字空間去查找啊。但不幸的是,在調用函數期間,Python 通過 _PyFrame_New_NoTrack
創建 PyFrameObject 對象時,這個至關重要的 local 名字空間並沒有被創建。
//frameobject.c
PyFrameObject* _Py_HOT_FUNCTION
_PyFrame_New_NoTrack(PyThreadState *tstate, PyCodeObject *code,
PyObject *globals, PyObject *locals)
{
...
...
f->f_locals = NULL;
f->f_trace = NULL;
...
...
}
在前面對函數調用時的 global 名字空間的解析中,我們看到,當 Python 虛擬機執行 xxx.py
的時候,f_locals 和 f_globals 指向的是同一個 PyDictObject 對象,然而現在在函數里面 f_locals 則變成了NULL,那么的話,那些重要的符號到底存儲在什么地方呢?(顯然我們知道是符號表co_varnames中, 但你們就裝作不知道配合我一下好吧(#^.^#))
。別急,我們先來看看使用局部變量的函數。
def foo(a, b):
c = a + b
print(c)
foo(1, 2)
看一下它的字節碼:
1 0 LOAD_CONST 0 (<code object foo at 0x0000013E31511450, file "local", line 1>)
2 LOAD_CONST 1 ('foo')
4 MAKE_FUNCTION 0
6 STORE_NAME 0 (foo)
6 8 LOAD_NAME 0 (foo)
10 LOAD_CONST 2 (1)
12 LOAD_CONST 3 (2)
14 CALL_FUNCTION 2
16 POP_TOP
18 LOAD_CONST 4 (None)
20 RETURN_VALUE
Disassembly of <code object foo at 0x0000013E31511450, file "local", line 1>:
2 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_ADD
6 STORE_FAST 2 (c)
3 8 LOAD_GLOBAL 0 (print)
10 LOAD_FAST 2 (c)
12 CALL_FUNCTION 1
14 POP_TOP
16 LOAD_CONST 0 (None)
18 RETURN_VALUE
我們說 f_localsplus 這段內存雖然是連續的,但它是給四個老鐵使用的,分別是:局部變量、cell對象、free對象、運行時棧,而我們看到字節碼偏移量為 6 和 10 的兩條指令分別是:STORE_FAST 和 LOAD_FAST,所以它和我們之前分析參數的時候是一樣的,都是存儲在 f_localsplus 中運行時棧前面的那段內存中。
此時我們對局部變量 c 的藏身之處已經了然於心。但是為什么在函數的實現中沒有使用 local 名字空間呢?其實函數內部的局部變量有多少,在編譯的時候就已經確定了,個數是不會變的。因此編譯時就能確定局部變量使用的內存空間位置,也能確定訪問局部變量的字節碼指令應該如何訪問內存。有了這些信息,Python 就能使用靜態的方法來實現局部變量的查找,而不需要借助於動態查找 PyDictObject 對象的技術,盡管 PyDictObject 是被高度優化的,但肯定沒有靜態的方法快啊,而且 Python 里面函數是對象,也是一等公民,並且函數使用的太普遍了。至於在后面的類的剖析中,由於類的特殊性,無論是類的實例對象、還是類對象本身,都是可以在運行時動態修改屬性的,那么我們知道顯然 Python 就不會再對類使用靜態屬性查找的方式了。
並且我們還可以從 Python 的層面來驗證這個結論:
x = 1
def foo():
globals()["x"] = 2
foo()
print(x) # 2
我們在函數內部訪問了 global 名字空間,而 global 空間顯然是全局唯一的,在 Python 層面上就是一個 dict 對象,那么我們修改 x,在外部再打印 x 肯定會變。但是,我要說但是了。
def foo():
x = 1
locals()["x"] = 2
print(x)
foo()
"""
1
"""
我們按照相同的套路,卻並沒有成功,這是為什么?原因就是我們剛才解釋的那樣,函數內部的局部變量在編譯時就已經確定好了,存儲在符號表 co_varnames 中,查詢的時候是靜態查找的,而不是從 locals() 中查找。locals() 不像 globals(),globals() 雖然和 locals() 都是一個 PyDictObject 對象,但是全局變量的訪問是從 globals() 這個字典里面訪問的,並且全局唯一,我們調用 globals() 就直接訪問到了存放全局變量的字典,一旦做了更改,肯定會影響外面的全局變量。但是locals() 則不會,因為局部變量壓根就不是從它這里訪問的,盡管它和 globals() 類似,在函數中也唯一,也會隨着當前的上下文動態改變。
def foo(a, b):
x = 1
print(locals())
print(id(locals()))
y = 2
print(locals())
print(id(locals()))
foo(1, 2)
"""
{'a': 1, 'b': 2, 'x': 1}
2459571657088
{'a': 1, 'b': 2, 'x': 1, 'y': 2}
2459571657088
"""
# 我們看到真的就是類似於全局名字空間一樣, 前后地址沒有變化, 但是鍵值對的個數在增加
# 因為 locals() 底層會執行 PyEval_GetLocals, 實際上拿到就是當前棧幀對象的 f_locals 屬性
# 所以這里可以看到一個比較奇特的現象
def bar(a, b):
d = locals()
print(d)
print(locals())
print(d["d"] is d["d"]["d"] is d["d"]["d"]["d"])
bar(1, 2)
"""
{'a': 1, 'b': 2}
{'a': 1, 'b': 2, 'd': {...}}
True
"""
# 可能有人好奇了, d 里面不是沒有 "d" 這個 key 嗎?
# 我們執行 d["d"] 之前再次調用了 locals, 由於此時局部空間多了一個鍵值對 "d": locals(), 所以 locals() 對應的字典被更新了
# 但我們說 locals() 在局部空間又是唯一的, 所以 d1 指向的空間也變了
# 關於字典這個現象, 其實可以類似於 globals 與 __builtins__ 之間的關系
x = 123
print(globals()["__builtins__"].globals()["__builtins__"].globals()["x"]) # 123
# 之所以能夠形成這個現象, 原因就在於字典里面的 key、value 存儲的都是 PyObject * 泛型指針
再看一個例子:
def foo():
locals()["x"] = 1
print(x)
foo()
此時會得到什么結果估計不用我說了,因為內部、外部、builtin都沒有變量 x。在編譯的時候,沒有找到類似於 x = 1
這樣的字眼。因此盡管在locals()里面,但是我們說局部變量的值不是從它這里獲取的,而是 f_localsplus 前面的那段內存里面,然后那段內存並沒有,而且符號表中就沒有 'x' 這個符號,所以報錯。
x = 123
def foo():
locals()["x"] = 1
print(x)
foo() # 123
原因不再廢話了,一句話:foo函數里面沒有 x 這個變量,所以打印的是全局變量,因此輸出123。
另外關於局部變量的查找,再來看看最后一個栗子,搭配 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,因為 Python 在將 foo 對應的 block 編譯成 PyCodeObject 對象時,並不知道這是創建了一個局部變量,它只知道這是一個函數調用。而 exec("x = 1") 相當於創建一個變量 x = 1,但它默認影響的是當前所在的作用域,所以 exec("x = 1") 的效果就是改變了局部名字空間,里面多了一個 "x": 1 鍵值對。但關鍵的是,局部變量 x 的訪問不是從局部名字空間中查找的,exec 終究還是錯付了人。由於函數 foo 對應的 PyCodeObject 對象的符號表中並沒有 x 這個符號,所以報錯了。
exec("x = 1")
print(x) # 1
# 這么做是可以的, 因為 exec 默認是影響當前作用域, 這里是全局作用域
# 而全局變量的查找是從字典中獲取的, 所以這里是可以獲取的
# 如果我們把上面的例子改一下
def foo():
# 此時 exec 影響的就是全局名字空間
exec("x = 123", globals())
# 這里不會報錯, 但是此時的 x 不是局部變量, 而是全局變量
print(x)
foo()
print(x)
"""
123
123
"""
但是問題又來了:
def foo():
exec("x = 1")
print(locals()["x"])
foo()
"""
1
"""
# 上面打印 1, 顯然這是沒有問題的, 因為 "x": 1 這個鍵值對已經在 local 空間中了
# 但是, 是的我又要說但是了
def bar():
exec("x = 1")
print(locals()["x"])
x = 123
bar()
"""
Traceback (most recent call last):
File .....
bar()
File .....
print(locals()["x"])
KeyError: 'x'
"""
這就比較尷尬了,為啥會出現這種效果?解決這個問題首先要明確兩點:
1. 函數內的局部變量在編譯的時候已經確定, 由語法規則所決定的, 並存儲在對應的 PyCodeObject 對象的符號表 (co_varnames) 中;
2. 函數內的局部變量在其整個作用域范圍內都是可見的;
舉一個常見的錯誤:
x = 1
def foo():
print(x)
def bar():
print(x)
x = 2
"""
調用 foo 沒有問題, 但調用 bar 的時候會報出如下錯誤
UnboundLocalError: local variable 'x' referenced before assignment
原因就在於我們之前說的兩個點, 函數內的局部變量在編譯的時候已經確定, 所以對於 bar 函數而言, 符號表中是存在 "x" 這個符號的
"""
print(foo.__code__.co_varnames) # ()
print(bar.__code__.co_varnames) # ('x',)
"""
而函數內的局部變量在整個作用域內都是可見的, 因此對於bar而言, 在 print(x) 的時候知道符號表中存在 "x" 這個符號
那么它也就認為局部作用域存在 x 這個局部變量, 因此就不會去找全局變量了, 而是去找局部變量
但是顯然 print(x) 是在 x = 2 之前發生的, 所以此時 print(x) 的時候就報錯了
UnboundLocalError: 局部變量 'x' 在賦值(x = 2)之前被引用(print(x))了
因為 print(x) 的時候, 常量池中還沒有對應的值與之綁定, 或者說 x 此時還是 C 中的 NULL(空指針), 並沒有指向一塊合法的內存
當 x = 2 之后, x 才會和 2 這個 PyLongObject 對象進行綁定, 只可以我們在綁定之前就使用 x 這個變量了, 顯然這是不合法的
"""
那么我們的那個問題就很好解釋了:
def foo():
exec("x = 1")
print(locals())
def bar():
exec("x = 1")
print(locals())
x = 123
foo()
"""
{'x': 1}
"""
bar()
"""
{}
"""
# 對於 foo 而言, 結果符合我們的預期, 但是對於 bar 而言, 只是多了一個賦值語句, 結果局部空間就變成空字典了
# 原因在於 'x' 已經在符號表當中了, exec("x = 1") 並沒有往局部空間中加入這個鍵值對
"""
有興趣可以查看解釋器源代碼: Python\bltinmodule.c 中的 builtin_exec_impl 函數, 看看 exec 底層到底是如何執行的
因為 exec 里面的字符串實際上是作為一個獨立的編譯單元去執行的, 里面的可以寫很多很多內容
要是再加上它是如何影響當前作用域的, 那么背后會牽扯非常多的內容, 從頭到尾分析下來需要的工作量不敢想象, 因此這里不深入展開了
"""
# 但是訪問 locals() 又是在 x = 123 之前發生的, 因此打印的是空字典, locals['x'] 自然就出現 KeyError 了
# 如果將 x = 123, 改成 y = 123 的話, 顯然 foo 和 bar 里面的打印結果是一樣的
嵌套函數、閉包與decorator
我們之前一直反復提到了四個字,名字空間。一段代碼執行的結果不光取決於代碼中的符號,更多取決於代碼中符號的語義,而這個運行時的語義正是由名字空間決定的。名字空間是在運行時由Python虛擬機動態維護的,但是有時我們希望將命名空間靜態化。換句話說,我們希望有的代碼不受命名空間變換帶來的影響,始終保持一致的功能該怎么辦呢?
比如下面的例子:
def index(name, password, nickname):
if not (name == "satori" and password == "123"):
return "拜拜"
else:
return f"歡迎:{nickname}"
print(index("satori", "123", "夏色祭")) # 歡迎:夏色祭
print(index("satori", "123", "白上吹雪")) # 歡迎:白上吹雪
我們注意到每次都需要輸入username和password,於是我們可以只設置一次基准值,通過使用嵌套函數來實現:
def wrap(name, password):
def index(nickname):
if not (name == "satori" and password == "123"):
return "拜拜"
else:
return f"歡迎:{nickname}"
return index
index = wrap("satori", "123")
print(index("夏色祭")) # 歡迎:夏色祭
print(index("白上吹雪")) # 歡迎:白上吹雪
盡管我們調用index的時候,local名字空間(對應那片內存)
里面沒有name和password,但是warp里面有。也就是說,index函數作為wrap函數的返回值被傳遞的時候,有一個名字空間(wrap的local名字空間)
就已經和index緊緊地綁定在一起了,在執行內層函數index的時候,在自己的local空間找不到,就會從和自己綁定的local空間里面去找,這就是一種名字空間靜態化的方法。這個名字空間和內層函數捆綁之后的結果我們就稱之為閉包(closure)
閉包:外部作用域 + 內層函數。
在前面我們也知道了,PyFunctionObject是Python虛擬機專門為字節碼指令准備的大包袱,global名字空間,默認參數都能在PyFunctionObject中與字節碼指令捆綁在一起,同樣的,PyFunctionObject也是Python中閉包的具體體現。
實現閉包的基石
閉包的創建通常是利用嵌套的函數來完成的,在PyCodeObject中,與嵌套函數相關的屬性是co_cellvars和co_freevars,兩者的具體含義如下:
co_cellvars:通常是一個tuple,保存了嵌套的作用域中使用的變量名的集合;
co_freevars:通常是一個tuple,保存了使用了的外層作用域中的變量名集合;
光看概念的話比較抽象,實際演示一下:
def foo():
name = "mashiro"
age = 16
gender = "female"
def bar():
nonlocal name
nonlocal age
print(gender)
return bar
print(foo.__code__.co_cellvars) # ('age', 'gender', 'name')
print(foo().__code__.co_freevars) # ('age', 'gender', 'name')
print(foo.__code__.co_freevars) # ()
print(foo().__code__.co_cellvars) # ()
我們發現無論是外層函數還是內層函數都有co_cellvars和co_freevars,但是無論是co_cellvars還是co_freevars,得到結果是一樣的,都是內層函數使用nonlocal聲明的變量、以及內層函數使用的外層函數的變量。只不過外層函數需要使用co_cellvars獲取,內層函數需要使用co_freevars獲取。如果使用外層函數獲取co_freevars的話,那么得到的結果顯然就是個空元組的,除非foo也作為某個函數的內層函數,並且內部有nonlocal聲明、或者使用外層函數的某個變量,同理內層也是一樣的道理。
在PyFrameObject對象中,也有一個屬性和閉包的實現相關,這個屬性就是f_localsplus,這樣一說,是不是有些隱隱約約察覺到了呢?其實在_PyFrame_New_NoTrack
就有一行代碼泄漏了天機。
//frameobject.c
PyFrameObject* _Py_HOT_FUNCTION
_PyFrame_New_NoTrack(PyThreadState *tstate, PyCodeObject *code,
PyObject *globals, PyObject *locals)
{
...
...
Py_ssize_t extras, ncells, nfrees;
ncells = PyTuple_GET_SIZE(code->co_cellvars);
nfrees = PyTuple_GET_SIZE(code->co_freevars);
//玄機在這里,extras正是f_localsplus指向的那片內存的大小,這里已經清晰的說明了
//這片內存是屬於四個老鐵的:運行時棧,局部變量,cell對象(對應co_cellvars),free對象(對應co_freevars),但是各自的順序不是按照這個順序來的
extras = code->co_stacksize + code->co_nlocals + ncells + nfrees;
...
...
雖然之前我們就見過f_localsplus的結構,但是到現在為止,其面紗才算是真正被揭開。
閉包的實現
在介紹了實現閉包的基石之后,我們可以開始追蹤閉包的具體實現過程了,當然還是要先看一下閉包對應的字節碼,老規矩嘛。
s = f"""
def get_func():
value = "inner"
def func():
print(value)
return func
show_value = get_func()
show_value()
"""
if __name__ == '__main__':
import dis
dis.dis(compile(s, "call_function", "exec"))
首先這個py文件執行之后,肯定會打印出"inner"這個字符串,下面讓我們來看看它的字節碼:
2 0 LOAD_CONST 0 (<code object get_func at 0x000001AAB6F4AB30, file "call_function", line 2>)
2 LOAD_CONST 1 ('get_func')
4 MAKE_FUNCTION 0
6 STORE_NAME 0 (get_func)
11 8 LOAD_NAME 0 (get_func)
10 CALL_FUNCTION 0
12 STORE_NAME 1 (show_value)
12 14 LOAD_NAME 1 (show_value)
16 CALL_FUNCTION 0
18 POP_TOP
20 LOAD_CONST 2 (None)
22 RETURN_VALUE
Disassembly of <code object get_func at 0x000001AAB6F4AB30, file "call_function", line 2>:
3 0 LOAD_CONST 1 ('inner')
2 STORE_DEREF 0 (value)
5 4 LOAD_CLOSURE 0 (value)
6 BUILD_TUPLE 1
8 LOAD_CONST 2 (<code object func at 0x000001AAB6F51450, file "call_function", line 5>)
10 LOAD_CONST 3 ('get_func.<locals>.func')
12 MAKE_FUNCTION 8 (closure)
14 STORE_FAST 0 (func)
8 16 LOAD_FAST 0 (func)
18 RETURN_VALUE
Disassembly of <code object func at 0x000001AAB6F51450, file "call_function", line 5>:
6 0 LOAD_GLOBAL 0 (print)
2 LOAD_DEREF 0 (value)
4 CALL_FUNCTION 1
6 POP_TOP
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
相信里面大部分的指令你都認識,我們直接介紹構建閉包對應的指令、以及調用內層函數對應的指令,先來看看前者:
0 LOAD_CONST 1 ('inner'): 把字符串'inner'這個常量load進來;
2 STORE_DEREF 0 (value): 這個STORE_DEREF是什么鬼?從功能來看應該類似於STORE_FAST,具體是啥暫時不用管;
4 LOAD_CLOSURE 0 (value): 又是一條未見過的指令,不過這個我們從名字上可以看出來是load一個閉包;
6 BUILD_TUPLE 1: build一個元組, 為什么? 顯然是為了存儲內層函數(閉包)的
8 LOAD_CONST 2 (<code object func...: LOAD字節碼,顯然是內層函數func的字節碼;
10 LOAD_CONST 3 ('get_func.<locals>.func'): 又是一個LOAD_CONST,我們按照之前的分析,這次LOAD的應該是外層的local名字空間;
12 MAKE_FUNCTION 8 (closure): MAKE_FUNCTION,構造一個函數, 參數是8; 而且括號里面寫着closure, 表示這是個閉包;
14 STORE_FAST 0 (func): 調用STORE_FAST,將符號func和之前的PyFunctionObject組合成entry存儲起來, 當然我們知道這里不是存在字典里面的;符號func是在符號表中, PyFunctionObject對象是在常量池中, 並且它們在各自數組中的索引是相等的;
16 LOAD_FAST 0 (func): 因為我們返回了func,所以LOAD_CONST的參數是func;
18 RETURN_VALUE: 返回func;
最后再來看看調用內層函數執行的指令:
0 LOAD_GLOBAL 0 (print): 首先是LOAD_GLOBAL得到print函數,這不需要多說;
2 LOAD_DEREF 0 (value): 關鍵是這條LOAD_DEREF指令,顯然和上面的STORE_DEREF是一組,關系應該是類似於LOAD_FAST和STORE_FAST之間的關系那樣, 我們猜測;
4 CALL_FUNCTION 1: 調用函數, 參數個數為1;
雖然我們看到了幾個不認識的指令,不過不用慌,我們下面會順藤摸瓜,沿着那美麗動人的曲線慢慢地、逐一探索。目前只需要知道,在Python虛擬機執行8 LOAD_CONST 2 (<code object func...
指令的時候,就已經開始為closure的實現悄悄地添磚加瓦了。
創建closure
def get_func():
value = "inner"
def func():
print(value)
return func
show_value = get_func()
show_value()
我們前面介紹了,虛擬機在執行CALL_FUNCTION指令時,會進入 _PyFunction_FastCallDict 中。
//frameobject.c
PyObject *
_PyFunction_FastCallDict(PyObject *func, PyObject *const *args, Py_ssize_t nargs,
PyObject *kwargs)
{
//......
if (co->co_kwonlyargcount == 0 &&
(kwargs == NULL || PyDict_GET_SIZE(kwargs) == 0) &&
(co->co_flags & ~PyCF_MASK) == (CO_OPTIMIZED | CO_NEWLOCALS | CO_NOFREE))
//......
}
而在 _PyFunction_FastCallDict 中,由於當前的PyCodeObject為函數get_func對應的PyCodeObject。對於有閉包的函數來說,顯然這個條件是不滿足的,因此不會進入快速通道,而是會進入 _PyEval_EvalCodeWithName 。而且當前的這個PyCodeObject的co_cellvars是有東西的,可能這里有人奇怪了,我們沒看到代碼里面使用nonlocal聲明啊,其實之前說了,除了使用nonlocal聲明的變量外,還有內層函數使用的外層作用域中的變量。
def get_func():
value1 = "inner"
value2 = "inner"
def func():
value2 = ""
print(value1)
print(value2)
return func
print(get_func.__code__.co_cellvars) # ('value1',)
print(get_func().__code__.co_freevars) # ('value1',)
我們發現了內層函數自己定義了value2,所以它不再co_cellvars中,但是value1在內層函數中沒有,而是使用的外層函數內部的value1變量,所以它也在co_cellvars中。因此除了那些被nonlocal關鍵字聲明的變量之外,還有被內層函數使用的外層函數的變量。
因此在 _PyEval_EvalCodeWithName 中,Python虛擬機會如同處理默認參數一樣,將co_cellvars中的東西拷貝到新創建的PyFrameObject的f_localsplus里面。
PyObject *
_PyEval_EvalCodeWithName(PyObject *_co, PyObject *globals, PyObject *locals,
PyObject *const *args, Py_ssize_t argcount,
PyObject *const *kwnames, PyObject *const *kwargs,
Py_ssize_t kwcount, int kwstep,
PyObject *const *defs, Py_ssize_t defcount,
PyObject *kwdefs, PyObject *closure,
PyObject *name, PyObject *qualname)
{
//......
for (i = 0; i < PyTuple_GET_SIZE(co->co_cellvars); ++i) {
//聲明 Cell 對象,這個 Cell 對象是什么后面就知道了
PyObject *c;
Py_ssize_t arg;
/* 處理被嵌套函數共享的外層函數的局部變量 */
if (co->co_cell2arg != NULL &&
(arg = co->co_cell2arg[i]) != CO_CELL_NOT_AN_ARG) {
//創建 Cell 對象
c = PyCell_New(GETLOCAL(arg));
SETLOCAL(arg, NULL);
}
else {
c = PyCell_New(NULL);
}
if (c == NULL)
goto fail;
SETLOCAL(co->co_nlocals + i, c);
}
/* 將 Cell 對象拷貝一份, 因為外層函數和內層函數都可以調用
只不過一個是co_cellvars、一個是co_freevars */
for (i = 0; i < PyTuple_GET_SIZE(co->co_freevars); ++i) {
PyObject *o = PyTuple_GET_ITEM(closure, i);
Py_INCREF(o);
freevars[PyTuple_GET_SIZE(co->co_cellvars) + i] = o;
}
//......
return retval;
}
因此在 _PyEval_EvalCodeWithName 中,Python虛擬機會如同處理默認參數一樣,將co_cellvars中的東西拷貝到新創建的PyFrameObject的f_localsplus里面。
嵌套函數有時候很復雜,如果嵌套的層數比較多的話:
def foo1():
def foo2():
x = 1
def foo3():
x = 2
def foo4():
print(x)
return foo4
return foo3
return foo2
foo1()()()()
"""
2
"""
但是無論多少層,我們之前說的結論是不會變的。之前我們提到了,Cell 對象在python底層也是一個對象,那它必然也是一個PyObject,我們看一下它的定義:
//cellobject.h
typedef struct {
PyObject_HEAD
PyObject *ob_ref; /* Content of the cell or NULL when empty */
} PyCellObject;
這個對象似乎出乎意料的簡單,僅僅維護了一個PyObject_HEAD,和一個ob_ref(指向某個對象的指針)
//cellobject.c
PyObject *
PyCell_New(PyObject *obj)
{
//聲明一個PyCellObject對象
PyCellObject *op;
//為這個PyCellObject申請空間,類型是PyCell_Type
op = (PyCellObject *)PyObject_GC_New(PyCellObject, &PyCell_Type);
if (op == NULL)
return NULL;
//這里的obj是什么呢?顯然是上面_PyEval_EvalCodeWithName里面的GETLOCAL(arg)或者NULL
//說白了,就是我們之前說的那些被內層函數引用的外層函數的局部變量,或者NULL,如果沒人引用的話就是NULL
op->ob_ref = obj;
Py_XINCREF(obj);
_PyObject_GC_TRACK(op);
return (PyObject *)op;
}
但是實際上一開始是不知道這個ob_ref指向的是誰的,什么時候才知道呢?是在我們一開始的閉包代碼中,那句value = 'inner'
指令指令的時候,才會真正知道ob_ref指向的是誰。隨后這個cell對象被拷貝到了新創建的PyFrameObject對象的f_localsplus中,並且位置是co->co_nlocals+i
,說明在f_localsplus中,cell對象的位置是在局部變量之后的,這完全符合我們之前說的f_localsplus的內存布局。另外圖中畫錯了,指向應該是一個字符串 "inner",但我不知道為啥畫成了整數 10
但是我們發現了一個奇怪的地方,那就是我們發現這個cell對象(value)
好像沒有設置名字誒。實際上這個和我們之前提到的Python虛擬機將對局部變量符號的訪問方式從PyDictObject的查找變成了對PyTupleObject的索引是一個道理。在get_func這個函數執行的過程中,對value這個cell對象是通過基於索引訪問在f_localsplus中完成,因此完全不需要知道cell對象的名字。這個cell對象的名字實際上是在處理被內層函數引用外層函數的默認參數是產生的。我們說參數和內部的創建的變量都是局部變量,在處理默認參數的時候,就把value這個cell對象一並處理了。
在處理了cell對象之后,Python虛擬機將正式進入PyEval_EvalFrameEx,從而正式開始對函數get_func的調用過程。再看一下字節碼:
3 0 LOAD_CONST 1 ('inner')
2 STORE_DEREF 0 (value)
5 4 LOAD_CLOSURE 0 (value)
6 BUILD_TUPLE 1
8 LOAD_CONST 2 (<code object func at 0x000001AAB6F51450, file "call_function", line 5>)
10 LOAD_CONST 3 ('get_func.<locals>.func')
12 MAKE_FUNCTION 8 (closure)
14 STORE_FAST 0 (func)
8 16 LOAD_FAST 0 (func)
18 RETURN_VALUE
Disassembly of <code object func at 0x000001AAB6F51450, file "call_function", line 5>:
6 0 LOAD_GLOBAL 0 (print)
2 LOAD_DEREF 0 (value)
4 CALL_FUNCTION 1
6 POP_TOP
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
我們看到執行0 LOAD_CONST 1 ('inner')
之后,會將PyUnicodeObject對象'inner'壓入到運行時棧,緊接着便執行一條我們從未見過的全新的字節碼指令--STORE_DEREF
PyObject* _Py_HOT_FUNCTION
_PyEval_EvalFrameDefault(PyFrameObject *f, int throwflag)
{
freevars = f->f_localsplus + co->co_nlocals;
}
case TARGET(STORE_DEREF): {
//這里pop彈出的顯然是運行時棧的PyUnicodeObject對象'inner'
PyObject *v = POP();
//獲取cell,也就是閉包; 注意:這里要和之前說的cell對象區分一下,之前的cell對象是變量
//這里的cell則是閉包(內層函數+外層函數的局部作用域)
PyObject *cell = freevars[oparg];
//獲取老的cell對象
PyObject *oldobj = PyCell_GET(cell);
//我們看到了一個PyCell_SET,那么玄機肯定就在這里面了
PyCell_SET(cell, v);
Py_XDECREF(oldobj);
DISPATCH();
}
因此我們發現,ob_ref指向的對象似乎就是通過PyCell_SET設置的,沒錯,這家伙就是干這個勾當的。
//cellobject.h
PyAPI_FUNC(int) PyCell_Set(PyObject *, PyObject *);
//cellobject.c
int
PyCell_Set(PyObject *op, PyObject *obj)
{
PyObject* oldobj;
if (!PyCell_Check(op)) {
PyErr_BadInternalCall();
return -1;
}
oldobj = PyCell_GET(op);
Py_XINCREF(obj);
PyCell_SET(op, obj);
Py_XDECREF(oldobj);
return 0;
}
如此一來,f_localsplus就發生了變化。
def get_func():
value = "inner"
def func():
print(value)
return func
show_value = get_func()
show_value()
現在在get_func的環境中我們知道了value符號對應着一個PyUnicodeObject對象,但是closure是要將這個約束進行凍結,為了在嵌套函數func中被調用的時候還可以使用這個約束。這一次,我們的工具人PyFunctionObject就又登場了,在執行接下來的def func()
表達式對應的字節碼時,python虛擬機就會將(value, 'inner')
這個約束塞到PyFunctionObject中。
case TARGET(LOAD_CLOSURE): {
PyObject *cell = freevars[oparg];
Py_INCREF(cell);
PUSH(cell);
DISPATCH();
}
4 LOAD_CLOSURE
會將剛剛放置好的PyCellObject對象取出,並壓入運行時棧,緊接着6 BUILD_TUPLE
指令將PyCellObject對象打包進一個PyTupleObject對象,顯然這個PyTupleObject對象中可以存放多個PyCellObject對象,只不過我們的例子中只有一個PyCellObject對象。
隨后Python虛擬機通過8 LOAD_CONST
和10 LOAD_CONST
將內層函數func對應PyCodeObject和符號LOAD進來,壓入運行時棧,緊接着以一個12 MAKE_FUNCTION 8
指令完成約束和PyCodeObject之間的綁定,注意這里的字節碼指令依舊是MAKE_FUNCTION
,但是參數是8,我們再次看看MAKE_FUNCTION
這個指令,還記得這個指令在哪里嗎?沒錯,之前說了只要是字節碼指令,都在ceval.c
中
TARGET(MAKE_FUNCTION) {
//彈出名字
PyObject *qualname = POP();
//彈出PyCodeObject
PyObject *codeobj = POP();
//根據PyCodeObject對象、global命名空間、名字構造出PyFunctionObject
PyFunctionObject *func = (PyFunctionObject *)
PyFunction_NewWithQualName(codeobj, f->f_globals, qualname);
Py_DECREF(codeobj);
Py_DECREF(qualname);
if (func == NULL) {
goto error;
}
//我們看到參數是8,因此這個條件是成立的
if (oparg & 0x08) {
assert(PyTuple_CheckExact(TOP()));
//彈出閉包需要使用的變量信息,將該信息寫入到func_closure中
func ->func_closure = POP();
}
//這是處理注解的:只在python3.6+中存在
if (oparg & 0x04) {
assert(PyDict_CheckExact(TOP()));
func->func_annotations = POP();
}
//處理關鍵字參數
if (oparg & 0x02) {
assert(PyDict_CheckExact(TOP()));
func->func_kwdefaults = POP();
}
//處理默認參數
if (oparg & 0x01) {
assert(PyTuple_CheckExact(TOP()));
func->func_defaults = POP();
}
//壓入運行時棧
PUSH((PyObject *)func);
DISPATCH();
}
此時便將約束(內層函數需要使用的作用域信息)
和內層函數綁定在了一起。然后執行14 STORE_FAST
將新創建的PyFunctionObject對象放置到了f_localsplus當中。這樣的話,f_localsplus就又發生了變化。
從圖上我們發現內層函數居然在get_func的局部變量里面,是的沒有錯。其實按照我們之前說的,函數即變量,所以函數和普通變量一樣,都是在上一級棧幀的f_localsplus里面的。最后這個新建的PyFunctionObject對象被壓入到了上一級棧幀的運行時棧中,並且被作為上一個棧幀的返回值返回了。顯然有人就能猜到下一步要介紹什么了,既然拿到了閉包、或者說內層函數對應的PyFunctionObject,那么肯定要使用啊。而且估計有人猜到了,當外面拿到閉包的時候,調用,顯然會找到對應的閉包,然后抽出里面的PyCodeObject對象繼續創建棧幀。
使用閉包
closure是在get_func函數中被創建的,而對closure的使用,則是在inner_func中。在執行show_value()
對應的CALL_FUNCTION指令時,因為func對應的PyCodeObject對象的co_flags域中包含了CO_NESTED,因此在 _PyFunction_FastCallDict 函數中不會進入快速通道function_code_fastcall
,而是會進入 _PyEval_EvalCodeWithName 、PyEval_EvalFrameEx 、繼而進入 _PyEval_EvalFrameDefault 。不過問題是,Python是怎么知道co_flags域中包含了CO_NESTED呢?
def get_func():
value = "inner"
def func():
print(value)
return func
show_value = get_func()
print(show_value.__code__.co_flags) # 19
我們看到func函數的字節碼的co_flags是19,那么這個值是什么計算出來的呢?還是記得我們在介紹PyCodeObject對象和pyc文件那一章中,當時我們說,co_flags這個域主要用於mask,用來判斷參數類型的。
//code.h
#define CO_OPTIMIZED 0x0001
#define CO_NEWLOCALS 0x0002
#define CO_VARARGS 0x0004
#define CO_VARKEYWORDS 0x0008
#define CO_NESTED 0x0010
#define CO_GENERATOR 0x0020
#define CO_NOFREE 0x0040
#define CO_COROUTINE 0x0080
#define CO_ITERABLE_COROUTINE 0x0100
#define CO_ASYNC_GENERATOR 0x0200
函數沒有參數,顯然CO_VARARGS和CO_VARKEYWORDS是不存在的:
print(0x0001 | 0x0002 | 0x0010) # 19
# 因此閉包是包含CO_NESTED這個域的
根據之前說了,對於閉包來說,func對應的PyCodeObject中的co_freevars里面有引用了外層作用域中的符號名,在 _PyEval_EvalCodeWithName 中就會對這個co_freevars進行處理。
//ceval.c
PyObject *
_PyEval_EvalCodeWithName(PyObject *_co, PyObject *globals, PyObject *locals,
PyObject *const *args, Py_ssize_t argcount,
PyObject *const *kwnames, PyObject *const *kwargs,
Py_ssize_t kwcount, int kwstep,
PyObject *const *defs, Py_ssize_t defcount,
PyObject *kwdefs, PyObject *closure,
PyObject *name, PyObject *qualname)
{
Py_ssize_t i, n;
/* Copy closure variables to free variables */
for (i = 0; i < PyTuple_GET_SIZE(co->co_freevars); ++i) {
PyObject *o = PyTuple_GET_ITEM(closure, i);
Py_INCREF(o);
freevars[PyTuple_GET_SIZE(co->co_cellvars) + i] = o;
}
...
...
...
}
其中的closure變量是作為倒數第三個參數傳遞進來的,我們可以看看到底傳遞了什么?
//funcobject.h
#define PyFunction_GET_CLOSURE(func) \
(((PyFunctionObject *)func) -> func_closure)
PyObject *
_PyFunction_FastCallDict(PyObject *func, PyObject *const *args, Py_ssize_t nargs,
PyObject *kwargs)
{
//......
result = _PyEval_EvalCodeWithName((PyObject*)co, globals, (PyObject *)NULL,
args, nargs,
k, k != NULL ? k + 1 : NULL, nk, 2,
d, nd, kwdefs,
closure, name, qualname);
Py_XDECREF(kwtuple);
return result;
}
我們看到了,是把PyFunctionObject對象的func_closure拿出來了,這個func_closure是啥還記得嗎?之前說得,不記得了再看一下。
TARGET(MAKE_FUNCTION) {
...
if (oparg & 0x08) {
assert(PyTuple_CheckExact(TOP()));
//彈出閉包需要使用的變量信息,將該信息寫入到func_closure中
func ->func_closure = POP();
}
...
}
顯然這個func_closure就是PyFunctionObject對象中的、我們之前說得那個與對應PyCodeObject綁定的、裝滿了PyCellObject對象的PyTupleObject。所以在 _PyEval_EvalCodeWithName 中,進行的動作就是將這個PyTupleObject里面的PyCellObject對象一個一個的放到f_localsplus中相應的位置。在處理完之后,func對應的PyFrameObject中f_localsplus就變成了這樣。
我們看到閉包使用的變量信息,被設置在了func_closure中,而這個函數是內層函數,那么我們可以通過__closure__進行獲取。
def get_func():
value = "inner"
def func():
print(value)
return func
show_value = get_func()
# cell是針對外層函數的
# free是針對內層函數的
# 我們看到在設置func_closure的時候是對內層函數進行設置的, 所以
print(get_func.__closure__) # None
print(show_value.__closure__) # (<cell at 0x000001E07D8382B0: str object at 0x000001E07D82D3F0>,)
# 我們看到外層函數的__closure__為None, 內層函數的__closure__則不是None
# 因此相當於將所有的cell對象(指針)拷貝了一份, 存在了free區域, 那么如何獲取cell對象的值呢
print(show_value.__closure__[0].cell_contents) # inner
所以在func調用的過程中,當引用外層作用域的符號時,一定是到f_localsplus里面的free變量區域去獲取對應PyCellObject,通過內部的ob_ref進而獲取符號對應的值。這正是func函數中'print(value)'表達式對應的第一條字節碼指令0 LOAD_DEREF 0
的意義。
case TARGET(LOAD_DEREF): {
PyObject *cell = freevars[oparg]; //獲取PyCellObject對象
PyObject *value = PyCell_GET(cell);//獲取PyCellObject對象的ob_ref指向的對象
if (value == NULL) {
format_exc_unbound(tstate, co, oparg);
goto error;
}
Py_INCREF(value);
PUSH(value);//壓入運行時棧
DISPATCH();
}
所以在func調用的過程中,當引用外層作用域的符號時,一定是到f_localsplus里面的free變量區域去獲取對應PyCellObject,通過內部的ob_ref進而獲取符號對應的值。這正是func函數中'print(value)'表達式對應的第一條字節碼指令0 LOAD_DEREF 0
的意義。
此外通過閉包,我們還可以玩出一些新花樣,但是工作中不要這么做。
def get_func():
value = "inner"
def func():
print(value)
return func
show_value = get_func()
show_value() # inner
show_value.__closure__[0].cell_contents = "內層函數"
show_value() # 內層函數
裝飾器
裝飾器算是Python中一個亮點,當然其實也不算什么亮點,本質上也是使用了閉包的思想,只不過給我們提供了一個優雅的語法糖。
裝飾器的本質就是高階函數加上閉包,至於為什么要有裝飾器,我覺得有句話說的非常好,裝飾器存在的最大意義就是可以在不改動原函數的代碼和調用方式的情況下,為函數增加一些新的功能。
def deco(func):
print("都閃開,我要開始裝飾了")
def inner(*args, **kwargs):
print("開始了")
ret = func(*args, **kwargs)
print("結束")
return ret
return inner
@deco # 這一步就等價於foo = deco(foo)
def foo(a, b):
print(a, b)
# 因此上來就會打印deco里面的print
print("---------")
# 此時再調用foo,已經不再是原來的foo了,而是deco里面的閉包inner
foo(1, 2)
# 整體輸出如下:
"""
都閃開,我要開始裝飾了
---------
開始了
1 2
結束
"""
# 根據輸出的---------,我們知道deco里面的print是在裝飾的時候就已經打印了
我們可以使用之前的方式:
def deco(func):
print("都閃開,我要開始裝飾了")
def inner(*args, **kwargs):
print("開始了")
ret = func(*args, **kwargs)
print("結束")
return ret
return inner
def foo(a, b):
print(a, b)
# 其實@deco的方式就是一個語法糖,它本質上就是
foo = deco(foo)
print("-------")
foo(1, 2)
"""
都閃開,我要開始裝飾了
-------
開始了
1 2
結束
"""
所以這個現象告訴我們,裝飾器只是類似於foo = deco(foo)
的一個語法糖罷了
裝飾器本質上就是使用了閉包,兩者的字節碼很類似,這里就不再看了。還是那句話,@
只是個語法糖,它和我們直接調用foo = deco(foo)
是一樣的,所以理解裝飾器(decorator)的關鍵就在於理解閉包(closure)。
當然函數在被裝飾器裝飾之后,整個函數其實就已經變了,為了保留原始信息我們一般會從functools中導入一個wraps函數。當然裝飾器的使用方式、以及類裝飾器,這些都屬於Python層級的東西了,我們就不說了。
當然,我們知道函數可以同時被多個裝飾器裝飾的。如果有多個裝飾器,那么它們是怎么裝飾的呢?
def deco1(func):
def inner():
return f"<deco1>{func()}</deco1>"
return inner
def deco2(func):
def inner():
return f"<deco2>{func()}</deco2>"
return inner
def deco3(func):
def inner():
return f"<deco3>{func()}</deco3>"
return inner
@deco1
@deco2
@deco3
def foo():
return "hanser"
print(foo())
請問它的輸出結果是什么呢?
可以先分析,解釋器還是從上到下解釋,但是發現了
@deco1
的時候,肯定要裝飾了,但是發現在它下面的哥們不是函數也是一個裝飾器,於是說:要不哥們,你先裝飾。然后@deco2
發現它下面還是一個裝飾器,於是重復了剛才的話,但是當@deco3
的時候,發現下面終於是一個普通的函數了。於是裝飾了,當deco3裝飾完畢之后,foo = deco3(foo)
,然后deco2發現deco3已經裝飾完畢了,然后對deco3裝飾的結果再進行裝飾,此時foo = deco2(deco3(foo))
,同理再經過deco1的裝飾,得到了foo = deco1(deco2(deco3(foo)))
print(foo()) # <deco1><deco2><deco3>hanser</deco3></deco2></deco1>
關於函數的面試題
1. Python 中有幾個名字空間,分別是什么?Python 變量以什么順序進行查找?
Python總共有4個名字空間:
局部名字空間(local)
閉包名字空間(closure)
全局名字空間(global)
內建名字空間(builtin)
我們之前說過,Python 查找變量時,依次檢查 局部 、閉包、全局、內建 這幾個名字空間,直到變量被找到為止。如果幾個空間都遍歷完了還沒找到,那么會拋出NameError。
2. 如何在一個函數內部修改全局變量?
在函數內部用 global 關鍵字將變量聲明為全局,然后再進行修改:
a = 1
def f():
global a
a = 2
print(a) # 1
f()
print(a) # 2
或者獲取global名字空間,然后通過字典進行修改,因為全局變量是通過字典來存儲的。
a = 1
def f():
globals()["a"] = 2
print(a) # 1
f()
print(a) # 2
3. 不使用 def 關鍵字的話,還有什么辦法可以創建函數對象?
根據 Python 對象模型,實例對象可以通過調用類型對象來創建。而函數類型對象,雖然沒有直接暴露給我們,但我們可以通過函數對象找到:
def f():
pass
print(f.__class__) # <class 'function'>
print((lambda: None).__class__) # <class 'function'>
事實上,Python 將函數類型對象暴露在 types 模塊中,可通過模塊屬性 FunctionType 訪問到:
from types import FunctionType
def f():
pass
print(FunctionType) # <class 'function'>
print(f.__class__ is FunctionType) # True
然而它干的事情和我們本質上是一樣的,我們看一下源碼怎么實現的:
def _f(): pass
FunctionType = type(_f)
吱吱吱~~~
而創建函數的時候,可以根據PyCodeObject對象創建,我們之前已經見過了。當時我們傳遞了3個參數:PyCodeObject、名字空間、函數名。其實可以傳遞五個參數:
PyCodeObject對象
globals
name
argdef: 默認參數的值
closure: 閉包變量
def f(v):
global value
value = v
g = {}
# 對於new_f而言, g就是它的全局名字空間, 所以設置的全局變量value會體現在g中
new_f = type(f)(f.__code__, g, "new_f")
new_f(10)
print(g) # {'value': 10}
new_f("夏色祭")
print(g) # {'value': '夏色祭'}
是不是奇怪的知識又增加了呢?但還是那句話,這種做法沒有什么實際用途,只是讓我們能夠更好地理解函數的機制。
4. 請介紹裝飾器的運行原理,並說說你對 @xxxx 這種寫法的理解?
裝飾器用於包裝函數對象,在不修改函數源碼和調用方式的前提下、達到修改函數行為的目的。它的本質是高階函數加上閉包,而@xxxx只是一個語法糖。
class Deco:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
print("++++++")
res = self.func(*args, **kwargs)
print("******")
return res
@Deco
def foo(a, b):
print(a + b)
print(foo(1, 2))
"""
++++++
3
******
None
"""
還是那句話,裝飾器本質是高階函數加上閉包,而很多語言都有閉包,也可以多層函數嵌套。但是對於Python而言,裝飾器顯得格外的優雅。
flask框架就用到了大量的裝飾器,比如:@app.route("/"),不得不說,flask的作者真的是非常喜歡使用裝飾器,還有它們團隊開發的、用於處理命令行參數的click模塊,也是大量使用了裝飾器。
5. Python 中的閉包變量(外層作用域的變量)可以被內部函數修改嗎?
顯然是可以的,有兩種方式:一種是通過nonlocal關鍵字,另一種是通過獲取閉包變量的方式。
def f1():
value = 0
def f2():
return value
return f2
f = f1()
print(f()) # 0
f.__closure__[0].cell_contents = ">>>"
print(f()) # >>>
6. 請描述執行以下程序將輸出什么內容?並試着解釋其中的原因。
def add(n, l=[]):
l.append(n)
return l
print(add(1)) # [1]
print(add(2)) # [1, 2]
print(add(3)) # [1, 2, 3]
出現這種問題的原因就在於,Python 函數在創建時便完成了默認參數的初始化,並保存在函數對象的 __defaults__ 字段中,並且是不變的,永遠是那一個對象:
def add(n, l=[]):
l.append(n)
return l
print(add.__defaults__[0]) # []
print(add(1)) # [1]
print(add.__defaults__[0]) # [1]
print(add(2)) # [1, 2]
print(add.__defaults__[0]) # [1, 2]
print(add(3)) # [1, 2, 3]
print(add.__defaults__[0]) # [1, 2, 3]
顯然在函數執行的時候,如果我們沒有傳遞參數,那么會從棧幀的f_localsplus中獲取對應的默認值,當然這個默認值也在函數的__defaults__中。這個f_localsplus由局部變量、cell對象、free對象、運行時棧組成,運行時棧位於棧頂,Python 虛擬機負責從函數對象中取出默認參數並設置相關局部變量:
由於列表是可變對象,因此采用append的方式,那么顯然每一次都會有變化的,因為操作的是同一個列表。
所以在設置默認參數的時候,不要設置成可變對象。如果你的IDE比較智能的話,比如pycharm,那么會給你拋出警告的。
我們看到飄黃了,因為默認參數的值是一個可變對象。
小結
到目前為止,我們關於函數的內容就算分析完了,可以好好體會一下函數的底層實現。我們下一篇將來分析Python中類的實現,又是一塊難啃的骨頭。