Python 中名稱的有效範圍 (scope)

codemee

codemee

Posted on January 5, 2023

Python 中名稱的有效範圍 (scope)

在 Python 中程式是以程式區塊 (code block) 為單位, 每個模組 (module, 單一腳本檔)、函式本體、類別定義都是一個程式區塊, 有自己紀錄名稱與對應物件的清單, 稱為名稱空間 (namespace), 系統就是依據程式區塊間的層級關係, 找到個別名稱要對應到的物件。

綁定名稱

每當執行設定敘述、函式定義、類別定義、import 敘述, 以及傳入引數叫用函式或方法時, 就會將名稱綁定 (binding) 到對應的物件, 記錄在該程式區塊的名稱空間內。

在 Python 執行環境中, 預設會有 __builtins__ 名稱空間, 對應到 builtins 模組, 包含所有內建的名稱, 像是內建函式 print 的名稱就是記錄在這裡。當找不到綁定的名稱時, 最終就會到 __builtins__ 中尋找, 這也是我們可以不用 import 任何名稱就可以叫用 print 等內建函式的原因。

基本原則:使用在最近一層的程式區塊中綁定的名稱

當使用到某個名稱時, 基本原則就是以最靠近的程式區塊中綁定的名稱為準, 例如以下這個簡單的例子:

a = 10

def test():
    b = 20
    print(b)
    print(a)

test()
Enter fullscreen mode Exit fullscreen mode

一開始執行模組本身的程式區塊時, 當執行到 a = 10 後, 就會紀錄 a 名稱綁定到 10 這個整數物件;執行完 test 函式的定義後, 也會記錄 test 名稱綁定到定義好的函式:

__main__
|
| a   -----------> 10
| test-----------> test 函式
+-------
Enter fullscreen mode Exit fullscreen mode

Python 會把執行的模組取名為 "__main__", 如果是匯入的模組, 名稱則是檔名。等到叫用 test() 時, 由於在目前的名稱空間中就可以找到 test 這個名稱, 因此會執行該名稱所綁定到的函式。這時會執行此函式的程式區塊, 並建立該區塊的命名空間, 並在執行 b = 20 後記錄 b 名稱綁定到 20 這個整數物件:

+--__main__--
|
| a   -----------> 10
| test-----------> 函式
|
|    +--test--
|    |
|    | b---------> 20
|    +--------
+------------
Enter fullscreen mode Exit fullscreen mode

執行到 print(b) 時, 由於在目前的名稱空間中就可以找到 b 這個名稱, 因此印出的就是 20。接著執行 print(a) 時, 因為在目前的名稱空間中並沒有名稱 a, 會往外層的程式區塊中尋找, 所以印出的會是上一層的 a 所綁定的 10。

最後的執行結果就會是:

# py test.py
20
10
Enter fullscreen mode Exit fullscreen mode

這個搜尋名稱的動作是在執行時進行的, 因此即便把 a = 10 移到定義 test() 函式之後也沒有問題:

def test():
    b = 20
    print(b)
    print(a)

a = 10

test()
Enter fullscreen mode Exit fullscreen mode

實際叫用 test() 時, 已經綁定 a 了, 所以一樣可以找到名稱 a 正確執行:

# py test.py
20
10
Enter fullscreen mode Exit fullscreen mode

區域 (local) 與全域 (global) 變數

在特定程式區塊內綁定的名稱, 會在程式區塊結束後跟著消失, 無法使用。舉例來說, 如果在剛剛的範例最後加上 print(b)

a = 10

def test():
    b = 20
    print(b)
    print(a)

test()
print(b)
Enter fullscreen mode Exit fullscreen mode

雖然在 test 函式中有將 b 綁定到 20, 但是在 test() 叫用結束後, b 這個名稱也消失了, 執行時會因為名稱空間中找不到 b 而引發 NameError 例外:

# py test.py
20
10
Traceback (most recent call last):
  File "D:\code\test\test.py", line 9, in <module>
    print(b)
NameError: name 'b' is not defined
Enter fullscreen mode Exit fullscreen mode

