1. 程式人生 > >理解Python閉包概念

理解Python閉包概念

閉包並不只是一個python中的概念,在函數語言程式設計語言中應用較為廣泛。理解python中的閉包一方面是能夠正確的使用閉包,另一方面可以好好體會和思考閉包的設計思想。

1.概念介紹

首先看一下維基上對閉包的解釋:

在電腦科學中,閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函式閉包(function closures),是引用了自由變數的函式。這個被引用的自由變數將和這個函式一同存在,即使已經離開了創造它的環境也不例外。所以,有另一種說法認為閉包是由函式和與其相關的引用環境組合而成的實體。閉包在執行時可以有多個例項,不同的引用環境和相同的函式組合可以產生不同的例項。

簡單來說就是一個函式定義中引用了函式外定義的變數,並且該函式可以在其定義環境外被執行。這樣的一個函式我們稱之為閉包。實際上閉包可以看做一種更加廣義的函式概念。因為其已經不再是傳統意義上定義的函式。

根據我們對程式語言中函式的理解,大概印象中的函式是這樣的:

程式被載入到記憶體執行時,函式定義的程式碼被存放在程式碼段中。函式被呼叫時,會在棧上建立其執行環境,也就是初始化其中定義的變數和外部傳入的形參以便函式進行下一步的執行操作。當函式執行完成並返回函式結果後,函式棧幀便會被銷燬掉。函式中的臨時變數以及儲存的中間計算結果都不會保留。下次呼叫時唯一發生變化的就是函式傳入的形參可能會不一樣。函式棧幀會重新初始化函式的執行環境。

C++中有static關鍵字,函式中的static關鍵字定義的變數獨立於函式之外,而且會保留函式中值的變化。函式中使用的全域性變數也有類似的性質。

但是閉包中引用的函式定義之外的變數是否可以這麼理解呢?但是如果函式中引用的變數既不是全域性的,也不是靜態的(python中沒有這個概念)。應該怎麼正確的理解呢?

2.閉包初探

為了說明閉包中引用的變數的性質,可以看一下下面的這個例子:

 1 def outer_func():
 2     loc_list = []
 3     def inner_func(name):
 4         loc_list.append(len(loc_list) + 1)
5 print '%s loc_list = %s' %(name, loc_list) 6 return inner_func 7 8 clo_func_0 = outer_func() 9 clo_func_0('clo_func_0') 10 clo_func_0('clo_func_0') 11 clo_func_0('clo_func_0') 12 clo_func_1 = outer_func() 13 clo_func_1('clo_func_1') 14 clo_func_0('clo_func_0') 15 clo_func_1('clo_func_1')

程式的執行結果:

clo_func_0 loc_list = [1]clo_func_0 loc_list = [1, 2]clo_func_0 loc_list = [1, 2, 3]clo_func_1 loc_list = [1]clo_func_0 loc_list = [1, 2, 3, 4]clo_func_1 loc_list = [1, 2]

從上面這個簡單的例子應該對閉包有一個直觀的理解了。執行的結果也說明了閉包函式中引用的父函式中local variable既不具有C++中的全域性變數的性質也沒有static變數的行為。

在python中我們稱上面的這個loc_list為閉包函式inner_func的一個自由變數(free variable)。

If a name is bound in a block, it is a local variable of that block. If a name is bound at the module level, it is a global variable. (The variables of the module code block are local and global.) If a variable is used in a code block but not defined there, it is a free variable.

在這個例子中我們至少可以對閉包中引用的自由變數有如下的認識:

  • 閉包中的引用的自由變數只和具體的閉包有關聯,閉包的每個例項引用的自由變數互不干擾。
  • 一個閉包例項對其自由變數的修改會被傳遞到下一次該閉包例項的呼叫。

由於這個概念理解起來並不是那麼的直觀,因此使用的時候很容易掉進陷阱。

3.閉包陷阱

下面先來看一個例子:

 1 def my_func(*args):
 2     fs = []
 3     for i in xrange(3):
 4         def func():
 5             return i * i
 6         fs.append(func)
 7     return fs
 8 
 9 fs1, fs2, fs3 = my_func()
10 print fs1()
11 print fs2()
12 print fs3()

 上面這段程式碼可謂是典型的錯誤使用閉包的例子。程式的結果並不是我們想象的結果0,1,4。實際結果全部是4。

這個例子中,my_func返回的並不是一個閉包函式,而是一個包含三個閉包函式的一個list。這個例子中比較特殊的地方就是返回的所有閉包函式均引用父函式中定義的同一個自由變數。

但這裡的問題是為什麼for迴圈中的變數變化會影響到所有的閉包函式?尤其是我們上面剛剛介紹的例子中明明說明了同一閉包的不同例項中引用的自由變數互相沒有影響的。而且這個觀點也絕對的正確。

