當(dāng)查找一個(gè) Python 變量時(shí),虛擬機(jī)會(huì)進(jìn)行哪些動(dòng)作?
楔子
上一篇文章我們介紹了名字空間,并且知道了全局變量都存在 global 名字空間中,往 global 空間添加一個(gè)鍵值對(duì)相當(dāng)于定義一個(gè)全局變量。那么問題來了,如果往函數(shù)的 local 空間里面添加一個(gè)鍵值對(duì),是不是也等價(jià)于創(chuàng)建了一個(gè)局部變量呢?
def f1():
locals()["name "] = "古明地覺"
try:
print(name)
except Exception as e:
print(e)
f1() # name 'name' is not defined
全局變量的創(chuàng)建是通過向字典添加鍵值對(duì)實(shí)現(xiàn)的,因?yàn)槿肿兞繒?huì)一直變,需要使用字典來動(dòng)態(tài)維護(hù)。
但對(duì)于函數(shù)來講,內(nèi)部的變量是通過靜態(tài)方式存儲(chǔ)和訪問的,因?yàn)榫植孔饔糜蛑写嬖谀男┳兞吭诰幾g的時(shí)候就已經(jīng)確定了,我們通過 PyCodeObject 的 co_varnames 即可獲取內(nèi)部都有哪些變量。
所以,雖然我們說變量查找遵循 LGB 規(guī)則,但函數(shù)內(nèi)部的變量其實(shí)是靜態(tài)訪問的,不過完全可以按照 LGB 的方式理解。關(guān)于這方面的細(xì)節(jié),后續(xù)還會(huì)細(xì)說。
因此名字空間是 Python 的靈魂,它規(guī)定了變量的作用域,使得 Python 對(duì)變量的查找變得非常清晰。
LEGB 規(guī)則
LGB 是針對(duì) Python2.2 之前的,而從 Python2.2 開始,由于引入了嵌套函數(shù),所以內(nèi)層函數(shù)在找不到某個(gè)變量時(shí)應(yīng)該先去外層函數(shù)找,而不是直接就跑到 global 空間里面找,那么此時(shí)的規(guī)則就是 LEGB。
x = 1
def foo():
x = 2
def bar():
print(x)
return bar
foo()()
"""
2
"""
調(diào)用了內(nèi)層函數(shù) bar,如果按照 LGB 的規(guī)則來查找的話,由于函數(shù) bar 的作用域沒有 a,那么應(yīng)該到全局里面找,打印的結(jié)果是 1 才對(duì)。
但我們之前說了,作用域僅僅是由文本決定的,函數(shù) bar 位于函數(shù) foo 之內(nèi),所以函數(shù) bar 定義的作用域內(nèi)嵌于函數(shù) foo 的作用域之內(nèi)。換句話說,函數(shù) foo 的作用域是函數(shù) bar 的作用域的直接外圍作用域。
所以應(yīng)該先從 foo 的作用域里面找,如果沒有那么再去全局里面找,而作用域和名字空間是對(duì)應(yīng)的,所以最終打印了 2。
另外在調(diào)用 foo() 的時(shí)候,會(huì)執(zhí)行函數(shù) foo 中的 def bar(): 語句,這個(gè)時(shí)候解釋器會(huì)將 a = 2 與函數(shù) bar 捆綁在一起,然后返回,這個(gè)捆綁起來的整體就叫做閉包。
所以:閉包 = 內(nèi)層函數(shù) + 引用的外層作用域。
而這里顯示的規(guī)則就是 LEGB,其中 E 表示 Enclosing,代表直接外圍作用域。
global 表達(dá)式
在初學(xué) Python 時(shí),估計(jì)很多人都會(huì)對(duì)下面的問題感到困惑。
x = 1
def foo():
print(x)
foo()
"""
1
"""
首先這段代碼打印 1,這顯然是沒有問題的,不過下面問題來了。
x = 1
def foo():
print(x)
x = 2
foo()
這段代碼在執(zhí)行 print(x) 的時(shí)候是會(huì)報(bào)錯(cuò)的,會(huì)拋出一個(gè) UnboundLocalError。
圖片
意思就是說,無法訪問局部變量 x,因?yàn)樗€沒有和某個(gè)值(對(duì)象)進(jìn)行綁定。當(dāng)然,如果是以前的 Python 版本,比如 3.8,同樣會(huì)拋出這個(gè)錯(cuò)誤,只是信息不同。
圖片
意思是局部變量 x 在賦值之前就被使用了,所以盡管報(bào)錯(cuò)信息不同,但表達(dá)的含義是一樣的。
那么問題來了,在 print(x) 的下面加一個(gè) x = 2,整體效果不應(yīng)該是先打印全局變量 x,然后再創(chuàng)建一個(gè)局部變量 x 嗎?為啥就報(bào)錯(cuò)了呢,相信肯定有人為此困惑。如果想弄明白這個(gè)錯(cuò)誤的原因,需要深刻理解兩點(diǎn):
- 函數(shù)中的變量是靜態(tài)存儲(chǔ)、靜態(tài)訪問的, 內(nèi)部有哪些變量在編譯的時(shí)候就已經(jīng)確定;
- 局部變量在整個(gè)作用域內(nèi)都是可見的;
在編譯的時(shí)候,因?yàn)?nbsp;x = 2 這條語句,所以知道函數(shù)中存在一個(gè)局部變量 x,那么查找的時(shí)候就會(huì)在當(dāng)前局部作用域中查找。但還沒來得及賦值,就 print(x) 了,換句話說,在打印 x 的時(shí)候,它還沒有和某個(gè)具體的值進(jìn)行綁定,所以報(bào)錯(cuò):局部變量 x 在賦值之前就被使用了。
但如果沒有 x = 2 這條語句則不會(huì)報(bào)錯(cuò),因?yàn)橹谰植孔饔糜蛑胁淮嬖?x 這個(gè)變量,所以會(huì)找全局變量 x,從而打印 1。
更有趣的東西隱藏在字節(jié)碼當(dāng)中,我們可以通過反匯編來查看一下:
import dis
x = 1
def foo():
print(x)
dis.dis(foo)
"""
5 0 RESUME 0
6 2 LOAD_GLOBAL 1 (NULL + print)
12 LOAD_GLOBAL 2 (x)
22 CALL 1
30 POP_TOP
32 RETURN_CONST 0 (None)
"""
def bar():
print(x)
x = 2
dis.dis(bar)
"""
10 0 RESUME 0
11 2 LOAD_GLOBAL 1 (NULL + print)
12 LOAD_FAST_CHECK 0 (x)
14 CALL 1
22 POP_TOP
12 24 LOAD_CONST 1 (2)
26 STORE_FAST 0 (x)
28 RETURN_CONST 0 (None)
"""
第二列的序號(hào)代表字節(jié)碼指令的偏移量,我們看偏移量為 12 的指令,函數(shù) foo 對(duì)應(yīng)的指令是 LOAD_GLOBAL,意思是在 global 空間中查找 x。而函數(shù) bar 的指令是 LOAD_FAST_CHECK,表示在數(shù)組中靜態(tài)查找 x,但遺憾的是,此時(shí) x 還沒有和某個(gè)值進(jìn)行綁定。
因此結(jié)果說明 Python 采用了靜態(tài)作用域策略,在編譯的時(shí)候就已經(jīng)知道變量藏身于何處。而且這個(gè)例子也表明,一旦函數(shù)內(nèi)有了對(duì)某個(gè)變量的賦值操作,它會(huì)在整個(gè)作用域內(nèi)可見,因?yàn)榫幾g時(shí)就已經(jīng)確定。換句話說,會(huì)遮蔽外層作用域中相同的名字。
我們看一下函數(shù) foo 和函數(shù) bar 的符號(hào)表。
x = 1
def foo():
print(x)
def bar():
print(x)
x = 2
print(foo.__code__.co_varnames) # ()
print(bar.__code__.co_varnames) # ('x',)
在編譯的時(shí)候,就知道函數(shù) bar 里面存在局部變量 x。
如果想修復(fù)這個(gè)錯(cuò)誤,可以用之前說的 global 關(guān)鍵字,將變量 x 聲明為全局的。
x = 1
def bar():
global x # 表示變量 x 是全局變量
print(x)
x = 2
bar() # 1
print(x) # 2
但這樣的話,會(huì)導(dǎo)致外部的全局變量被修改,如果不想出現(xiàn)這種情況,那么可以考慮直接獲取全局名字空間。
x = 1
def bar():
print(globals()["x"])
x = 2
bar() # 1
print(x) # 1
這樣結(jié)果就沒問題了,同樣的,類似的問題也會(huì)出現(xiàn)在嵌套函數(shù)中。
def foo():
x = 1
def bar():
print(x)
x = 2
return bar
foo()()
執(zhí)行內(nèi)層函數(shù) bar 的時(shí)候,print(x) 也會(huì)出現(xiàn) UnboundLocalError,如果想讓它不報(bào)錯(cuò),而是打印外層函數(shù)中的 x,該怎么做呢?Python 同樣為我們準(zhǔn)備了一個(gè)關(guān)鍵字: nonlocal。
def foo():
x = 1
def bar():
# 使用 nonlocal 的時(shí)候,必須是在內(nèi)層函數(shù)里面
nonlocal x
print(x)
x = 2
return bar
foo()() # 1
如果 bar 里面是 global x,那么表示 x 是全局變量,當(dāng) foo()() 執(zhí)行完畢之后,會(huì)創(chuàng)建一個(gè)全局變量 x = 2。但這里不是 global,而是 nonlocal,表示 x 是外部作用域中的變量,因此會(huì)打印 foo 里面的變量 x。
當(dāng)然啦,既然聲明為 nonlocal,那么 foo 里面的 x 肯定會(huì)受到影響。
from types import FrameType
import inspect
frame: FrameType | None = None
def foo():
globals()["frame"] = inspect.currentframe()
x = 1
def bar():
nonlocal x
# print(x)
x = 2
return bar
bar = foo()
# 打印 foo 的局部變量,此時(shí)變量 x 的值為 1
print(frame.f_locals)
"""
{'bar': <function foo.<locals>.bar at 0x0000021EC32AB9A0>, 'x': 1}
"""
# 調(diào)用內(nèi)層函數(shù) bar
bar()
# 此時(shí) foo 的局部變量 x 的值變成了 2
print(frame.f_locals)
"""
{'bar': <function foo.<locals>.bar at 0x0000021EC32AB9A0>, 'x': 2}
"""
不過由于 foo 是一個(gè)函數(shù),調(diào)用內(nèi)層函數(shù) bar 的時(shí)候,外層函數(shù) foo 已經(jīng)結(jié)束了,所以不管怎么修改它里面的變量,都無所謂了。
另外上面的函數(shù)只嵌套了兩層,即使嵌套很多層也是可以的。
from types import FrameType
import inspect
frame: FrameType | None = None
def a():
def b():
globals()["frame"] = inspect.currentframe()
x = 123
def c():
def d():
def e():
def f():
nonlocal x
print(x)
x = 456
return f
return e
return d
return c
return b
b = a()
c = b()
d = c()
e = d()
f = e()
print(frame.f_locals)
"""
{'c': <function a.<locals>.b.<locals>.c at 0x00000255A0C10F70>, 'x': 123}
"""
# 調(diào)用函數(shù) f 的時(shí)候,打印的是函數(shù) b 里面的變量 x
# 當(dāng)然,最后也會(huì)修改它
f()
"""
123
"""
print(frame.f_locals)
"""
{'c': <function a.<locals>.b.<locals>.c at 0x00000255A0C10F70>, 'x': 456}
"""
不難發(fā)現(xiàn),在嵌套多層的情況下,會(huì)采用就近原則。如果函數(shù) d 里面也定義了變量 x,那么函數(shù) f 里面的 nonlocal x 表示的就是函數(shù) d 里面的局部變量 x。
屬性查找
當(dāng)我們?cè)L問某個(gè)變量時(shí),會(huì)按照 LEGB 的規(guī)則進(jìn)行查找,而屬性查找也是類似的,本質(zhì)上都是到名字空間中查找一個(gè)名字所引用的對(duì)象。但由于屬性查找限定了范圍,所以要更簡(jiǎn)單,比如 a.xxx,就是到 a 里面去找屬性 xxx,這個(gè)規(guī)則是不受 LEGB 作用域限制的,就是到 a 里面查找,有就是有,沒有就是沒有。
import numpy as np
# 在 np 指向的對(duì)象(模塊)中查找 array 屬性
print(np.array([1, 2, 3]))
"""
[1 2 3]
"""
# 本質(zhì)上就是去 np 的屬性字典中查找 key = "array"
print(np.__dict__["array"]([11, 22, 33]))
"""
[11 22 33]
"""
class Girl:
name = "古明地覺"
age = 16
print(Girl.name, Girl.age)
"""
古明地覺 16
"""
print(Girl.__dict__["name"], Girl.__dict__["age"])
"""
古明地覺 16
"""
需要補(bǔ)充一點(diǎn),我們說屬性查找會(huì)按照 LEGB 規(guī)則,但這必須限制在自身所在的模塊內(nèi),如果是多個(gè)模塊就不行了。舉個(gè)例子,假設(shè)有兩個(gè) py 文件,內(nèi)容如下:
# girl.py
print(name)
# main.py
name = "古明地覺"
from girl import name
關(guān)于模塊的導(dǎo)入我們后續(xù)會(huì)詳細(xì)說,總之執(zhí)行 main.py 的時(shí)候報(bào)錯(cuò)了,提示變量 name 沒有被定義,但問題是 main.py 里面定義了變量 name,為啥報(bào)錯(cuò)呢?
很明顯,因?yàn)?girl.py 里面沒有定義變量 name,所以導(dǎo)入 girl 的時(shí)候報(bào)錯(cuò)了。因此結(jié)論很清晰了,變量查找雖然是 LEGB 規(guī)則,但不會(huì)越過自身所在的模塊。print(name) 在 girl.py 里面,而變量 name 定義在 main.py 里面,在導(dǎo)入時(shí)不可能跨過 girl.py 的作用域去訪問 main.py 里的 name,因此在執(zhí)行 from girl import name 的時(shí)候會(huì)拋出 NameError。
雖然每個(gè)模塊內(nèi)部的作用域規(guī)則有點(diǎn)復(fù)雜,因?yàn)橐裱?LEGB;但模塊與模塊的作用域之間則劃分得很清晰,就是相互獨(dú)立。
關(guān)于模塊,我們后續(xù)會(huì)詳細(xì)說。總之通過屬性操作符 . 的方式,本質(zhì)上都是去指定的名字空間中查找對(duì)應(yīng)的屬性。
屬性空間
我們知道,自定義的類里面如果沒有 __slots__,那么這個(gè)類的實(shí)例對(duì)象會(huì)有一個(gè)屬性字典,和名字空間的概念是等價(jià)的。
class Girl:
def __init__(self):
self.name = "古明地覺"
self.age = 16
g = Girl()
print(g.__dict__) # {'name': '古明地覺', 'age': 16}
# 對(duì)于查找屬性而言, 也是去屬性字典中查找
print(g.name, g.__dict__["name"]) # 古明地覺 古明地覺
# 同理設(shè)置屬性, 也是更改對(duì)應(yīng)的屬性字典
g.__dict__["gender"] = "female"
print(g.gender) # female
當(dāng)然模塊也有屬性字典,本質(zhì)上和類的實(shí)例對(duì)象是一致的,因?yàn)槟K本身就是一個(gè)實(shí)例對(duì)象。
print(__builtins__.str) # <class 'str'>
print(__builtins__.__dict__["str"]) # <class 'str'>
另外這個(gè) __builtins__ 位于 global 名字空間里面,然后獲取 global 名字空間的 globals 又是一個(gè)內(nèi)置函數(shù),于是一個(gè)神奇的事情就出現(xiàn)了。
print(globals()["__builtins__"].globals()["__builtins__"].
globals()["__builtins__"].globals()["__builtins__"].
globals()["__builtins__"].globals()["__builtins__"]
) # <module 'builtins' (built-in)>
print(globals()["__builtins__"].globals()["__builtins__"].
globals()["__builtins__"].globals()["__builtins__"].
globals()["__builtins__"].globals()["__builtins__"].list("abc")
) # ['a', 'b', 'c']
global 名字空間和 builtin 名字空間,都保存了指向彼此的指針,所以不管套娃多少次,都是可以的。
小結(jié)
整個(gè)內(nèi)容很好理解,關(guān)鍵的地方就在于局部變量,它是靜態(tài)存儲(chǔ)的,編譯期間就已經(jīng)確定。而在訪問局部變量時(shí),也是基于數(shù)組實(shí)現(xiàn)的靜態(tài)查找,而不是使用字典。
關(guān)于 local 空間,以及如何使用數(shù)組靜態(tài)查找,我們后面還會(huì)詳細(xì)說。