由於所有的名稱都只在綁定時所在的程式區塊內有效, 因此稱為區域變數 (local variables)。對於在模組層級綁定的名稱, 例如前面範例中的 a, 因為在模組內的任何地方都可以使用, 也稱它們為全域變數 (global variables), 也就是說, 模組內的名稱既是該程式區塊內的區域變數, 也是模組內的全域變數。如果使用到沒有在所在區塊內綁定的名稱, 例如前述範例中 test() 裡面的 a, 就稱為自由變數 (free variable)

如果在區塊中有綁定特定名稱, 就會將該名稱視為是區塊內的區域變數, 若在綁定之前就先使用該名稱, 並不會因為找不到該名稱而往外層尋找, 而是會引發 UnboundLocalError 例外, 意思就是尚未綁定的區域變數, 例如:

a = 10

def test():
    b = 20
    print(b)
    print(a)
    a = 30

test()
Enter fullscreen mode Exit fullscreen mode

執行結果如下:

# py test.py
20
Traceback (most recent call last):
  File "D:\code\python\test.py", line 9, in <module>
    test()
  File "D:\code\python\test.py", line 6, in test
    print(a)
UnboundLocalError: local variable 'a' referenced before assignment
Enter fullscreen mode Exit fullscreen mode

這是因為 a 是在 print(a) 之後才綁定, 即使外層有同名的 a 也一樣。

內外層同名名稱的處理

由於是從最近一層的程式區塊開始尋找名稱, 所以若是內層與外層有同樣的名稱, 就無法使用到外層的名稱, 例如:

a = 10

def test():
    a = 20
    print(a)
    test1()

def test1():
    a = 30
    print(a)

print(a)
test()
Enter fullscreen mode Exit fullscreen mode

一開始執行到 print(a) 時, 找到的是模組綁定的名稱 a

__main__
|
| a    -----------> 10
| test -----------> test 函式
| test1-----------> test1 函式
+-------
Enter fullscreen mode Exit fullscreen mode

因此會印出 10, 到執行 test 時, 找到的是函式內綁定的名稱 a, 這個名稱和外層模組綁定的名稱 a 雖然同名, 但分屬於不同的名稱空間:

__main__
|
| a    -----------> 10
| test -----------> test 函式
| test1-----------> test1 函式
|
|    +--test--
|    |
|    | a ---------> 20
|    +--------
+-------
Enter fullscreen mode Exit fullscreen mode

因此印出 20。到執行 test1 時, 又綁定了一個新的 a, 如下所示:

__main__
|
| a    -----------> 10
| test -----------> test 函式
| test1-----------> test1 函式
|
|    +--test--
|    |
|    | a ---------> 20
|    +--------
|
|    +--test1--
|    |
|    | a ---------> 30
|    +--------
+-------
Enter fullscreen mode Exit fullscreen mode

因此會印出 30。最後的執行結果如下:

# py test.py
10
20
30
Enter fullscreen mode Exit fullscreen mode

請特別留意, 程式區塊的層級關係是原始碼的層級關係, 並不是函式之間叫用的關係, 也就是說, 雖然是在 test() 內叫用 test1(), 但兩者之間並沒有包含的關係。因此, 如果我們把 test1 中綁定 a 的程式去除, 像是這樣:

a = 10

def test():
    a = 20
    print(a)
    test1()

def test1():
    print(a)

print(a)
test()
Enter fullscreen mode Exit fullscreen mode

test1 中印出的 a 就會是外層模組中 a 綁定的 10:

# py test.py
10
20
10
Enter fullscreen mode Exit fullscreen mode

但如果將 test1 定義在 test 內, 像是這樣:

a = 10

def test():
    def test1():
        print(a)
    a = 20
    print(a)
    test1()

print(a)
test()
Enter fullscreen mode Exit fullscreen mode

執行到 test1 的時候, 區塊的層級關係會是這樣:

__main__
|
| a    -----------> 10
| test -----------> test 函式
| test1-----------> test1 函式
|
|    +--test--
|    |
|    | a ---------> 20
|    |
|    |    +--test1--
|    |    |
|    |    +--------
|    +--------
+-------
Enter fullscreen mode Exit fullscreen mode

因此離 test1 最近一層就是 test, 所以 a 名稱綁定的就是 20, 而不是模組內的 10 了:

# py test.py
10
20
20
Enter fullscreen mode Exit fullscreen mode

如果把 test 中綁定名稱 a 的設定敘述去除, 像是這樣:

