Python 學習之作用域

ARM的程式設計師敲著詩歌的夢發表於2019-03-05

Python 學習之作用域

問題的引出

請看這樣一段程式碼:

例1

# test01.py
n = 5

def test():
    n += 1
    print(n)

test()

猜猜執行結果是什麼?

我們們執行一下:

$ python3 test01.py
Traceback (most recent call last):
  File "test01.py", line 8, in <module>
    test()
  File "test01.py", line 5, in test
    n += 1
UnboundLocalError: local variable 'n' referenced before assignment

居然出錯了,難道不應該是輸出6嗎?其實,要想弄清楚這個問題,就要系統地學習一些概念。

程式碼塊

Python 程式是由程式碼塊構成的。程式碼塊是被作為一個單元來執行的一段 Python 程式文字。以下幾個都是程式碼塊:模組、函式體和類定義

名稱的繫結

名稱用於指代物件(你可以理解為名稱是一個指向物件的指標)。名稱是通過名稱繫結操作來引入的。以下構造會繫結名稱:傳給函式的正式形參,import 語句,類定義,函式定義,以識別符號為目標的賦值,for迴圈的開頭等等等等。

總之,就本文討論的問題,我們只要記住 以識別符號為目標的賦值會繫結名稱 就可以了。

三種變數

如果名稱繫結在一個程式碼塊中,則為該程式碼塊的區域性變數(通俗地說該程式碼塊內定義了一個區域性變數),除非宣告為 nonlocalglobal。如果名稱繫結在模組層級,則為全域性變數。(模組程式碼塊的變數既為區域性變數又為全域性變數。) 如果變數在一個程式碼塊中被使用但不是在其中定義,則為自由變數。

讀到這裡,你可以先簡單理解為def之外定義的變數就是全域性變數,def之內定義的變數就是區域性變數,只讀不修改的變數就是自由變數。

再回頭看看例1的程式碼,我新增加了第2行和第5行的註釋。

# test01.py
n = 5   # 名稱繫結在模組層級,n為全域性變數

def test():
    n += 1 # 因為有賦值操作,所以是名稱繫結。 名稱繫結在函式內,n為區域性變數,這個n不是第2行的那個n
    print(n)

test()

作用域

作用域定義了一個名稱在程式碼塊內的可見性。

如果程式碼塊中定義了一個區域性變數,則其作用域包含該程式碼塊,也就是說這個名稱在該程式碼塊內是可見的。如果定義(名稱繫結)發生於函式程式碼塊中,則其作用域會擴充套件到該函式所包含的任何程式碼塊,除非有某個被包含程式碼塊引入了對該名稱的不同繫結。

名稱繫結的位置決定了這個名稱的作用域。也就是說,一個變數的作用域總是由它在程式碼中被賦值的地方所決定。

一般來說,變數可以在3個不同的地方賦值,分別對應3種不同的作用域:

  1. 如果一個變數在def內賦值,則它被定位在這個函式之內;
  2. 如果一個變數在一個巢狀的def中賦值,對於外層的def來說,它是非本地的(即它不屬於外層函式);
  3. 如果一個變數在def之外賦值,它就是全域性的。

注意:一個函式內部的任何型別的賦值都會把一個名稱劃定為本地的。這包括=語句,import中的模組名稱,def中的函式名稱,函式引數名稱等。

名稱解析

所謂名稱解析,我的理解就是確定一個名稱到底指代哪個物件。舉個例子,全國有成千上萬個叫“王東”的,我說王東欠我80萬,你知道我說的是哪個王東嗎?

當一個名稱在程式碼塊中被使用時,會由包含它的最近(這裡的近是指地理位置近)作用域來解析。在最近作用域中,這個名稱被繫結到了哪個物件,它就指代那個物件。如果完全找不到這個名稱,將會引發NameError異常。

例2

# test02.py
n = 5    # 名稱繫結在模組層級,n為全域性變數

def test():
    n = 6  # 名稱繫結在函式內,n為區域性變數,這個n不是第2行的那個n
    print(n) # 包含n的最近作用域是函式作用域,所以這個n就是第5行的n

test()

這個例子的結果是輸出6。

如果一個程式碼塊內的任何位置發生名稱繫結操作,則程式碼塊內所有對該名稱的使用都會被認為是對當前程式碼塊的引用。當一個名稱在其被繫結前就在程式碼塊內被使用時則會導致錯誤。這是一個很微妙的規則。Python 缺少宣告語法,並允許名稱繫結操作發生於程式碼塊內的任何位置。一個程式碼塊的區域性變數可通過在整個程式碼塊文字中掃描名稱繫結操作來確定。

一個典型的例子是:如果名稱繫結發生於函式程式碼塊中,但是該名稱被使用的時候尚未繫結到特定值,那麼將會引發UnboundLocalError異常。UnboundLocalErrorNameError 的一個子類。

講到這裡,你應該明白例1為什麼會報錯了吧。

請注意第5行的註釋。

