1. 程式人生 > 實用技巧 >認識Python中的閉包:閉包入門到自閉

認識Python中的閉包:閉包入門到自閉

本文首發於:行者AI

python中什麼是閉包?閉包有什麼用?為什麼要用閉包?今天我們就帶著這3個問題來一步一步認識閉包。

閉包和函式緊密聯絡在一起,介紹閉包前有必要先介紹一些背景知識,諸如巢狀函式、變數的作用域等概念。

1. 作用域

作用域是程式執行時變數可被訪問的範圍,定義在函式內的變數是區域性變數,區域性變數的作用範圍只能是函式內部範圍內,它不能在函式外引用。

定義在模組最外層的變數是全域性變數,它是全域性範圍內可見的,當然在函式裡面也可以讀取到全域性變數的。而在函式外部則不可以訪問區域性變數。例如:

a = 1 
def foo(): 
   print(a) # 1 
def foo(): 
    print(a) # NameError: name 'num' is not defined 

2. 巢狀函式

函式不僅可以定義在模組的最外層,還可以定義在另外一個函式的內部,像這種定義在函式裡面的函式稱之為巢狀函式(nested function)。對於巢狀函式,它可以訪問到其外層作用域中宣告的非區域性(non-local)變數,比如程式碼示例中的變數a可以被巢狀函式 printer 正常訪問。

def foo(): 
   #foo是外圍函式 
   a = 1 
   # printer是巢狀函式 
   def printer(): 
       print(a)
   printer() 
foo() # 1

那麼有沒有一種可能即使脫離了函式本身的作用範圍,區域性變數還可以被訪問得到呢?

答案就是閉包!

我們將上述函式改成高階函式(接受函式為引數,或者把函式作為結果返回的函式是高階函式)的寫法。

def foo(): 
   #foo是外圍函式 
   a = 1 
   # printer是巢狀函式 
   def printer(): 
       print(a)
   return printer
x = foo() 
x() # 1

這段程式碼和前面例子的效果完全一樣,同樣輸出 1。不同的地方在於內部函式 printer 直接作為返回值返回了。

一般情況下,函式中的區域性變數僅在函式的執行期間可用,一旦 foo() 執行過後,我們會認為變數a將不再可用。然而,在這裡我們發現 foo

執行完之後,在呼叫 x 的時候a 變數的值正常輸出了,這就是閉包的作用,閉包使得區域性變數在函式外被訪問成為可能。

3. 閉包

人們有時會把閉包和匿名函式弄混。這是有歷史原因的:在函式內部定義函式 不常見,直到開始使用匿名函式才會這樣做。而且,只有涉及巢狀函式時才有閉包問題。 因此,很多人是同時知道這兩個概念的。

其實,閉包指延伸了作用域的函式,其中包含函式定義體中引用、但是不在定義體中定義的 非全域性變數。函式是不是匿名的沒有關係,關鍵是它能訪問定義體之外定義的非全域性變數。

通俗來講閉包,顧名思義,就是一個封閉的包裹,裡面包裹著自由變數,就像在類裡面定義的屬性值一樣,自由變數的可見範圍隨同包裹,哪裡可以訪問到這個包裹,哪裡就可以訪問到這個自由變數。 那這個包裹是繫結在哪的呢?在上文程式碼追加一句列印:

  def foo():
       # foo是外圍函式
       a = 1
       # printer是巢狀函式
       def printer():
           print(a)
       return printer
x = foo()
print(x.__closure__[0].cell_contents) # 1 

可以發現是在函式物件的__closure__屬性中,__closure__是一個元祖物件函式負責閉包繫結,即自由變數的繫結。該屬性值通常是 None,如果這個函式是一個閉包的話,那麼它返回的是一個由 cell 物件組成的元組物件。cell 物件的cell_contents 屬性就是閉包中的自由變數。這解釋了為什麼區域性變數脫離函式之後,還可以在函式之外被訪問的原因的,因為它儲存在了閉包的 cell_contents中了。

4. 閉包的好處

閉包避免了使用全域性變數,此外,閉包允許將函式與其所操作的某些資料(環境)關連起來。這一點與面向物件程式設計是非常類似的,在面對象程式設計中,物件允許我們將某些資料(物件的屬性)與一個或者多個方法相關聯。

一般來說,當物件中只有一個方法時,這時使用閉包是更好的選擇。來看一個計算均值的例子,假如有個名為 avg 的函式,它的作用是計算不斷增加的系列值的均值;例如,整個歷史中 某個商品的平均收盤價。每天都會增加新價格,因此平均值要考慮至目前為止所有的價格,如下所示:

>>> avg(10) #10.0 
>>> avg(11) #10.5 
>>> avg(12) #11.0 

在以往,我們可以設計一個類:

class Averager():

def __init__(self):
   self.series = []
    
def __call__(self, new_value):
   self.series.append(new_value)
   total = sum(self.series)
   return total/len(self.series)
   
avg = Averager()
avg(10) #10.0
avg(11) #10.5
avg(12) #11.0

這時候我們使用閉包來實現。

def make_averager():
   series = []
   def averager(new_value):
       series.append(new_value)
       total = sum(series)
       return total/len(series)
   return averager

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

呼叫 make_averager 時,返回一個 averager 函式物件。每次呼叫 averager 時,它會把引數新增到列表中,然後計算當前平均值。 這比用類來實現更優雅,此外裝飾器也是基於閉包的一中應用場景。

5. 閉包的坑

看了上述閉包的解釋你以為閉包也不過如此?實際使用中往往在不經意間就會掉入陷阱,看看下面的例子:

def create_multipliers():
   return [lambda x: x * i for i in range(5)]
    
for multiplier in create_multipliers():
   print(multiplier(2))
    
# 期望輸出0, 2, 4, 6, 8
# 結果是 8, 8, 8, 8, 8

我們期望是輸出0, 2, 4, 6, 8。結果卻是 8, 8, 8, 8, 8。為什麼會出現這問題呢?讓我們改下程式碼:

def create_multipliers():
   multipliers = [lambda x: x * i for i in range(5)]
   print([m.__closure__[0].cell_contents for m in multipliers])
        
create_multipliers()  # [4, 4, 4, 4, 4] 

可以看到函式繫結的i值都成了4即迴圈後最終i的取值,這是因為Python 的閉包是延遲繫結 ,這意味著閉包中用到的變數的值,是在內部函式被呼叫時查詢得到的。

正確的使用方式是將i的值利用引數的方式進行傳遞:

def create_multipliers():
   return [lambda x,i=i: x * i for i in range(5)]
 
s = create_multipliers()
for multiplier in s:
   print(multiplier(2))  # 0, 2, 4, 6, 8

我們利用預設引數來傳遞i,同閉包一樣預設引數是繫結在__defaults__屬性上。

print([f.__defaults__ for f in s]) # [(0,), (1,), (2,), (3,), (4,)] 

PS:更多技術乾貨,快關注【公眾號 | xingzhe_ai】,與行者一起討論吧!