Python 中的作用域準則

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 從原有筆記中抽取本文整理而成
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容