那麼問題到底出在哪裡?應該怎樣正確的分析這個錯誤的根源。

其實問題的關鍵就在於在返回閉包列表fs之前for迴圈的變數的值已經發生改變了,而且這個改變會影響到所有引用它的內部定義的函式。因為在函式my_func返回前其內部定義的函式並不是閉包函式,只是一個內部定義的函式。

當然這個內部函式引用的父函式中定義的變數也不是自由變數,而只是當前block中的一個local variable。

1 def my_func(*args):
2     fs = []
3     j = 0
4     for i in xrange(3):
5         def func():
6             return j * j
7         fs.append(func)
8     j = 2
9     return fs

 上面的這段程式碼邏輯上與之前的例子是等價的。這裡或許更好理解一點,因為在內部定義的函式func實際執行前,對區域性變數j的任何改變均會影響到函式func的執行結果。

函式my_func一旦返回,那麼內部定義的函式func便是一個閉包,其中引用的變數j成為一個只和具體閉包相關的自由變數。後面會分析,這個自由變數存放在Cell物件中。

使用lambda表示式重寫這個例子:

1 def my_func(*args):
2     fs = []
3     for i in xrange(3):
4         func = lambda : i * i
5         fs.append(func)
6     return fs

經過上面的分析,我們得出下面一個重要的經驗:返回閉包中不要引用任何迴圈變數,或者後續會發生變化的變數。

這條規則本質上是在返回閉包前,閉包中引用的父函式中定義變數的值可能會發生不是我們期望的變化。

正確的寫法

1 def my_func(*args):
2     fs = []
3     for i in xrange(3):
4         def func(_i = i):
5             return _i * _i
6         fs.append(func)
7     return fs

或者:

1 def my_func(*args):
2     fs = []
3     for i in xrange(3):
4         func = lambda _i = i : _i * _i
5         fs.append(func)
6     return fs

正確的做法便是將父函式的local variable賦值給函式的形參。函式定義時,對形參的不同賦值會保留在當前函式定義中,不會對其他函式有影響。

另外注意一點,如果返回的函式中沒有引用父函式中定義的local variable,那麼返回的函式不是閉包函式。

4.閉包的應用

自由變元可以記錄閉包函式被呼叫的資訊,以及閉包函式的一些計算結果中間值。而且被自由變數記錄的值,在下次呼叫閉包函式時依舊有效。

根據閉包函式中引用的自由變數的一些特性,閉包的應用場景還是比較廣泛的。後面會有文章介紹其應用場景之一——單例模式,限於篇幅,此處以裝飾器為例介紹一下閉包的應用。

如果我們想對一個函式或者類進行修改重定義,最簡單的方法就是直接修改其定義。但是這種做法的缺點也是顯而易見的:

  • 可能看不到函式或者類的定義
  • 會破壞原來的定義,導致原來對類的引用不相容
  • 如果多人想在原來的基礎上定製自己函式,很容易衝突

 使用閉包可以相對簡單的解決上面的問題,下面看一個例子:

 1 def func_dec(func):
 2     def wrapper(*args):
 3         if len(args) == 2:
 4             func(*args)
 5         else:
 6             print 'Error! Arguments = %s'%list(args)
 7     return wrapper
 8 
 9 @func_dec
10 def add_sum(*args):
11     print sum(args)
12 
13 # add_sum = func_dec(add_sum)
14 args = range(1,4)
15 add_sum(*args)

 對於上面的這個例子,並沒有破壞add_sum函式的定義,只不過是對其進行了一層簡單的封裝。如果看不到函式的定義,也可以對函式物件進行封裝,達到相同的效果(即上面註釋掉的13行),而且裝飾器是可以疊加使用的。

4.1 潛在的問題

但閉包的缺點也是很明顯的,那就是經過裝飾器裝飾的函式或者類不再是原來的函式或者類了。這也是使用裝飾器改變函式或者類的行為與直接修改定義最根本的差別。

實際應用的時候一定要注意這一點,下面看一個使用裝飾器導致的一個很隱蔽的問題。

 1 def counter(cls):
 2     obj_list = []
 3     def wrapper(*args, **kwargs):
 4         new_obj = cls(*args, **kwargs)
 5         obj_list.append(new_obj)
 6         print "class:%s'object number is %d" % (cls.__name__, len(obj_list))
 7         return new_obj
 8     return wrapper
 9 
10 @counter
11 class my_cls(object):
12     STATIC_MEM = 'This is a static member of my_cls'
13     def __init__(self, *args, **kwargs):
14         print self, args, kwargs
15         print my_cls.STATIC_MEM

  這個例子中我們嘗試使用裝飾器來統計一個類建立的物件數量。當我們建立my_cls的物件時,會發現something is wrong!

