關於Python閉包的一切

自動化程式碼美學發表於2021-05-28

任何把函式當做一等物件的語言,它的設計者都要面對一個問題:作為一等物件的函式在某個作用域中定義,但是可能會在其他作用域中呼叫,如何處理自由變數?

自由變數(free variable),未在區域性作用域中繫結的變數。

為了解決這個問題,Python之父Guido Van Rossum設計了閉包,有如神來之筆,程式碼美學盡顯。在討論閉包之前,有必要先了解Python中的變數作用域。

變數作用域

先看一個全域性變數和自由變數的示例:

>>> b = 6
>>> def f1(a):
...     print(a)
...     print(b)
...     
>>> f1(3)
3
6

函式體外的b為全域性變數,函式體內的b為自由變數。因為自由變數b繫結到了全域性變數,所以在函式f1()中能正確print。

如果稍微改一下,那麼函式體內的b就會從自由變數變成區域性變數

>>> b = 6
def f1(a):
...     print(a)
...     print(b)
...     b = 9
...     
>>> f1(3)
3
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "<input>", line 3, in f1
UnboundLocalError: local variable 'b' referenced before assignment

在函式f1()後面加上b = 9報錯:區域性變數b在賦值前進行了引用。

這不是缺陷,而是Python設計:Python不要求宣告變數,而是假定在函式定義體中賦值的變數是區域性變數

如果想讓直譯器把b當做全域性變數,那麼需要使用global宣告:

>>> b = 6
>>> def f1(a):
...     global b
...     print(a)
...     print(b)
...     b = 9
...     
>>> f1(3)
3
6

閉包

回到文章開頭的自由變數問題,假如有個叫做avg的函式,它的作用是計算系列值的均值,用類實現:

class Averager():
    
    def __init__(self):
        self.series = []
        
    def __call__(self, new_value):
        self.series.append(new_value)
        total = sum(self.series)
        return totle / len(self.series)

avg = Averager()
avg(10)  # 10.0
avg(11)  # 10.5
avg(12)  # 11.0

類實現不存在自由變數問題,因為self.series是類屬性。但是函式實現,進行函式巢狀時,問題就出現了:

def make_averager():
    series = []
    
    def averager(new_value):
        # series是自由變數
        series.append(new_value)
        total = sum(series)
        return totle / len(series)
    
    return averager

avg = make_averager()
avg(10)  # 10.0
avg(11)  # 10.5
avg(12)  # 11.0

函式make_averager()在區域性作用域中定義了series變數,它的內部函式averager()的自由變數series繫結了這個值。但是在呼叫avg(10)時,make_averager()函式已經return返回了,它的區域性作用域也消失了。沒有閉包的話,自由變數series一定會報錯找不到定義。

那麼閉包是怎麼做的呢?閉包是一種函式,它會保留定義時存在的自由變數的繫結,這樣呼叫函式時,雖然定義作用域不可用了,但是仍然能使用那些繫結。

如下圖所示:

image-20210525094509549

閉包會保留自由變數series的繫結,在呼叫avg(10)時繼續使用這個繫結,即使make_averager()函式的區域性作用域已經消失。

nonlocal

把上面示例的需求稍微優化下,只儲存目前的總值和元素個數:

def make_averager():
    count = 0
    total = 0
    
    def averager(new_value):
        count += 1
        total += new_value
        return total / count
        
    return averager

執行後會報錯:區域性變數count在賦值前進行了引用。因為count +=1等同於count = count + 1,存在賦值,count就變成區域性變數了。total也是如此。

這裡如果把count和total通過global關鍵字宣告為全域性變數,顯然是不合適的,它們作用域最多隻擴充套件到make_averager()函式內。為了解決這個問題,Python3引入了nonlocal關鍵字宣告:

def make_averager():
    count = 0
    total = 0
    
    def averager(new_value):
        nonlocal count, total
        count += 1
        total += new_value
        return total / count
        
    return averager

nonlocal的作用是把變數標記為自由變數,即使在函式中為變數賦值了,也仍然是自由變數。

注意,對於列表、字典等可變型別來說,新增元素不是賦值,不會隱式建立區域性變數。對於數字、字串、元組等不可變型別以及None來說,賦值會隱式建立區域性變數。示例:

def make_averager():
    # 可變型別
    count = {}

    def averager(new_value):
        print(count)  # 成功
        count[new_value] = new_value
        return count

    return averager

可變物件新增元素不是賦值,不會隱式建立區域性變數。

def make_averager():
    # 不可變型別
    count = 1

    def averager(new_value):
        print(count)  # 報錯
        count = new_value
        return count

    return averager

count是不可變型別,賦值會隱式建立區域性變數,報錯:區域性變數count在賦值前進行了引用。

def make_averager():
    # None
    count = None

    def averager(new_value):
        print(count)  # 報錯
        count = new_value
        return count

    return averager

count是None,賦值會隱式建立區域性變數,報錯:區域性變數count在賦值前進行了引用。

小結

本文先介紹了全域性變數、自由變數、區域性變數的概念,這是理解閉包的前提。閉包就是用來解決函式巢狀時,自由變數如何處理的問題,它會保留自由變數的繫結,即使區域性作用域已經消失。對於不可變型別和None來說,賦值會隱式建立區域性變數,把自由變數轉換為區域性變數,這可能會導致程式報錯:區域性變數在賦值前進行了引用。除了使用global宣告為全域性變數外,還可以使用nonlocal宣告把區域性變數強制變為自由變數,實現閉包。

參考資料:

《流暢的Python》

相關文章