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