閉包漫談(從抽象代數及函數式編程角度)
如果Google一下“閉包”這個詞,會發現網上關于閉包的文章已經不計其數,甚至很多人將閉包看做面試JavaScript程序員的必考題(雖然閉包和JavaScript沒有什么必然聯系)。既然如此,我為什么還要寫一篇關于閉包的文章呢?
首先,雖然網上關于閉包的文章甚多,但是很少以較為形式化的角度闡述閉包,而我認為理解閉包的關鍵之一就是從形式化角度理解其涵義;其次,大多數文章將閉包的概念與JavaScript語言綁定太死,這樣容易局限對閉包概念的理解,而難以窺探到其本質。從JavaScript去理解閉包,個人認為這是本末倒置的,應該先理解純粹意義上的閉包,然后再理解JavaScript中的閉包機制。
基于以上情況,本文將從較為形式化的角度闡述閉包概念,當做對現有網絡的資料的一個補充。
一個需要明確的重要事實
在開始闡述閉包之前,我需要特別明確一個非常重要的事實,那就是“閉包(closure)”一詞被用于定義兩個毫不相關的概念,分別是數學領域抽象代數分支下用于描述集合之于運算的一種性質以及計算機科學程序設計語言分支用于描述函數式語言所支持的一種機制。這種不同大約就如“電影《人猿泰山》”和“五岳之尊泰山”中的“泰山”差不多,兩個短語中的“泰山”描述的是兩個風馬牛不相及的概念,雖然是同一個詞。
一般來說,作為程序員我們說的閉包更多是指后者,但是如果你與我一樣同時具有一點數學背景,第一次接觸“閉包”一詞是在抽象數學中的話,那么當剛接觸到計算機中的“閉包”時多少會產生困惑,同樣,如果你是一個純粹的程序員,那么當在數學著作中讀到“閉包”一詞時請小心區分這個“閉包”具體是表述哪一個概念。
我會在下文中分別闡述數學領域和計算機科學領域閉包的概念。
抽象代數中的閉包
抽象代數是一門研究代數結構的數學分支,它的研究對象包括群、環、域和向量空間等等。當然我在這里絲毫沒有要大談特談這些令人頭大的概念的意思,我會盡量以一種易懂的半形式化方式去闡述一些概念。
集合的定義
非正式地,集合是N個對象組成的一個無序、互異且確定的整體。其中N是自然數。這些對象稱為集合的元素。
無序性是指集合中的元素不存在序關系(集合上可以定義序關系,但這些序關系不是集合本身的一部分),每個元素的地位是相同的。
互異性是指集合中任意兩個元素是不同的,即同一集合中任意兩個元素間不存在等價關系。
確定是指對任意一個對象和任意一個集合,這個對象要么屬于此集合,要么不屬于此集合,二者必居其一,不存在模棱兩可的狀態(模糊數學中有一種中間隸屬狀態,本文不考慮模糊數學領域)。
運算的定義
非正式地,集合上的n元運算是一個映射,這個映射將作用于任意n個集合中的元素,并映射為一個結果(注意結果不一定屬于這個集合)。
例如,設N+是正整數集合,那么加法(+)和減法(−)都可以看做定義在N+上的二元運算,因為任意兩個正整數都可以進行加減。
封閉的定義
有了集合和運算的概念,就可以定義封閉的概念了。
非正式地,如果定義于集合A上的運算⊕的運算結果仍然屬于A,那么運算⊕對于集合A是封閉的。下面給出“封閉”的一個半形式化定義:
如果對于任意a1,a2∈A,有a1⊕a2∈A,那么說二元運算⊕對于集合A是封閉的。
例如“+”對于N+是封閉的,因為任意兩個正整數的和結果仍然是正整數;但是“−”對于N+不是封閉的,例如3和5屬于N+,但是:3−5=−2∉N+。
閉包性質
一個集合滿足閉包性質當且僅當這個集合在某個運算或某些運算的搜集下是封閉的,其中“某些運算的搜集下封閉”是指這個集合單獨閉合在每個運算之下。
值得一提的是,之前這條定義往往被作為一條公理引入一個代數結構,叫做“閉包公理”。但是現代集合論往往將運算形式化的定義為集合間的運算,所以將其作為公理引入代數結構是多余的(因為可以通過其它公理間接定義閉包公理),但是對于子集是否閉合的問題,閉包公理仍然有意義。
一個例子 - 存在于形式語言與自動機理論中的閉包
上面說了很多東西,我們來看一個抽象代數領域閉包的例子。
我們回想在形式語言與自動機理論中(或者編譯原理中也有提到)在定義語言時做的一些推導。
一般我們會先定義一個有限集合Σ,叫做字母表,Σ的n階冪運算定義為形成一個新的集合Σn,一個對象屬于Σn當且僅當它是Σ中任意n個字母連接成的串,其中Σ0={ε}。而
Σ∗=Σ0∪Σ1∪...∪Σn∪...
此時集合Σ∗滿足閉包性質,因為這個集合對于冪運算是封閉的。有興趣的讀者可以自行證明一下。
函數式編程中的閉包
在這一章節開始之前,我需要再和大家明確一個比較糾結的事實,就是在函數式編程領域中當說到“閉包”時,也有可能是指數學領域中閉包的概念,這是因為函數式編程在基礎理論與抽象代數有一定親緣性,所以當在函數式語言著作中討論“閉包”時,有可能是在抽象數學的上下文中討論的。然而,在表述上可能會有微妙變化。在函數式語言領域對于數學閉包常用的表述是“如果一個運算的結果仍然能被此運算作用,則這個運算是封閉的”,要注意這只不過是上文提到的“閉包”概念的另一種等價表述而已,如果我們將這個運算的所有結果看做一個集合,那么就可以等價表述說這個運算在這個集合上是封閉的。
而我下面所要闡述的閉包是一種截然不同的概念。所以,當在函數式語言的著作中看到“閉包”時,需要根據上下文環境小心區分其表述哪種概念。
Lambda演算與自由變量
函數式編程語言的基礎是lambda演算,這是一套用于研究函數定義、應用和遞歸的形式系統,由數學家丘奇在20世紀30年代引入。如果您不太熟悉lambda演算,那么維基百科相關頁面是很好的快速入門資料,請原諒我不會完整描述lambda演算(因為已經有很多可以參考的資料)。
簡單來說lambda演算將計算過程看過一系列的函數代換,例如,下面是加運算的lambda函數(假設+運算已經定義):
λx.λy.x+y
lambda演算就是反復將函數應用于實際值,并用實際值代替參數,最終得出結果。例如下面是7+2的計算過程:
(λx.λy.x+y)72⇒(λy.7+y)2⇒7+2⇒9
首先用第一個參數(7)代替最外層函數的參數(x),然后用第二個參數(2)代替第二層函數的參數(y),最終得到計算結果。
鑒于如果下面大量使用lambda演算描述問題大家可能會崩潰(我也會崩潰),我將改用函數式語言scheme(Lisp的一個方言)來進行問題描述。注意其實scheme在本質上與lambda演算是等價的,只是看起來更好懂,例如不需要遵循lambda演算一個變態的規定:每個函數只允許有一個參數(雖然任何多參數函數式程序都可以通過Currying過程化歸為等價的lambda演算)。
下面是用scheme程序對上述lambda演算的等價表示:
(define (f x y) (+ x y))
可以這樣計算7+2:
(f 7 2);Value: 9
下面看一個稍微復雜點的例子:
(define (f x) (lambda (y) (+ x y)))
這里定義了函數f,接受一個參數x,特別要注意它的返回值:不是一個值而是一個匿名函數。如果我們把這個函數單獨拿出來:
(lambda (y) (+ x y))
可以看到,這個匿名函數接收一個參數y,但是卻沒有參數x!也就是說,如果脫離上下文執行這個函數,則x處于未指定狀態,我們說對于這個函數,y是綁定的,而x是自由的。
一般地:x是一個函數的函數體中的變量,如果x被這個函數的參數指定,則x是綁定于這個函數的,否則說x對于此函數是自由的。
下面可以看到,變量的綁定和自由概念是理解閉包本質的一把鑰匙。
程序語言的閉包性質
繼續上面的scheme程序,我們已經定義了函數f:
(define (f x) (lambda (y) (+ x y)))
如果我們運行下面程序:
(f 7);Value 13: #[compound-procedure 13]
可以看到,f返回了一個過程(匿名函數),按照函數演算規則,這個函數應該是:
(lambda (y) (+ 7 y))
那么下面的運算就很直觀了:
((f 7) 2);Value: 9
注意這里有一個非常重要的地方(也是閉包性質的關鍵),那就是這個運算執行了兩個函數:f和匿名函數。而f的作用域為(f 7),這就是說,其實在(f 7)之后,f這個函數就結束了,而x(這里被賦值為7)是f的私有變量(綁定于f),那么程序設計語言的設計者就有兩種選擇:第一,在函數超出其作用域后立即銷毀其綁定變量,如果是這樣的話,((f 7) 2) 是無法得出結果的,因為在外層的f運算結束后,存放數值“7”的變量就被釋放了,所以匿名函數無法得到其自由變量x的值;顯然scheme的設計者做了第二種選擇:如果一個函數返回另一個函數,而被返回函數又需要外層函數的變量時,不會立即釋放這個變量,而是允許被返回的函數引用這些變量。支持這種機制的語言稱為支持閉包機制,而這個內部函數連同其自由變量就形成了一個閉包。
上面的這段話不太好理解,但是請務必多讀幾遍,因為,這就是閉包的全部。
從Scheme到JavaScript
好的,現在開始討論JavaScript中的閉包。
上文說過,閉包是函數式語言才有的機制,或者說支持函數式編程泛型的語言才有的性質。一個語言支持函數式編程泛型,如果它同時具有下列特性:
可以將一個函數賦值給一個變量。
函數可以作為參數傳遞給另一個函數。
函數的返回值可以是一個函數。
結合上面關于閉包性質的定義,就很清楚為什么只有支持函數編程泛型的語言才可以談閉包性質。
很顯然,JavaScript是具有上述三條性質的,所以可以說JavaScript擁有函數式編程泛型。當然,一般我們還是習慣于用命令式的寫JavaScript,但是其閉包本質是一樣的。為了說明這一點,我將會首先用JavaScript按照函數式泛型重寫上面的scheme程序,然后轉為命令式。
上文用scheme所寫的函數f,可以用JavaScript等價實現如下:
function f(x){ return function(y) { return x + y; }; }
可以執行與上述scheme類似的計算(因為脫離了瀏覽器,我是用nodejs執行這段JavaScript):
console.log( (f(7)) (2) ); //9
其中f返回的匿名函數與其自由變量x組成了一個閉包系統。
如果用命令式重寫上面的程序,就得到了我們熟悉的閉包:
function f(x{ return function(y) { return x + y; }; } var lam = f(7); console.log(lam(2));
總結
本文分別討論了抽象代數和函數式編程中兩個截然不同的閉包概念,當然,作為程序員我們更關心的是后者(但前者也不是對程序員一點用也沒有,如果學習函數式語言的構造原理,抽象代數中的閉包也是必須理解的概念)。
實際上,閉包遠沒有很多人想象的復雜和神秘,只不過是函數式編程中一個普通的概念,但是可能因為我們大多數人是從命令式編程語言學習編程,很少去寫函數式程序,而要理解閉包卻是需要結合函數式編程泛型,因此很難看清閉包的本質。希望本文能對您有所幫助,同時我個人也建議可以學習一門函數式語言,這樣對很多概念的理解非常有好處。