0x00 前言
因為最早用的是 Java 和 C#,寫 Python 的時候自然也把 Python 作用域的想的和原有的一致。
Python 的作用域變量遵循在大部分情況下是一致的,但也有例外的情況。
本文著通過遇到的一個作用域的小問題來說說 Python 的作用域
0x01 作用域的幾個實例
但也有部分例外的情況,比如:
1.1 第一個例子
作用域第一版代碼如下
a = 1
print(a, id(a)) # 打印 1 4465620064
def func1():
print(a, id(a))
func1() # 打印 1 4465620064
作用域第一版對應字節碼如下
4 0 LOAD_GLOBAL 0 (print)
3 LOAD_GLOBAL 1 (a)
6 LOAD_GLOBAL 2 (id)
9 LOAD_GLOBAL 1 (a)
12 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
15 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
18 POP_TOP
19 LOAD_CONST 0 (None)
22 RETURN_VALUE
PS: 行 4 表示 代碼行數 0 / 3 / 9 ... 不知道是啥,我就先管他叫做條吧 是 load global
PPS: 注意條 3/6 LOAD_GLOBAL 為從全局變量中加載
順手附上本文需要著重理解的幾個指令
LOAD_GLOBA : Loads the global named co_names[namei] onto the stack.
LOAD_FAST(var_num) : Pushes a reference to the local co_varnames[var_num] onto the stack.
STORE_FAST(var_num) : Stores TOS into the local co_varnames[var_num].
這點似乎挺符合我們認知的,那么,再深一點呢?既然這個變量是可以 Load 進來的就可以修改咯?
1.2 第二個例子
然而并不是,我們看作用域第二版對應代碼如下
a = 1
print(a, id(a)) # 打印 1 4465620064
def func2():
a = 2
print(a, id(a))
func2() # 打印 2 4465620096
一看,WTF, 兩個 a 內存值不一樣。證明這兩個變量是完全兩個變量。
作用域第二版對應字節碼如下
4 0 LOAD_CONST 1 (2)
3 STORE_FAST 0 (a)
5 6 LOAD_GLOBAL 0 (print)
9 LOAD_FAST 0 (a)
12 LOAD_GLOBAL 1 (id)
15 LOAD_FAST 0 (a)
18 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
21 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
24 POP_TOP
25 LOAD_CONST 0 (None)
28 RETURN_VALUE
注意行 4 條 3 (STORE_FAST) 以及行 5 條 9/15 (LOAD_FAST)
這說明了這里的 a 并不是 LOAD_GLOBAL 而來,而是從該函數的作用域 LOAD_FAST 而來。
1.3 第三個例子
那我們在函數體重修改一下 a 值看看。
a = 1
def func3():
print(a, id(a)) # 注釋掉此行不影響結論
a += 1
print(a, id(a))
func3() # 當調用到這里的時候 local variable 'a' referenced before assignment
# 即 a += 1 => a = a + 1 這里的第二個 a 報錯鳥
3 0 LOAD_GLOBAL 0 (print)
3 LOAD_FAST 0 (a)
6 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
9 POP_TOP
4 10 LOAD_FAST 0 (a)
13 LOAD_CONST 1 (1)
16 BINARY_ADD
17 STORE_FAST 0 (a)
5 20 LOAD_GLOBAL 0 (print)
23 LOAD_FAST 0 (a)
26 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
29 POP_TOP
30 LOAD_CONST 0 (None)
33 RETURN_VALUE
那么,func3 也就自然而言由于沒有無法 LOAD_FAST 對應的 a 變量,則報了引用錯誤。
然后問題來了,a 為基本類型的時候是這樣的。如果引用類型呢?我們直接仿照 func3 的實例把 a 改成 list 類型。如下
1.4 第四個例子
a = [1]
def func4():
print(a, id(a)) # 這條注不注釋掉都一樣
a += 1 # 這里我故意寫錯 按理來說應該是 a.append(1)
print(a, id(a))
func4()
# 當調用到這里的時候 local variable 'a' referenced before assignment
╮(╯▽╰)╭ 看來事情那么簡單,結果變量 a 依舊是無法修改。
可按理來說跟應該報下面的錯誤呀
'int' object is not iterable
1.5 第五個例子
a = [1]
def func5():
print(a, id(a))
a.append(1)
print(a, id(a))
func5()
# [1] 4500243208
# [1, 1] 4500243208
這下可以修改了。看一下字節碼。
3 0 LOAD_GLOBAL 0 (print)
3 LOAD_GLOBAL 1 (a)
6 LOAD_GLOBAL 2 (id)
9 LOAD_GLOBAL 1 (a)
12 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
15 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
18 POP_TOP
4 19 LOAD_GLOBAL 1 (a)
22 LOAD_ATTR 3 (append)
25 LOAD_CONST 1 (1)
28 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
31 POP_TOP
5 32 LOAD_GLOBAL 0 (print)
35 LOAD_GLOBAL 1 (a)
38 LOAD_GLOBAL 2 (id)
41 LOAD_GLOBAL 1 (a)
44 CALL_FUNCTION 1 (1 positional, 0 keyword pair)
47 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
50 POP_TOP
51 LOAD_CONST 0 (None)
54 RETURN_VALUE
從全局拿來 a 變量,執行 append 方法。
0x02 作用域準則以及本地賦值準則
2.1 作用域準則
看來這是解釋器遵循了某種變量查找的法則,似乎就只能從原理上而不是在 CPython 的實現上解釋這個問題了。
查找了一些資料,發現 Python 解釋器在依據 基于 LEGB 準則 (順手吐槽一下不是 LGBT)
LEGB 指的變量查找遵循
- Local
- Enclosing-function locals
- Global
- Built-In
StackOverFlow 上 martineau 提供了一個不錯的例子用來說明
x = 100
print("1. Global x:", x)
class Test(object):
y = x
print("2. Enclosed y:", y)
x = x + 1
print("3. Enclosed x:", x)
def method(self):
print("4. Enclosed self.x", self.x)
print("5. Global x", x)
try:
print(y)
except NameError as e:
print("6.", e)
def method_local_ref(self):
try:
print(x)
except UnboundLocalError as e:
print("7.", e)
x = 200 # causing 7 because has same name
print("8. Local x", x)
inst = Test()
inst.method()
inst.method_local_ref()
我們試著用變量查找準則去解釋 第一個例子 的時候,是解釋的通的。
第二個例子,發現函數體內的 a 變量已經不是那個 a 變量了。要是按照這個查找原則的話,似乎有點說不通了。
但當解釋第三個例子的時候,就完全說不通了。
a = 1
def func3():
print(a, id(a)) # 注釋掉此行不影響結論
a += 1
print(a, id(a))
func3() # 當調用到這里的時候 local variable 'a' referenced before assignment
# 即 a += 1 => a = a + 1 這里的第二個 a 報錯鳥
按照我的猜想,這里的代碼執行可能有兩種情況:
- 當代碼執行到第三行的時候可能是向從 local 找 a, 發現沒有,再找 Enclosing-function 發現沒有,最后應該在 Global 里面找到才是。注釋掉第三行的時候也是同理。
- 當代碼執行到第三行的時候可能是向下從 local 找 a, 發現有,然后代碼執行,結束。
但如果真的和我的想法接近的話,這兩種情況都可以執行,除了變量作用域之外還是有一些其他的考量。我把這個叫做本地賦值準則 (拍腦袋起的名稱)
一般我們管這種考量叫做 Python 作者就是覺得這種編碼方式好你愛寫不寫 Python 作者對于變量作用域的權衡。
事實上,當解釋器編譯函數體為字節碼的時候,如果是一個賦值操作 (list.append 之流不是賦值操作),則會被限定這個變量認為是一個 local 變量。如果在 local 中找不到,并不向上查找,就報引用錯誤。
這不是 BUG
這不是 BUG
這不是 BUG
這是一種設計權衡 Python 認為 雖然不強求強制聲明類型,但假定被賦值的變量是一個 Local 變量。這樣減少避免動態語言比如 JavaScript 動不動就修改掉了全局變量的坑。
這也就解釋了第四個例子中賦值操作報錯,以及第五個例子 append 為什么可以正常執行。
如果我偏要勉強呢? 可以通過 global 和 nonlocal 來 引入模塊級變量 or 上一級變量。
PS: JS 也開始使用 let 進行聲明,小箭頭函數內部賦值查找變量也是向上查找。
0xEE 參考鏈接
ChangeLog:
- 2017-11-20 從原有筆記中抽取本文整理而成