Traceback (most recent call last):
  File "G:\Cnblogs\Alpha Panda\Main.py", line 360, in <module>
    my_cls(1,2, key = 'shijun')
  File "G:\Cnblogs\Alpha Panda\Main.py", line 347, in wrapper
    new_obj = cls(*args, **kwargs)
  File "G:\Cnblogs\Alpha Panda\Main.py", line 358, in __init__
    print my_cls.STATIC_MEM
AttributeError: 'function' object has no attribute 'STATIC_MEM'

 如果對裝飾器不是特別的瞭解,可能會對這個錯誤感到詫異。經過裝飾器修飾後,我們定義的類my_cls已經成為一個函式。

my_cls.__name__ == 'wrapper' and type(my_cls) is types.FunctionType

 my_cls被裝飾器counter修飾,等價於 my_cls = counter(my_cls)

顯然在上面的例子中,my_cls.STATIC_MEM是錯誤的,正確的用法是self.STATIC_MEM。

物件中找不到屬性的話,會到類空間中尋找,因此被裝飾器修飾的類的靜態屬性是可以通過其物件進行訪問的。雖然my_cls已經不是類,但是其呼叫返回的值卻是被裝飾之前的類的物件。

該問題同樣適用於staticmethod。那麼有沒有方法得到原來的類呢?當然可以,my_cls().__class__便是被裝飾之前的類的定義。

5.閉包的實現

本著會用加理解的原則,可以從應用層的角度來稍微深入的理解一下閉包的實現。畢竟要先會用python麼,如果一切都從原始碼中學習,那成本的確有點高。

 1 def outer_func():
 2     loc_var = "local variable"
 3     def inner_func():
 4         return loc_var
 5     return inner_func
 6 
 7 import dis
 8 dis.dis(outer_func)
 9 clo_func = outer_func()
10 print clo_func()
11 dis.dis(clo_func)

 為了更加清楚理解上述過程,我們先嚐試給出outer_func.func_code中的部分屬性:

  • outer_func.func_code.co_consts: (None, 'local variable', <code object inner_func at 025F7770, file "G:\Cnblogs\Alpha Panda\Main.py", line 207>)
  • outer_func.func_code.co_cellvars:('loc_var',)
  • outer_func.func_code.co_varnames:('inner_func',)

嘗試反彙編上面這個簡單清晰的閉包例子,得到下面的結果:

2            0 LOAD_CONST               1 ('local variable')   # 將outer_func.func_code.co_consts[1]放到棧頂
             3 STORE_DEREF              0 (loc_var)        # 將棧頂元素存放到cell物件的slot 0 

3            6 LOAD_CLOSURE             0 (loc_var)        # 將outer_func.func_code.co_cellvars[0]物件的索引放到棧頂
             9 BUILD_TUPLE              1              # 將棧頂1個元素取出,建立元組並將元組壓入棧中
             12 LOAD_CONST              2 (<code object inner_func at 02597770, file "G:\Cnblogs\Alpha Panda\Main.py", line 207>) # 將outer_func.func_code.co_consts[2]放到棧頂
             15 MAKE_CLOSURE            0              # 建立閉包,此時棧頂是閉包函式程式碼段的入口,棧頂下面則是函式的free variables,也就是本例中的'local variable ',將閉包壓入棧頂
             18 STORE_FAST              0 (inner_func)       # 將棧頂存放入outer_func.func_code.co_varnames[0]

5            21 LOAD_FAST               0 (inner_func)       # 將outer_func.func_code.co_varnames[0]的引用放入棧頂
             24 RETURN_VALUE                       # Returns with TOS to the caller of the function.
local variable
4            0 LOAD_DEREF               0 (loc_var)         # 將cell物件中的slot 0物件的引用壓入棧頂
             3 RETURN_VALUE                          # Returns with TOS to the caller of the function 

這個結果中,我們反彙編了外層函式及其返回的閉包函式(為了便於檢視,修改了部分行號)。從對上面兩個函式的反彙編的註釋可以大致瞭解閉包實現的步驟。

python閉包中引用的自由變數實際存放在一個Cell物件中,當自由變元被閉包引用時,便將Cell中存放的自由變數的引用放入棧頂。

閉包實現的一個關鍵的地方是Cell Object,下面是官方給出的解釋:

“Cell” objects are used to implement variables referenced by multiple scopes. For each such variable, a cell object is created to store the value; the local variables of each stack frame that references the value contains a reference to the cells from outer scopes which also use that variable. When the value is accessed, the value contained in the cell is used instead of the cell object itself. This de-referencing of the cell object requires support from the generated byte-code; these are not automatically de-referenced when accessed. Cell objects are not likely to be useful elsewhere.

好了,限於篇幅就先介紹到這裡。重要的是理解的基礎上靈活的應用解決實際的問題並避免陷阱,希望本文能讓你對閉包有一個不一樣的認識。