Python 中的作用域準(zhǔn)則

0x00 前言

因?yàn)樽钤缬玫氖?Java 和 C#,寫 Python 的時候自然也把 Python 作用域的想的和原有的一致。

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

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

0x01 作用域的幾個實(shí)例

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

1.1 第一個例子

作用域第一版代碼如下

a = 1
print(a, id(a)) # 打印 1 4465620064
def func1():
    print(a, id(a))
func1()  # 打印 1 4465620064

作用域第一版對應(yīng)字節(jié)碼如下

  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 表示 代碼行數(shù) 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].

這點(diǎn)似乎挺符合我們認(rèn)知的,那么,再深一點(diǎn)呢?既然這個變量是可以 Load 進(jìn)來的就可以修改咯?

1.2 第二個例子

然而并不是,我們看作用域第二版對應(yīng)代碼如下

a = 1
print(a, id(a)) # 打印 1 4465620064
def func2():
    a = 2
    print(a, id(a))
func2() # 打印 2 4465620096

一看,WTF, 兩個 a 內(nèi)存值不一樣。證明這兩個變量是完全兩個變量。

作用域第二版對應(yīng)字節(jié)碼如下

  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 而來,而是從該函數(shù)的作用域 LOAD_FAST 而來。

1.3 第三個例子

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

a = 1
def func3():
    print(a, id(a)) # 注釋掉此行不影響結(jié)論
    a += 1
    print(a, id(a))
func3() # 當(dāng)調(diào)用到這里的時候 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 對應(yīng)的 a 變量,則報了引用錯誤。

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

1.4 第四個例子

a = [1]
def func4():
    print(a, id(a)) # 這條注不注釋掉都一樣
    a += 1 # 這里我故意寫錯 按理來說應(yīng)該是 a.append(1)
    print(a, id(a))
func4()

# 當(dāng)調(diào)用到這里的時候 local variable 'a' referenced before assignment

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

可按理來說跟應(yīng)該報下面的錯誤呀

'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

這下可以修改了??匆幌伦止?jié)碼。

  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 變量,執(zhí)行 append 方法。

0x02 作用域準(zhǔn)則以及本地賦值準(zhǔn)則

2.1 作用域準(zhǔn)則

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

查找了一些資料,發(fā)現(xiàn) Python 解釋器在依據(jù) 基于 LEGB 準(zhǔn)則 (順手吐槽一下不是 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()

我們試著用變量查找準(zhǔn)則去解釋 第一個例子 的時候,是解釋的通的。

第二個例子,發(fā)現(xiàn)函數(shù)體內(nèi)的 a 變量已經(jīng)不是那個 a 變量了。要是按照這個查找原則的話,似乎有點(diǎn)說不通了。

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

a = 1
def func3():
    print(a, id(a)) # 注釋掉此行不影響結(jié)論
    a += 1
    print(a, id(a))
func3() # 當(dāng)調(diào)用到這里的時候 local variable 'a' referenced before assignment
# 即 a += 1 => a = a + 1 這里的第二個 a 報錯鳥

按照我的猜想,這里的代碼執(zhí)行可能有兩種情況:

  • 當(dāng)代碼執(zhí)行到第三行的時候可能是向從 local 找 a, 發(fā)現(xiàn)沒有,再找 Enclosing-function 發(fā)現(xiàn)沒有,最后應(yīng)該在 Global 里面找到才是。注釋掉第三行的時候也是同理。
  • 當(dāng)代碼執(zhí)行到第三行的時候可能是向下從 local 找 a, 發(fā)現(xiàn)有,然后代碼執(zhí)行,結(jié)束。

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

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

事實(shí)上,當(dāng)解釋器編譯函數(shù)體為字節(jié)碼的時候,如果是一個賦值操作 (list.append 之流不是賦值操作),則會被限定這個變量認(rèn)為是一個 local 變量。如果在 local 中找不到,并不向上查找,就報引用錯誤。

這不是 BUG
這不是 BUG
這不是 BUG

這是一種設(shè)計權(quán)衡 Python 認(rèn)為 雖然不強(qiáng)求強(qiáng)制聲明類型,但假定被賦值的變量是一個 Local 變量。這樣減少避免動態(tài)語言比如 JavaScript 動不動就修改掉了全局變量的坑。

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

如果我偏要勉強(qiáng)呢? 可以通過 global 和 nonlocal 來 引入模塊級變量 or 上一級變量。

PS: JS 也開始使用 let 進(jìn)行聲明,小箭頭函數(shù)內(nèi)部賦值查找變量也是向上查找。

0xEE 參考鏈接


ChangeLog:

  • 2017-11-20 從原有筆記中抽取本文整理而成
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容