Python 中的作用域準則

0x00 前言

因為最早用的是 Java 和 C#,寫 Python 的時候自然也把 Python 作用域的想的和原有的一致。

Python 的作用域變數遵循在大部分情況下是一致的,但也有例外的情況。

本文著通過遇到的一個作用域的小問題來說說 Python 的作用域

0x01 作用域的幾個實例

但也有部分例外的情況,比如:

1.1 第一個例子

作用域第一版代碼如下

a = 1nprint(a, id(a)) # 列印 1 4465620064ndef func1():n print(a, id(a))nfunc1() # 列印 1 4465620064n

作用域第一版對應位元組碼如下

4 0 LOAD_GLOBAL 0 (print)n 3 LOAD_GLOBAL 1 (a)n 6 LOAD_GLOBAL 2 (id)n 9 LOAD_GLOBAL 1 (a)n 12 CALL_FUNCTION 1 (1 positional, 0 keyword pair)n 15 CALL_FUNCTION 2 (2 positional, 0 keyword pair)n 18 POP_TOPn 19 LOAD_CONST 0 (None)n 22 RETURN_VALUEn

PS: 行 4 表示 代碼行數 0 / 3 / 9 ... 不知道是啥,我就先管他叫做吧 是 load global PPS: 注意條 3/6 LOAD_GLOBAL 為從全局變數中載入

順手附上本文需要著重理解的幾個指令

LOAD_GLOBA : Loads the global named co_names[namei] onto the stack.nLOAD_FAST(var_num) : Pushes a reference to the local co_varnames[var_num] onto the stack.nSTORE_FAST(var_num) : Stores TOS into the local co_varnames[var_num].n

這點似乎挺符合我們認知的,那麼,再深一點呢?既然這個變數是可以 Load 進來的就可以修改咯?

1.2 第二個例子

然而並不是,我們看作用域第二版對應代碼如下

a = 1nprint(a, id(a)) # 列印 1 4465620064ndef func2():n a = 2n print(a, id(a))nfunc2() # 列印 2 4465620096n

一看,WTF, 兩個 a 內存值不一樣。證明這兩個變數是完全兩個變數。

作用域第二版對應位元組碼如下

4 0 LOAD_CONST 1 (2)n 3 STORE_FAST 0 (a)nn 5 6 LOAD_GLOBAL 0 (print)n 9 LOAD_FAST 0 (a)n 12 LOAD_GLOBAL 1 (id)n 15 LOAD_FAST 0 (a)n 18 CALL_FUNCTION 1 (1 positional, 0 keyword pair)n 21 CALL_FUNCTION 2 (2 positional, 0 keyword pair)n 24 POP_TOPn 25 LOAD_CONST 0 (None)n 28 RETURN_VALUEn

注意行 4 條 3 (STORE_FAST) 以及行 5 條 9/15 (LOAD_FAST)

這說明了這裡的 a 並不是 LOAD_GLOBAL 而來,而是從該函數的作用域 LOAD_FAST 而來。

1.3 第三個例子

那我們在函數體重修改一下 a 值看看。

a = 1ndef func3():n print(a, id(a)) # 注釋掉此行不影響結論n a += 1n print(a, id(a))nfunc3() # 當調用到這裡的時候 local variable a referenced before assignmentn# 即 a += 1 => a = a + 1 這裡的第二個 a 報錯鳥nn 3 0 LOAD_GLOBAL 0 (print)n 3 LOAD_FAST 0 (a)n 6 CALL_FUNCTION 1 (1 positional, 0 keyword pair)n 9 POP_TOPnn 4 10 LOAD_FAST 0 (a)n 13 LOAD_CONST 1 (1)n 16 BINARY_ADDn 17 STORE_FAST 0 (a)nn 5 20 LOAD_GLOBAL 0 (print)n 23 LOAD_FAST 0 (a)n 26 CALL_FUNCTION 1 (1 positional, 0 keyword pair)n 29 POP_TOPn 30 LOAD_CONST 0 (None)n 33 RETURN_VALUEn

那麼,func3 也就自然而言由於沒有無法 LOAD_FAST 對應的 a 變數,則報了引用錯誤。

然後問題來了,a 為基本類型的時候是這樣的。如果引用類型呢?我們直接仿照 func3 的實例把 a 改成 list 類型。如下

1.4 第四個例子

a = [1]ndef func4():n print(a, id(a)) # 這條注不注釋掉都一樣n a += 1 # 這裡我故意寫錯 按理來說應該是 a.append(1)n print(a, id(a))nfunc4()nn# 當調用到這裡的時候 local variable a referenced before assignmentn

╮(╯▽╰)╭ 看來事情那麼簡單,結果變數 a 依舊是無法修改。

可按理來說跟應該報下面的錯誤呀

int object is not iterablen

1.5 第五個例子