a = 10

def test():
    def test1():
        print(a)
    print(a)
    test1()

print(a)
test()
Enter fullscreen mode Exit fullscreen mode

test1 就會再往外找到最外層的 a, 這樣就會印出 3 個 10 了:

# py test.py
10
10
10
Enter fullscreen mode Exit fullscreen mode

指定使用全域變數或是外層的區域變數

如果你想要使用的是最外層模組的全域變數 a, 可以在 test1 中使用 global 指明要引用的全域變數, 例如:

a = 10

def test():
    def test1():
        global a
        print(a)
    a = 20
    print(a)
    test1()

print(a)
test()
Enter fullscreen mode Exit fullscreen mode

這樣系統就會知道在 test1 中使用到名稱 a 時, 要直接到最外層找, 因此列印的是最外層的 a 綁定的 10:

# py test.py
10
20
10
Enter fullscreen mode Exit fullscreen mode

如果你很明確的要使用外層的區域變數, 而不是最上層模組的全域變數, 可以使用 nonlocal, 像是這樣:

a = 10

def test():
    def test1():
        nonlocal a
        print(a)
    a = 20
    print(a)
    test1()

print(a)
test()
Enter fullscreen mode Exit fullscreen mode

這樣在 test1 中使用的就會是外層 test 中的 a 了:

# py test.py
10
20
20
Enter fullscreen mode Exit fullscreen mode

nonlocal 並不只是單單往外找一層, 而是會一層層往外找, 例如:

a = 10

def test():
    def test1():
        def test2():
            nonlocal a
            print(a)
        test2()
    a = 20
    test1()

test()
Enter fullscreen mode Exit fullscreen mode

test2 中使用的就是往外兩層在 test 中綁定的 a, 所以印出的是 20:

# py test.py
20
Enter fullscreen mode Exit fullscreen mode

你可能會想說, 咦?這樣好像不用特別標示 nonlocal, 不就一樣會一層層往外找尋名稱, 為什麼要多此一舉呢?這是因為 nonlocal 尋找名稱時, 並不會到最外層的模組找尋全域變數, 以底下的例子來說:

b = 10

def test():
    def test1():
        nonlocal b
        print(b)
    test1()

test()
Enter fullscreen mode Exit fullscreen mode

雖然最外層模組有名稱 b, 可是因為在 test1 中將 b 標示為 nonlocal, 所以尋找名稱時並不會找到最外層而出現錯誤:

# py test.py
  File "D:\code\test\test.py", line 5
    nonlocal b
    ^^^^^^^^^^