# test01.py
n = 5

def test():
    n += 1  # 這句確實是名稱繫結,但是先要讀出n的值,這時候n尚未繫結到特定值。
    print(n)

test()

我們們再看一個例子。
例3

# test03.py
n = 5

def test():
    print(n)
    n = 7

test()

請思考一下,結果會是什麼。

結果是:

$ python3 test03.py 
Traceback (most recent call last):
  File "test03.py", line 8, in <module>
    test()
  File "test03.py", line 5, in test
    print(n)
UnboundLocalError: local variable 'n' referenced before assignment

同例1一樣,發生了 UnboundLocalError

我們分析一下,請看註釋:

# test03.py
n = 5      # 名稱繫結在模組層級,n為全域性變數

def test():
    print(n)
    # 如果一個程式碼塊內的任何位置發生名稱繫結操作,
    # 則程式碼塊內所有對該名稱的使用都會被認為是對當前程式碼塊的引用,
    # 所以這裡的n就是第10行的n,n的使用在第10行的繫結之前,所以會導致錯誤
    
    n = 7   # 發生了名稱繫結,程式碼塊內所有對該名稱的使用都會被認為是這個n

test()

LEGB 作用域查詢規則

在這裡插入圖片描述

前文已經說過:當一個名稱在程式碼塊中被使用時,會由包含它的最近作用域來解析。具體來說,當引用一個變數時,Python 按照以下順序查詢:

  1. 本地變數(in the local scope)
  2. 任意上層函式的作用域(in any enclosing functions’ local scopes)
  3. 全域性作用域(in the global scope)
  4. 內建作用域(in the built-in scope)

Python 會在第一處能找到這個變數名的地方停下來。

global 和 nonlocal

global 語句用來表明特定變數生存於全域性作用域並且應當在其中被重新繫結;nonlocal 語句表明特定變數生存於外層作用域並且應當在其中被重新繫結。

也許你不理解這句話,沒有關係,下文會舉例說明。

global

如果需要給一個在函式內部卻屬於模組頂層的變數名賦值,需要在函式內部用 global 語句宣告。

例4

# test04.py
n = 5

def test():
    global n  # 宣告函式內的n是第2行的全域性變數n
    n += 1    # 全域性變數n加1
    print(n)  # 列印全域性變數
    
test()  # 輸出 6
print(n) # 輸出 6,說明全域性變數確實被修改了

例5

# test05.py
n = 5  # 名稱繫結在模組層級,n為全域性變數

def test():
    print(n)  # 引用第2行的全域性變數,可以不宣告
    
test() # 輸出5

要點

全域性變數如果在函式內被賦值的話,必須經過宣告。

全域性變數如果是在函式內被引用(只是讀),可以不宣告。

nonlocal

如果需要給一個在巢狀的def中卻屬於外層函式的變數名賦值,需要在巢狀的def中用 nonlocal 宣告(從Python 3.0 開始)。

如果沒有宣告,這些變數將是隻讀的(嘗試寫入這樣的變數只會在最內層作用域中建立一個新的區域性變數,而同名的外部變數保持不變)。

例6

# test06.py
x = 99  # 名稱繫結在模組層級,x為全域性變數

def f1():
    x = 88   # 名稱繫結在函式f1內,x為函式f1的區域性變數
    def f2():
        print(x) # 根據LEGB規則,引用第5行的x
    f2()

f1() # 輸出88

例7

# test07.py
def func():
    x = 66  # 名稱繫結在函式func內,x為函式func的區域性變數
    def nested():
        nonlocal x  # 宣告x是外層函式中的x,即第3行定義的x
        x = 88      # 修改第3行的x,而不是重新定義一個x
    nested()
    print(x)

func()  # 輸出88

如果去掉第5行:

例8

# test08.py
def func():
    x = 66     # 名稱繫結在函式func內,x為函式func的區域性變數
    def nested():
        x = 88      # 重新定義一個x,不同於第3行的x
    nested()
    print(x)       # 引用第3行的x

func()  # 輸出66

注意

  1. 自由變數的名稱解析發生於執行時,而不是編譯時。
  2. 原處改變物件並不會把變數劃分為本地變數,實際上只有對變數名賦值才可以。

例9

i = 10
def f():
    print(i)  # i是自由變數,在執行時解析。雖然它就是指第1行的i,但是在執行時,i的值是42
i = 42
f()  # 列印42

例10

# test10.py
lst = [1,2,3]

def test():
    print(lst)    # 引用第2行的lst
    lst[0] += 10  # 原處改變物件,而不是對變數名lst賦值,此處的lst是引用第2行的lst
    print(lst)

test()
print(lst)

執行結果是:

$ python3 test10.py 
[1, 2, 3]
[11, 2, 3]
[11, 2, 3]

【End】





參考資料

《Learning Python,5th Edition》

《Python Tutorial,Release 3.7.2》

《The Python Language Reference, Release 3.7.2》

相關文章