在對Python中的閉包進行簡單分析之前,我們先了解一下Python中的作用域規則。關於Python中作用域的詳細知識,有很多的博文都進行了介紹。這裡我們先從一個簡單的例子入手。
Python中的作用域
假設在互動式命令列中定義如下的函式:
1 2 3 4 5 6 |
>>> a = 1 >>> def foo(): b = 2 c = 3 print "locals: %s" % locals() return "result: %d" % (a + b +c) |
上述程式碼先給a賦值1,緊接著定義了一個函式:foo()。在函式foo()中我們定義了兩個整數b和c,函式的返回值為a、b、c三個數的和。
對上述函式進行驗證:
1 2 3 4 |
# result >>> foo() locals: {'c': 3, 'b': 2} result: 6 |
根據驗證的結果,foo()函式的返回值為6。上述的函式定義中只有b和c兩個變數的賦值,那呼叫函式是如何判斷a的值呢?這涉及到函式的作用域規則。本文摘錄《Python參考手冊(第4版)》中的相關論述:
每次執行一個函式時, 就會建立心得區域性名稱空間。該名稱空間代表一個區域性環境,其中包含函式引數的名稱和在函式體內賦值的變數名稱。解析這些名稱時:
- 直譯器將首先搜尋區域性名稱空間;
- 如果沒有找到匹配的名稱,它就會搜尋全域性名稱空間(函式的全域性名稱空間始終是定義該函式的模組);
- 如果直譯器在全域性名稱空間中也找不到匹配值,最終會檢查內建名稱空間;
- 如果在內建名稱空間中也找不到匹配值,就會引發NameError異常。
對應於上面的例子,foo函式首先會在區域性名稱空間中找三個變數的匹配值。上述程式碼中的locals()方法給出了foo函式區域性名稱空間的內容。可以看出,區域性名稱空間是一個字典,包含b和c的值,這是因為我們在foo函式中定義了這兩個變數。然而,區域性名稱空間中不包含a的值,所以就需要在全域性名稱空間中尋找。可以使用__globals__獲取一個函式的區域性名稱空間。
1 2 3 |
# foo函式的全域性名稱空間 >>> foo.__globals__ {'a': 1, '__builtins__': <module '__builtin__' (built-in)>, '__package__': None, '__name__': '__main__', 'foo': <function foo at 0x0000000004613518>, '__doc__': None} |
foo函式的全域性名稱空間中包含了內建函式模組、foo函式、變數a以及其他的一些引數。由於在foo函式的全域性名稱空間中找到了變數a,foo函式便返回三個變數的和。
閉包
上述的Python作用域規則具有普遍性。然而,在Python中“一切皆物件”,函式也不例外。這也就是說可以把函式當作引數傳遞給其他的函式,也可以放在資料結構中,還可以作為函式的返回結果。在這種情況下,Python的作用域規則會發生什麼變化呢?我們還是舉一個例子:
1 2 3 4 5 6 7 |
>>> def foo(): a = 1 def bar(): b = 2 c = 3 return a + b + c return bar |
在這個例子中,我們定義了一個函式foo,並對變數a賦值。不過與之前的例子不同的是,在函式foo中我們還巢狀了一個函式bar,並且還定義了兩個變數,這個函式是作為函式foo的返回值。根據上面的作用域規則,函式foo的區域性作用域既不是函式bar的區域性作用域,也不是它的全域性作用域,那函式bar能否正確匹配變數a的值呢?我們我們來驗證一下這個函式是否能夠正常執行。
1 2 3 4 5 6 7 8 9 |
# 呼叫函式foo() >>> bar = foo() # 返回值bar是一個函式 >>> bar <function bar at 0x00000000045F3588> # 呼叫bar() >>> bar() # 結果顯示為三個變數之和 6 |
以上的驗證結果說明,在上述巢狀的函式中,內部函式可以正確地引用外部函式的變數,即使外部的函式已經返回。
這種內部函式的區域性作用域中可以訪問外部函式區域性作用域中變數的行為,我們稱為: 閉包。內部函式可以訪問外部函式變數的特點很像將外部函式的變數直接“打包”到內部函式中一樣,我們也可以這樣理解閉包:將組成函式的語句以及執行這些語句的環境“打包”在一起時得到的物件稱為閉包。
和閉包相關的幾個物件
為了瞭解閉包是怎麼實現內部函式對外部函式變數的引用,還需要對閉包相關的幾個物件進行介紹。關於這幾個物件會涉及到Python的底層實現,本文中對此不加以詳述,可以參考以下文章:
不過,為了直觀地說明閉包的實現過程(不分析底層實現),這裡先簡單介紹以下code物件。code物件是指程式碼物件,表示編譯成位元組的的可執行Python程式碼,或者位元組碼。它有幾個比較重要的屬性:
- co_name:函式的名稱
- co_nlocals: 函式使用的區域性變數的個數
- co_varnames: 一個包含區域性變數名字的元組
- co_cellvars: 是一個元組,包含巢狀的函式所引用的區域性變數的名字
- co_freevars: 是一個元組,儲存使用了的外層作用域中的變數名
- co_consts: 是一個包含位元組碼使用的字面量的元組
其餘屬性可以參考Python文件。
其中比較關鍵的是co_varnames和co_freevars兩個屬性。我們對上面的例子稍加修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
>>> def foo(): a = 1 b = 2 def bar(): return a + 1 def bar2(): return b + 2 return bar >>> bar = foo() # 外層函式 >>> foo.func_code.co_cellvars ('a', 'b') >>> foo.func_code.co_freevars () # 內層巢狀函式 >>> bar.func_code.co_cellvars () >>> bar.func_code.co_freevars ('a',) |
以上說明外層函式的code物件的co_cellvars儲存了內部巢狀函式需要引用的變數的名字,而內層巢狀函式的code物件的co_freevars儲存了需要引用外部函式作用域中的變數名字。具體來說,就是foo函式中巢狀了兩個函式,它們都需要引用foo函式區域性作用域中的變數,所以foo.func_code.co_cellvars便包含變數a和變數b的名稱。而函式bar是foo的返回值,只引用了變數a,因此bar.func_code.co_freevars中便只包含變數a。
內部函式和外部函式的co_freevars、co_cellvars的對應關係,使得在函式編譯過程中內部函式具有了一個閉包的特殊屬性__closure__(底層中對此有相關實現)。__closure__屬性是一個由cell物件組成的元組,包含了由多個作用域引用的變數。可以做以下驗證:
1 2 3 4 5 6 7 |
>>> foo.__closure__ #None # 內部函式bar對變數a的引用 >>> bar.__closure__ (<cell at 0x00000000044F6798: int object at 0x0000000003FA4B38>,) # 內部函式bar引用的變數a的值 >>> bar.__closure__[0].cell_contents 1 |