在前面的內容中,我們討論了全局變量和局部變量的作用域,也討論了嵌套函數的作用域,並了解了局部變量或嵌套函數僅限於在函數體內使用。但在一些情況下,可以將函數內部的嵌套函數引入到全局環境中使用,Python將引入到全局環境中使用的嵌套函數及其環境變量構建成一個封閉的包,該包內的環境變量不受外部環境的影響,這就是我們將要討論的閉包。
前面我們了解了嵌套函數的作用域僅限於其父函數體內,如果在父函數體外調用其嵌套的函數,就會超出嵌套函數的作用域。
上面的代碼定義了line_conf函數,在line_conf函數體內有嵌套定義了計算直線方程的函數line。依據前面學過的知識,我們會推斷出在line_conf函數體內調用line函數是合法的,但在line_conf函數體外調用line函數是非法的,因為在line_conf函數體外調用line函數已經超越了line函數的作用域。上面代碼的執行結果也驗證了我們的推斷。
在Python語言中,當父函數體內定義了嵌套函數後,父函數可以把定義的嵌套函數作為嵌套函數的引用返回給調用者。
函數的引用是什麼呢?當我們定義一個函數時,不管是父函數還是父函數體內的嵌套函數。Python解釋器都會為定義的函數分配內存空間,用於存儲函數的代碼、使用的變量等等,該內存的地址被賦值給函數名稱所標識的存儲單元,函數調用者可以通過函數名稱所標識的存儲單元找到函數的內存地址,並執行該函數。
由此看來,函數名稱也是一個變量,它存儲了函數的內存地址。函數的內存地址既能賦值給函數名稱,也可以通過函數名稱賦值給其它變量,只不過其它變量存儲的不是函數的直接內存地址,而是函數名稱的內存地址。例如前面代碼定義的line函數,可以把line函數名稱的地址賦值給變量a和b,執行a、b、line的效果都是一樣的。
上面的代碼把line函數名稱分別賦值給局部變量a和b,分別執行line(5)、a(5)、b(5),其執行結果是相同的。由此可以證明,變量a和b與line指向同一個函數。在這種情況下,我們說a和b是line函數的引用。
理解了什麼是函數的引用後,我們就很容易理解把函數作為一個引用返回給調用者了。當父函數把父函數體內的嵌套函數名稱返回給調用者時,實際上是把嵌套函數名稱的內存地址返回給調用者,調用者將返回的函數名稱內存地址賦值給接收變量,調用者通過接收的變量就可以執行接收變量所引用的函數。
上面的代碼把line_conf函數內部定義的line嵌套函數返回給調用者,調用者將line嵌套函數的內存地址賦值給my_line變量,最後執行my_line所引用的line函數。執行結果如下圖所示。
現在我們已經實現了把父函數中的嵌套函數引入到全局環境中,嵌套函數可以在父函數體外被調用。這已經是閉包的概念了,嵌套函數作為一個獨立的執行環境被外部引用,此時被外部環境獨立引用的嵌套函數已經脫離了父函數體,構成一個獨立的運行環境。
我們都知道,局部變量在函數執行完成後就被銷毀了。那麼,如果在line函數中使用了line_conf的變量,當line_conf函數執行完成後,返回到全局環境的line函數還能使用line_conf的中的變量嗎?
在上面的代碼中,line函數使用了其父函數聲明的變量b,變量b在line函數的定義之外,此時b為line的環境變量。當line函數作為line_conf函數的返回值時,變量b的取值已經和line函數綁定在一起,也就是說父函數和line函數綁定的變量b的值已經沒有關係了,變量b即使再有變化,也不會影響到line函數的計算結果。在這種情況下,我們說line函數和它的環境變量b構成了一個閉包,閉包是一個獨立的運行環境,不受外部環境的影響和約束。上面的代碼輸出結果為25,而不是15。
下面是簡單使用閉包的例子,模擬一個計數器。
上面的代碼定義了counter函數,它所做的事情就是接收一個初始值並開始計數,初始值賦值給列表count的一個成員。然後在內部定義一個嵌套函數incr,incr使用了父函數的局部變量count,count也是incr函數的環境變量,這樣就創建了一個閉包,因為incr函數包含了counter函數中局部變量的作用域。下圖是上面代碼執行後的輸出結果。
閉包可以將函數和它需要的數據關聯起來而不受外部影響。從這點來看,閉包和面向對象基本類似,面向對象也是將數據和行為封裝成一個類。在一些情況下使用類也許是最好的方式,閉包更適合函數式編程,函數式編程我們將放在後面討論。