a = [1]ndef func5():n print(a, id(a))n a.append(1)n print(a, id(a))nfunc5()n# [1] 4500243208n# [1, 1] 4500243208n

這下可以修改了。看一下位元組碼。

3 0 LOAD_GLOBAL 0 (print)n 3 LOAD_GLOBAL 1 (a)n 6 LOAD_GLOBAL 2 (id)n 9 LOAD_GLOBAL 1 (a)n 12 CALL_FUNCTION 1 (1 positional, 0 keyword pair)n 15 CALL_FUNCTION 2 (2 positional, 0 keyword pair)n 18 POP_TOPnn 4 19 LOAD_GLOBAL 1 (a)n 22 LOAD_ATTR 3 (append)n 25 LOAD_CONST 1 (1)n 28 CALL_FUNCTION 1 (1 positional, 0 keyword pair)n 31 POP_TOPnn 5 32 LOAD_GLOBAL 0 (print)n 35 LOAD_GLOBAL 1 (a)n 38 LOAD_GLOBAL 2 (id)n 41 LOAD_GLOBAL 1 (a)n 44 CALL_FUNCTION 1 (1 positional, 0 keyword pair)n 47 CALL_FUNCTION 2 (2 positional, 0 keyword pair)n 50 POP_TOPn 51 LOAD_CONST 0 (None)n 54 RETURN_VALUEn

從全局拿來 a 變數,執行 append 方法。

0x02 作用域準則以及本地賦值準則

2.1 作用域準則

看來這是解釋器遵循了某種變數查找的法則,似乎就只能從原理上而不是在 CPython 的實現上解釋這個問題了。

查找了一些資料,發現 Python 解釋器在依據 基於 LEGB 準則 (順手吐槽一下不是 LGBT)

LEGB 指的變數查找遵循

  • Local
  • Enclosing-function locals
  • Global
  • Built-In

StackOverFlow 上 martineau 提供了一個不錯的例子用來說明

x = 100nprint("1. Global x:", x)nclass Test(object):n y = xn print("2. Enclosed y:", y)n x = x + 1n print("3. Enclosed x:", x)nn def method(self):n print("4. Enclosed self.x", self.x)n print("5. Global x", x)n try:n print(y)n except NameError as e:n print("6.", e)nn def method_local_ref(self):n try:n print(x)n except UnboundLocalError as e:n print("7.", e)n x = 200 # causing 7 because has same namen print("8. Local x", x)nninst = Test()ninst.method()ninst.method_local_ref()n

我們試著用變數查找準則去解釋 第一個例子 的時候,是解釋的通的。

第二個例子,發現函數體內的 a 變數已經不是那個 a 變數了。要是按照這個查找原則的話,似乎有點說不通了。

但當解釋第三個例子的時候,就完全說不通了。

a = 1ndef func3():n print(a, id(a)) # 注釋掉此行不影響結論n a += 1n print(a, id(a))nfunc3() # 當調用到這裡的時候 local variable a referenced before assignmentn# 即 a += 1 => a = a + 1 這裡的第二個 a 報錯鳥n

按照我的猜想,這裡的代碼執行可能有兩種情況:

  • 當代碼執行到第三行的時候可能是向從 local 找 a, 發現沒有,再找 Enclosing-function 發現沒有,最後應該在 Global 裡面找到才是。注釋掉第三行的時候也是同理。
  • 當代碼執行到第三行的時候可能是向下從 local 找 a, 發現有,然後代碼執行,結束。

但如果真的和我的想法接近的話,這兩種情況都可以執行,除了變數作用域之外還是有一些其他的考量。我把這個叫做本地賦值準則 (拍腦袋起的名稱)

一般我們管這種考量叫做 Python 作者就是覺得這種編碼方式好你愛寫不寫 Python 作者對於變數作用域的權衡。

事實上,當解釋器編譯函數體為位元組碼的時候,如果是一個賦值操作 (list.append 之流不是賦值操作),則會被限定這個變數認為是一個 local 變數。如果在 local 中找不到,並不向上查找,就報引用錯誤。

這不是 BUGn這不是 BUGn這不是 BUGn

這是一種設計權衡 Python 認為 雖然不強求強制聲明類型,但假定被賦值的變數是一個 Local 變數。這樣減少避免動態語言比如 JavaScript 動不動就修改掉了全局變數的坑。

這也就解釋了第四個例子中賦值操作報錯,以及第五個例子 append 為什麼可以正常執行。

如果我偏要勉強呢? 可以通過 global 和 nonlocal 來 引入模塊級變數 or 上一級變數。

PS: JS 也開始使用 let 進行聲明,小箭頭函數內部賦值查找變數也是向上查找。

0xEE 參考鏈接

  • Martineau 的例子

ChangeLog:

  • 2017-11-20 從原有筆記中抽取本文整理而成

推薦閱讀:

TAG:Python | variablescope | 编程 |