SyntaxError: no binding for nonlocal 'b' found`
Enter fullscreen mode Exit fullscreen mode

實際上甚至根本都還沒有執行, Python 在編譯程式碼時就發現外層區塊並沒有綁定 b 名稱, 因而引發代表語法錯誤的 SyntaxError 例外。

縮排並不會建立程式區塊

由於函式的主體需要縮排, 所以會讓人誤以為縮排也會建立一個程式區塊, 像是 C/C++ 程式用大括號建立的區塊那樣。不過事實上, 縮排並不是程式區塊, 在縮排中綁定的名稱就是隸屬於所在的程式區塊, 離開縮排區域後還是存在, 例如:

for i in range(3):
    a = i
    print(i)

print(i)
print(a)
Enter fullscreen mode Exit fullscreen mode

for 迴圈結束後, 不論是隨著 for 綁定的 i 還是在 for 迴圈本體中才綁定的 a 都還是有效, 並不會消失。執行結果如下:

# py test.py
0
1
2
2
2
Enter fullscreen mode Exit fullscreen mode

類別定義的程式區塊不包含類別內的方法

前面提過區塊層級是以原始碼而定, 但有個例外, 就是類別定義的程式區塊並不包含類別中的方法, 像是以下這個例子:

class A:
    x = 10

    def test(self):
        print(x)

a = A()
a.test()
Enter fullscreen mode Exit fullscreen mode

依照往最近的區塊找尋名稱的規則, 在 test 方法中找不到的 x 應該是往外層找到類定義中綁定的 x, 不過實際上這個程式會發生錯誤:

# py test.py
Traceback (most recent call last):
  File "D:\code\test\test.py", line 8, in <module>
    a.test()
  File "D:\code\test\test.py", line 5, in test
    print(x)
NameError: name 'x' is not defined
Enter fullscreen mode Exit fullscreen mode

這是因為實際上類別定義有它自己的名稱空間, 和類別內的方法之間是獨立的, 你可以將之視為如下:

__main__
|
| a    -----------> A 物件
| A    -----------> 類別 A 的定義
|
|    +--A--
|    |
|    | x ---------> 10
|    +--------
|
|    +--a.test--
|    |
|    +--------
+-------
Enter fullscreen mode Exit fullscreen mode

在方法中找不到的名稱會往全域變數找, 因此如果在最外層定義 x, a.test 就會使用最外層的 x, 例如:

class A:
    x = 10

    def test(self):
        print(x)

x = 20
a = A()
a.test()
Enter fullscreen mode Exit fullscreen mode

執行結果如下:

# py test.py
20
Enter fullscreen mode Exit fullscreen mode

或者透過 self 引用定義在類別中的 x

class A:
    x = 10

    def test(self):
        print(self.x)

a = A()
a.test()
Enter fullscreen mode Exit fullscreen mode

印出來的就會是 10 了:

# py test.py
10
Enter fullscreen mode Exit fullscreen mode

類別定義的命名空間會成為類別自己的特徵值 (attributes), 這可以透過 object.__dict__ 查看, 例如:

>>> A.__dict__.keys()
dict_keys(['__module__', 'x', 'test', '__dict__', '__weakref__', '__doc__'])
Enter fullscreen mode Exit fullscreen mode

你可以看到 x 出現在其中, 我們也可以觀察 a

>>> a.__dict__.keys()
dict_keys([])
Enter fullscreen mode Exit fullscreen mode

你會發現是空的集合, 如果透過 a 引用 x, 會因為 a 本身沒有 x 可用, 於是再透過 a.__class__A 尋找而引用到類別定義中的 x

>>> a.x
10

>>> a.__class__
<class '__main__.A'>

>>> a.__class__.x
10
Enter fullscreen mode Exit fullscreen mode

如果幫 a 物件添加 x 特徵值, 那麼 a.test() 就會循 self 引用到這個 x

>>> a.x = 20
>>> A.x
10

>>> a.test()
20
>>> a.__dict__.keys()
dict_keys(['x'])
Enter fullscreen mode Exit fullscreen mode

遞迴呼叫的命名空間

前面提過, 每次執行一個程式區塊時, 就會建立一個新的名稱空間, 對於遞迴呼叫的函式, 就會建立多個同一函式的名稱空間, 以底下的例子來說:

def fact(n):
    if n < 2:
        return 1
    return n * fact(n - 1)

print(fact(4))
Enter fullscreen mode Exit fullscreen mode

執行到 fact(4) 時的名稱空間如下:

__main__
|
| fact    -----------> fact 函式
|
|    +--fact(4)--
|    |
|    | n ---------> 4
|    +--------
+-------
Enter fullscreen mode Exit fullscreen mode

但是因為遞迴, 會再依序執行 fact(3)fact()fact(1), 名稱空間變成:

__main__
|
| fact    -----------> fact 函式
|
|    +--fact(4)--
|    |
|    | n ---------> 4
|    +--------
|
|    +--fact(3)--
|    |
|    | n ---------> 3
|    +--------
|
|    +--fact(2)--
|    |
|    | n ---------> 2
|    +--------
|
|    +--fact(1)--
|    |
|    | n ---------> 1
|    +--------
+-------
Enter fullscreen mode Exit fullscreen mode

也就是說, 每次叫用 fact 時, 其內的 n 都是各自專屬的名稱, 而不是所有的 fact 共用同一個 n。這個結構會從 fact(1) 傳回 1 後依序傳回計算值, 最後得到 4*3*2*1, 也就是 24 的值:

# py test.py
24
Enter fullscreen mode Exit fullscreen mode

結語

本文希望透過簡短的文章與圖解, 讓初學者可以分清楚程式中實際使用的名稱到底是哪一個?避免因為用到尚未綁定的名稱、或是用錯名稱導致程式錯誤, 實際上可能還有一些細節, 不過對於一般程式來說, 本文提到的部分應該已經夠用了。

💖 💪 🙅 🚩
codemee
codemee

Posted on January 5, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related