Python3中的命名繫結、解析與函式閉包
介紹
本篇主要介紹Python中的命名解析與函式閉包,關於類或物件的命名解析是關於屬性,在另一篇中有詳細介紹:Python3描述器
Python中的名字(name)
Python中的名字不等同於其他語言中的變數,當進行賦值操作時,name1 = xxx,是給物件xxx賦予了名字nam1,這被稱為命名繫結(name binding)。而當訪問name1時,例如print(name1),則是將訪問name1對應的繫結物件xxx,這個操作被稱為命名解析(Resolution of name)。
首先通過一個簡單例子描述Python中的名字。
a = 3
b = a
a = 4
print(a)
print(b)
""" output
4
3
"""
在上面程式碼中a = 3,將名字’a’繫結到了物件’3’,而當執行b = a時,等號右邊的a指的是之前的物件’3’,這條語句將名字’b’繫結到了’a’繫結的物件’3’,此時物件’3’有了兩個名字’a’、‘b’。在執行a = 4時,將名字’a’重新繫結到物件’4’上,因此最後打印出了結果是’b’繫結的物件3和’a’新繫結的物件’4’。
下面將對Python中的命名繫結和解析進行分析。
命名繫結
Python中有多種命名繫結的方式,例如最常見的在程式碼塊中的變數賦值,這裡的程式碼塊包括(模組、函式體、類等等)。除此之外,函式引數的傳入、類和函式的定義(繫結函式、類名到所屬程式碼塊)、import語句等等也都是常見的命名繫結。
名稱空間
通過上文我們知道命名繫結實際上是給某個物件起了一個名字,方便後序我們通過訪問名字時就能拿到繫結的物件,因此現在產生了一個問題,Python如何記錄描述這個對應關係呢?換句話說當我們訪問(呼叫)某個名字時我們怎麼拿到這個名字對應的物件呢?
上面的問題由Python中的名稱空間(namespace)來解答,在命名繫結後,Python會產生字典來描述名字和物件的對應關係,通過字典的key-value能獲取名字和物件的對應關係。
下面看看Python中有哪些名稱空間以及他們的生命週期。
名稱空間 | 級別 | 生成 | 死亡 |
---|---|---|---|
全域性(global) | 模組中 | 模組開始或被引用時 | 直譯器結束 |
區域性(local) | 函式中等 | 函式開始 | 函式結束 |
原生(built-in) | 原生包builtin中 | 直譯器開始 | 直譯器結束 |
a = 1
def hello():
a = 2
print(a)
def hey():
a = 3
print(a)
hello()
hey()
print(a)
""" output
2
3
1
"""
通過以上例子我們可以看出,名字’a’及它繫結的物件可以存在於多個名稱空間,這些名稱空間之間相互獨立,在兩個函式之外繫結的a = 1將儲存在全域性名稱空間,而在呼叫函式時,名字’a’將儲存在自己所屬的區域性名稱空間。
因此,我們可以引出下一問題,這些名稱空間可以同時存在且相互獨立,我們在某處程式碼中訪問某個名字想要獲取它繫結的物件時,到底訪問的是哪個名稱空間?為解釋這個問題,下面將詳細介紹命名解析,和命名繫結時發生的細節。
scope
再解決上節描述的問題前,我們先看Python中scope的概念。簡單來說,scope就是程式碼層面上的區域,Python中的程式碼由各個scope構成(模組的全域性scope,函式的區域性scope),這個區域對應著這個scope中生成的名稱空間。上述所說的命名繫結可以發生在任何scope上,例如模組、函式等。因此上節的問題“在某處程式碼中訪問某個名字想要獲取它繫結的物件時,到底訪問的是哪個名稱空間?”的另一種表達為,在某處scope訪問某個名字時是通過哪個scope的名稱空間訪問的。
命名解析
上面scope的描述可能有些晦澀,簡單來說命名解析(訪問名字對應的物件)其實就是編譯器如何找到這個名字所對應的物件,這個是通過確定scope來解決的。那麼下面就會產生新的問題:
- 在命名解析時,Python直譯器是怎麼找到從哪個scope中的名稱空間從而從中拿到對應的物件呢?
- 確定scope的時間是什麼時候呢?
下面將對這個兩個問題展開詳細闡述。
編譯階段
Python中的dis.dis可以看到Python中編譯後的位元組碼,通過它可以看到Python中的執行順序。例如:通過dis觀察函式的編譯,可以看出函式執行時的具體步驟。
from dis import dis
def hello():
a = 3
print(a)
dis(hello)
""" output
5 0 LOAD_CONST 1 (3)
2 STORE_FAST 0 (a)
6 4 LOAD_GLOBAL 0 (print)
6 LOAD_FAST 0 (a)
8 CALL_FUNCTION 1
10 POP_TOP
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
"""
dis中打印出來Python編譯後的位元組順序,其中LOAD_*和STORE_*分別代表著命名解析和命名繫結的步驟。下文中我們將詳細通過它們來介紹,命名解析和繫結發生的步驟。
區域性變數(local variables)
我們首先看看常見的在函式中定義變數(命名繫結),以及訪問變數(命名解析)。在函式這個scope中進行命名繫結的就是區域性變數,例如上文hello函式中的a=3,除此之外函式引數也是區域性變數(函式引數的傳入也是命名繫結),上面位元組碼中的’STORE_FAST’就是儲存區域性變數的過程,當Python執行這句話時,名字’a’將儲存到函式hello的區域性名稱空間中。因為’a’是區域性變數,因此在命名解析時的位元組碼為’LOAD_FAST’,說明Python在執行訪問名字’a’時將從區域性名稱空間中找到’a’所對應的物件。由此我們可以看出,Python在編譯階段就可以確定該如何進行命名解析。
全域性變數(global variables)
在函式中,我們也會經常訪問函式之外的在模組中繫結的名字,編譯器會將在模組中繫結的名字視全域性變數,在執行階段執行到繫結的語句後全域性變數將儲存在模組的全域性名稱空間中。在所有模組中定義的函式中對該名字進行訪問,都將被編譯器視作是訪問全域性scope中的名稱空間。
但如果該函式中再次綁定了這個名字,這就變為了上節提到的區域性變數,編譯器將不會在將訪問變數時指向全域性scope的名稱空間。下面通過例子來說明這兩點。
from dis import dis
a = 3
def hello():
print(a)
dis(hello)
""" output
6 0 LOAD_GLOBAL 0 (print)
2 LOAD_GLOBAL 1 (a)
4 CALL_FUNCTION 1
6 POP_TOP
8 LOAD_CONST 0 (None)
10 RETURN_VALUE
"""
在hello函式外綁定了名字a後,在hello函式中的訪問都會被編譯器翻譯成’LOAD_GLOBAL’位元組碼,以此在Python執行hello函式時,訪問名字’a’時將會從全域性名稱空間去找到’a’對應的物件。
from dis import dis
a = 3
def hello():
a = 4
print(a)
dis(hello)
""" output
6 0 LOAD_CONST 1 (4)
2 STORE_FAST 0 (a)
7 4 LOAD_GLOBAL 0 (print)
6 LOAD_FAST 0 (a)
8 CALL_FUNCTION 1
10 POP_TOP
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
"""
如果在hello函式中重新繫結名字’a’,我們看到在hello函式中訪問’a’時,不再是’LOAD_GLOBAL’的位元組碼,而是變為’LOAD_FAST’,因為名字’a’在hello函式繫結時會被編譯器視為區域性變數。
繫結的順序不會影響編譯器的判斷,上面的程式碼中我們都是在命名解析前進行命名繫結,下面我們調整下順序觀察編譯器的變化。
from dis import dis
a = 3
def hello():
print(a)
a = 4
dis(hello)
""" output
6 0 LOAD_GLOBAL 0 (print)
2 LOAD_FAST 0 (a)
4 CALL_FUNCTION 1
6 POP_TOP
7 8 LOAD_CONST 1 (4)
10 STORE_FAST 0 (a)
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
"""
我們在函式hello中將兩行程式碼的順序交換了一下,可以看到在hello函式中訪問’a’時,即使’a’在global中已經繫結,但由於hello函式中的’a’又被重新繫結作為區域性變數,即使重新繫結在訪問之後,編譯器扔將這次訪問翻譯成’LOAD_FAST’的區域性變數方式的位元組碼,和繫結的順序無關。因此在執行hello函式時,由於在進行到’LOAD_FAST’這步時會從區域性名稱空間去找,但由於’STORE_FAST’存入名稱空間的這步還為發生,因此訪問’a’會報’UnboundLocalError’的錯誤,並不會打印出a在全域性空間繫結的物件’3’。
上述中的編譯尤為重要,是命名繫結和解析的核心關鍵,我們看到編譯器關心命名在哪個scope中進行繫結,並不關心繫結和解析的順序,而在程式執行中才會考慮到順序問題。
因此上文scope的問題的部分回答為:如果在函式中對某個名字進行繫結,則會被編譯器翻譯成’LOAD_FAST’,直譯器會從函式的區域性名稱空間中去找對應的物件;如果該名字沒有在函式scope中進行繫結,則編譯器會翻譯成’LOAD_GLOABL’,直譯器在執行階段會從全域性名稱空間找,如果找不到則去原生名稱空間找,都找不到則會報’NameError’錯誤。
總結一下在編譯階段,就可以決定執行時在某scope處訪問名字時應當從哪個名稱空間對找名字對應的物件。
自由變數(free variables)
對於巢狀函式來說,會和上面的描述有些區別。我們可以在被巢狀的內部函式中訪問外部函式,下面看看具體細節。
from dis import dis
def hello():
a = 3
def hello_in():
print(a)
print(a)
dis(hello)
""" output
4 0 LOAD_CONST 1 (3)
2 STORE_DEREF 0 (a)
5 4 LOAD_CLOSURE 0 (a)
6 BUILD_TUPLE 1
8 LOAD_CONST 2 (<code object hello_in at 0x10b8c7b70, file "/Users/lyr/Desktop/lyr/計算機/python/py-learn/interface.py", line 5>)
10 LOAD_CONST 3 ('hello.<locals>.hello_in')
12 MAKE_FUNCTION 8
14 STORE_FAST 0 (hello_in)
7 16 LOAD_GLOBAL 0 (print)
18 LOAD_DEREF 0 (a)
20 CALL_FUNCTION 1
22 POP_TOP
24 LOAD_CONST 0 (None)
26 RETURN_VALUE
"""
我們可以看到因為在hello函式的內部函式hello_in中用到了hello函式的區域性變數’a’因此,編譯器將不再將’a’視為區域性變數,而將它視作自由變數,因此在hello函式中對名字’a’進行繫結和解析時分別對應的位元組碼為’STORE_DEREF’和’LOAD_DEREF’,
在執行階段,自由變數對應的名稱空間也不在是hello函式的區域性名稱空間,而可以理解為是一個cell陣列,而在內部函式hello_in中訪問’a’的位元組碼為’LOAD_CLOSURE’,它在執行時引用剛才提到的cell物件進行命名解析。在後文中的閉包中將進一步解析。
global和nonlocal
我們在函式中可以訪問全域性變數,但當在函式中重新繫結改命名時將會產生區域性變數,而在函式中使用global關鍵字,編譯器可以將命名的scope指定為全域性的,當 進行賦值操作時不再是將該名字繫結到該函式的scope作為區域性變數,而是重新綁定了全域性的該名字,下面通過例子說明。
from dis import dis
a = 3
def hello():
global a
a = 4
print(a)
hello()
print(a)
dis(hello)
""" output
4
4
6 0 LOAD_CONST 1 (4)
2 STORE_GLOBAL 0 (a)
7 4 LOAD_GLOBAL 1 (print)
6 LOAD_GLOBAL 0 (a)
8 CALL_FUNCTION 1
10 POP_TOP
12 LOAD_CONST 0 (None)
14 RETURN_VALUE
"""
我們可以看到在編譯器檢查到’global a’時,所有在該函式對名字’a’的繫結和解析都是針對模組的名稱空間的,因此編譯器編譯成相應的’*_GLOBAL’位元組碼。在執行完hello函式後,因為重新繫結的原因模組的名稱空間中的’a’所對應的物件變為’4’。
同樣地,編譯器不關心global和命名繫結的順序,如果在‘global a’之前呼叫’a = 4’,編譯器也不會將’a’視作區域性變量了,‘a = 4’依然會被翻譯為’STORE_GLOBAL’。因此若此時呼叫hello函式會報’SyntaxError’的錯誤。
閉包(Closure)
一個函式在執行完後其中的名稱空間銷燬,此時該名稱空間中的名字所對應的物件也因此釋放,而利用閉包可以保留住物件狀態。下面舉例說明。
import weakref
map = weakref.WeakKeyDictionary()
class A:
def __init__(self, value):
self.value = value
def outer():
a = A(0)
map[a] = a.value
print(dict(map))
outer()
print(dict(map))
""" output
{<__main__.A object at 0x10f060b38>: 0}
{}
"""
上面的map是一個字典,它的key用來儲存弱引用物件,當執行outer函式時map字典增加了用名字’a’繫結的類A的例項物件,而在outer函式結束時,outer函式的區域性名稱空間被清理,記憶體釋放後沒有名字繫結到剛才的例項物件,因此弱引用字典為空。下面通過閉包保留住這個例項物件。
import weakref
map = weakref.WeakKeyDictionary()
class A:
def __init__(self, value):
self.value = value
def outer():
a = A(0)
def inner():
map[a] = a.value
print(dict(map))
return inner
in = outer()
in()
print(dict(map))
""" output
{<__main__.A object at 0x1077f2b38>: 0}
{<__main__.A object at 0x1077f2b38>: 0}
"""
以上的編譯後的位元組碼為
12 0 LOAD_GLOBAL 0 (A)
2 LOAD_CONST 1 (0)
4 CALL_FUNCTION 1
6 STORE_DEREF 0 (a)
14 8 LOAD_CLOSURE 0 (a)
10 BUILD_TUPLE 1
12 LOAD_CONST 2 (<code object inner at 0x10a18e4b0)
14 LOAD_CONST 3 ('outer.<locals>.inner')
16 MAKE_FUNCTION 8
18 STORE_FAST 0 (inner)
18 20 LOAD_FAST 0 (inner)
22 RETURN_VALUE
以上的位元組碼在上文中提到,當inner函式引用了外層函式的變數’a’時,‘a’將不再儲存到區域性名稱空間中,而是儲存到cell陣列中,而這個cell陣列其實儲存到了閉包inner中。當呼叫’in = outer()'時,函式將名字’in’繫結到了outer函式返回的閉包物件inner,因此再次呼叫’print(dict(map))'時,由於閉包inner被名字’in’繫結,此時cell陣列並沒有被銷燬,依然儲存著outer函式中’a’繫結的類A的例項物件。所以通過閉包保持了outer函式中的變數。
若將’in = outer(); in()‘更改為’outer()()’,同樣呼叫了閉包inner,但因為沒有名字綁定了閉包,所以map字典就會是空的了。
閉包的用途
下面我們通過不同的方式,實現計算函式被呼叫的次數。
通過類:
class A:
times = 1
def hello(self):
print("call times: ", self.times)
self.times += 1
a = A()
a.hello()
a.hello()
a.hello()
""" output
call times: 1
call times: 2
call times: 3
"""
可以將只包含一個函式的類轉閉包的實現方式。
def out():
a = 1
def hello():
nonlocal a
print("call times: ", a)
a += 1
return hello
hello = out()
hello()
hello()
hello()
""" output
call times: 1
call times: 2
call times: 3
"""
通過裝飾器的方式:
def decorate(func):
a = 1
def wrapper(*args, **kwargs):
nonlocal a
print('call times: ', a)
a += 1
return func(*args, **kwargs)
return wrapper
@decorate
def hello():
pass
hello()
hello()
hello()
""" output
call times: 1
call times: 2
call times: 3
"""
我們看到,裝飾器本質其實就是閉包,"@decorate"相當於執行了"hello = decorate(hello)",相當於hello名字綁定了閉包wrapper,而decorate函式中的’a’和’func’就是閉包要保持的物件。
通過程式碼:“print(hello.code.co_freevars)“我們可以看到hello繫結的閉包返回了t它繫結的自由變數元組”(‘a’, ‘func’)”。
閉包的陷阱
import weakref
map = weakref.WeakKeyDictionary()
class A:
def __init__(self, value):
self.value = value
def hello():
a = A(0)
def hey():
map[a] = a.value
pass
heylist = []
for i in range(3):
a = A(i)
heylist.append(hey)
return heylist
a = hello()
for h in a:
h()
print(dict(map))
上面程式碼我們渴望的map輸出為三個物件的key,value分別為0,1,2。但由於hello函式中的變數’a’儲存在了閉包hey的cell陣列中,因此在閉包返回後,無論呼叫幾次,'a’所對應的物件都是外層函式hello中最後繫結的物件(這裡是整型i)。可以通過以下改造解決這個問題。
import weakref
map = weakref.WeakKeyDictionary()
class A:
def __init__(self, value):
self.value = value
def hello():
a = A(0)
def hey(a=a):
map[a] = a.value
pass
heylist = []
for i in range(3):
a = A(i)
heylist.append(lambda x=a: hey(x))
return heylist
a = hello()
for h in a:
h()
print(dict(map))
這裡關鍵點的是heylist.append中的’x=a’,x將作為區域性變數傳入hey中,hey將不再是閉包,'a’也不再是被保持的變數。真正的閉包是那個個lambda函式,它保持了變數hey。
通過__code__檢視:
print(hello.__code__.co_cellvars)
print(a[0].__code__.co_freevars)
1. 第一種
('a',)
('a',)
2. 第二種
('hey',)
('hey',)