隨著硬件性能的提升以及編譯技術和虛擬機技術的改進,一些曾被性能問題所限制的動態語言開始受到關注,Python、Ruby 和 Lua 等語言都開始在應用中嶄露頭角。動態語言因其方便快捷的開發方式成為很多人喜愛的編程語言,伴隨動態語言的流行,我們經常聽到一個名詞——閉包,很多人會問閉包是什么?閉包是用來做什么的?本文匯集了有關閉包的概念、應用及其在一些編程語言中的表現形式,以供參考。
什么是閉包?
閉包并不是什么新奇的概念,它早在高級語言開始發展的年代就產生了。閉包(Closure)是詞法閉包(Lexical Closure)的簡稱。對閉包的具體定義有很多種說法,這些說法大體可以分為兩類:
- 一種說法認為閉包是符合一定條件的函數,比如參考資源中這樣定義閉包:閉包是在其詞法上下文中引用了自由變量(注 1)的函數。
- 另一種說法認為閉包是由函數和與其相關的引用環境組合而成的實體。比如參考資源中就有這樣的的定義:在實現深約束(注 2)時,需要創建一個能顯式表示引用環境的東西,并將它與相關的子程序捆綁在一起,這樣捆綁起來的整體被稱為閉包。
這兩種定義在某種意義上是對立的,一個認為閉包是函數,另一個認為閉包是函數和引用環境組成的整體。雖然有些咬文嚼字,但可以肯定第二種說法更確切。閉包只是在形式和表現上像函數,但實際上不是函數。函數是一些可執行的代碼,這些代碼在函數被定義后就確定了,不會在執行時發生變化,所以一個函數只有一個實例。閉包在運行時可以有多個實例,不同的引用環境和相同的函數組合可以產生不同的實例。所謂引用環境是指在程序執行中的某個點所有處于活躍狀態的約束所組成的集合。其中的約束是指一個變量的名字和其所代表的對象之間的聯系。那么為什么要把引用環境與函數組合起來呢?這主要是因為在支持嵌套作用域的語言中,有時不能簡單直接地確定函數的引用環境。這樣的語言一般具有這樣的特性:
- 函數是一階值(First-class value),即函數可以作為另一個函數的返回值或參數,還可以作為一個變量的值。
- 函數可以嵌套定義,即在一個函數內部可以定義另一個函數。
這些概念上的解釋很難理解,顯然一個實際的例子更能說明問題。Lua 語言的語法比較接近偽代碼,我們來看一段 Lua 的代碼:
清單 1. 閉包示例1
function make_counter()
local count = 0
function inc_count()
count = count + 1
return count
|
end
return inc_countendc1 = make_counter()c2 = make_counter()print(c1())print(c2())
在這段程序中,函數 inc_count 定義在函數 make_counter 內部,并作為 make_counter 的返回值。變量 count 不是 inc_count 內的局部變量,按照最內嵌套作用域的規則,inc_count 中的 count 引用的是外層函數中的局部變量 count。接下來的代碼中兩次調用 make_counter() ,并把返回值分別賦值給 c1 和 c2 ,然后又依次打印調用 c1 和 c2 所得到的返回值。
這里存在一個問題,當調用 make_counter 時,在其執行上下文中生成了局部變量 count 的實例,所以函數 inc_count 中的 count 引用的就是這個實例。但是 inc_count 并沒有在此時被執行,而是作為返回值返回。當 make_counter 返回后,其執行上下文將失效,count 實例的生命周期也就結束了,在后面對 c1 和 c2 調用實際是對 inc_count 的調用,而此處并不在 count 的作用域中,這看起來是無法正確執行的。
上面的例子說明了把函數作為返回值時需要面對的問題。當把函數作為參數時,也存在相似的問題。下面的例子演示了把函數作為參數的情況。
清單 2. 閉包示例2
function do10times(fn)
for i = 0,9 do
fn(i)
end
end
sum = 0
function addsum(i)
sum = sum + i
end
do10times(addsum)
print(sum)
|
這里我們看到,函數 addsum 被傳遞給函數 do10times,被并在 do10times 中被調用10次。不難看出 addsum 實際的執行點在 do10times 內部,它要訪問非局部變量 sum,而 do10times 并不在 sum 的作用域內。這看起來也是無法正常執行的。
這兩種情況所面臨的問題實質是相同的。在這樣的語言中,如果按照作用域規則在執行時確定一個函數的引用環境,那么這個引用環境可能和函數定義時不同。要想使這兩段程序正常執行,一個簡單的辦法是在函數定義時捕獲當時的引用環境,并與函數代碼組合成一個整體。當把這個整體當作函數調用時,先把其中的引用環境覆蓋到當前的引用環境上,然后執行具體代碼,并在調用結束后恢復原來的引用環境。這樣就保證了函數定義和執行時的引用環境是相同的。這種由引用環境與函數代碼組成的實體就是閉包。當然如果編譯器或解釋器能夠確定一個函數在定義和運行時的引用環境是相同的(注 3),那就沒有必要把引用環境和代碼組合起來了,這時只需要傳遞普通的函數就可以了。現在可以得出這樣的結論:閉包不是函數,只是行為和函數相似,不是所有被傳遞的函數都需要轉化為閉包,只有引用環境可能發生變化的函數才需要這樣做。
再次觀察上面兩個例子會發現,代碼中并沒有通過名字來調用函數 inc_count 和 addsum,所以他們根本不需要名字。以第一段代碼為例,它可以重寫成下面這樣:
清單 3. 閉包示例3
function make_counter()
local count = 0
return function()
count = count + 1
return count
end
end
c1 = make_counter()
c2 = make_counter()
print(c1())
print(c2())
|
這里使用了匿名函數。使用匿名函數能使代碼得到簡化,同時我們也不必挖空心思地去給一個不需要名字的函數取名字了。
上面簡單地介紹了閉包的原理,更多的閉包相關的概念和理論請參考參考資源中的"名字,作用域和約束"一章。
一個編程語言需要哪些特性來支持閉包呢,下面列出一些比較重要的條件:
- 函數是一階值;
- 函數可以嵌套定義;
- 可以捕獲引用環境,并
- 把引用環境和函數代碼組成一個可調用的實體;
- 允許定義匿名函數;
這些條件并不是必要的,但具備這些條件能說明一個編程語言對閉包的支持較為完善。另外需要注意,有些語言使用與函數定義不同的語法來定義這種能被傳遞的"函數",如 Ruby 中的 Block。這實際上是語法糖,只是為了更容易定義匿名函數而已,本質上沒有區別。
借用一個非常好的說法來做個總結(注 4):對象是附有行為的數據,而閉包是附有數據的行為。
閉包的表現形式
雖然建立在相似的思想之上,各種語言所實現的閉包卻有著不同的表現形式,下面我們來看一下閉包在一些常用語言中的表現形式。
JavaScript 中的閉包
JavaScript(ECMAScript)不是通用編程語言,但卻擁有較大的用戶群體,而 Ajax 的流行也使更多的人關注 JavaScript。雖然在進行 DOM 操作時容易引發循環引用問題,但 JavaScript 語言本身對閉包的支持還是很好的,下面是一個簡單的例子:
清單 4. JavaScript
function addx(x) {
return function(y) {return x+y;};
}
add8 = addx(8);
add9 = addx(9);
alert(add8(100));
alert(add9(100));
|
Ruby 中的閉包
隨著 Ruby on Rails 的走紅,Ruby 無疑是時下炙手可熱的語言之一,Ruby 吸取了很多其他語言的優點,是非常優秀的語言,從這一點來看,很難說清是 Rails 成就了 Ruby 還是 Ruby 成就了 Rails。
Ruby 使用 Block 來定義閉包,Block 在 Ruby 中十分重要,幾乎到處都可以看到它的身影,下面的代碼就展示了一個 Block:
清單 5. Ruby
sum = 0
10.times{|n| sum += n}
print sum
|
10.times 表示調用對象10的 times 方法(注 5),緊跟在這個調用后面的大括號里面的部分就是Block。所謂 Block 是指緊跟在函數調用之后用大括號或 do/end 括起來的代碼,Block 的開始部分(左大括號或 do)必須和函數調用在同一行。Block 也可以接受參數,參數列表必須用兩個豎杠括起來放在最前面。Block 會被作為它前面的函數調用的參數,而在這個函數中可以使用關鍵字 yield 來調用該 Block。在這個例子中,10.times 會以數字0到9為參數調用 Block 10次。
Block 實際上就是匿名函數,它可以被調用,可以捕獲上下文。由于語法上要求 Block 必須出現在函數調用的后面,所以 Block 不能直接作為函數的的返回值。要想從一個函數中返回 Block,必須使用 proc 或 lambda 函數把 Block 轉化為對象才行。詳細內容請參考參考資源和3。
Python 中的閉包
Python 因其簡單易學、功能強大而擁有很多擁護者,很多企業和組織在使用這種語言。Python 使用縮進來區分作用域的做法也十分有特點。下面是一個 Python 的例子:
清單 6. Python 1
def addx(x):
def adder (y): return x + y
return adder
add8 = addx(8)
add9 = addx(9)
print add8(100)
print add9(100)
|
在 Python 中使用 def 來定義函數時,是必須有名字的,要想使用匿名函數,則需要使用lambda 語句,象下面的代碼這樣:
清單 7. Python 2
def addx(x):
return lambda y: x + y
add8 = addx(8)
add9 = addx(9)
print add8(100)
print add9(100)
|
Python 簡單易用且功能強大,關于 Python 的更多信息請參考參考資源。
Perl 中的閉包
Perl 是老牌文本處理語言了,在 WEB 開發方面也有一席之地。不過 Perl6 的開發進行比較慢,也許一些用戶開始轉投其它語言了。下面是一個 Perl 的例子。
清單 8. Perl
sub addx {
my $x = shift;
return sub { shift() + $x };
}
$add8 = addx(8);
$add9 = addx(9);
print $add8->(100);
print $add9->(100);
|
Lua 中的閉包
Lua 以其小巧和快速的特點受到游戲開發者的青睞,被一些游戲用來定制 UI 或作為插件語言,如果你玩過《魔獸世界》,那你對 Lua 一定不會感到陌生。前面在說明閉包原理時就使用了 Lua,這里就不再給出其他的例子了。更多的內容請參考參考資源。
Scheme 中的閉包
Scheme 是 Lisp 的一種方言,被 MIT 用作教學語言。Scheme 屬于函數語言,雖然不像命令語言那么流行,卻是很多黑客喜歡的語言。很多編程思想起源于函數語言,閉包就是其中之一。一般認為 Scheme 是第一個提供完整閉包支持的語言。下面是一個 Scheme 的例子:
清單 9. Scheme
(define (addx x)
(lambda (y) (+ y x)))
(define add8 (addx 8))
(define add9 (addx 9))
(add8 100)
(add9 100)
|
Scheme 的語法非常簡單,只是有人覺得寫法看起來比較古怪。有關 Scheme 更多信息請參考參考資源。
閉包的應用
閉包可以用優雅的方式來處理一些棘手的問題,有些程序員聲稱沒有閉包簡直就活不下去了。這雖然有些夸張,卻從側面說明閉包有著強大的功能。下面列舉了一些閉包應用。
加強模塊化
閉包有益于模塊化編程,它能以簡單的方式開發較小的模塊,從而提高開發速度和程序的可復用性。和沒有使用閉包的程序相比,使用閉包可將模塊劃分得更小。比如我們要計算一個數組中所有數字的和,這只需要循環遍歷數組,把遍歷到的數字加起來就行了。如果現在要計算所有元素的積呢?要打印所有的元素呢?解決這些問題都要對數組進行遍歷,如果是在不支持閉包的語言中,我們不得不一次又一次重復地寫循環語句。而這在支持閉包的語言中是不必要的,比如對數組求和的操作在 Ruby 中可以這樣做:
清單 10. 加強模塊化
nums = [10,3,22,34,17]
sum = 0
nums.each{|n| sum += n}
print sum
|
這種處理方法多少有點像我們熟悉的回調函數,不過要比回調函數寫法更簡單,功能更強大。因為在閉包里引用環境是函數定義時的環境,所以在閉包里改變引用環境中變量的值,直接就可以反映到它定義時的上下文中,這是通常的回調函數所不能做到的。這個例子說明閉包可以使我們把模塊劃分得更小。
抽象
閉包是數據和行為的組合,這使得閉包具有較好抽象能力,下面的代碼通過閉包來模擬面向對象編程。函數 make_stack 用來生成 stack 對象,它的返回值是一個閉包,這個閉包作為一個 Dispatcher,當以 “push” 或 “pop” 為參數調用時,返回一個與函數 push 或 pop 相關聯的閉包,進而可以操作 data 中的數據。
清單 11. 抽象
function make_stack()
local data = {};
local last = -1;
local function push(e)
last = last + 1;
data[last] = e;
end
local function pop()
if last == -1 then
return nil
end
last = last - 1
return data[last+1]
end
return function (index)
local tb = {push=push, pop=pop}
return tb[index]
end
end
s = make_stack()
s("push")("test0")
s("push")("test1")
s("push")("test2")
s("push")("test3")
print(s("pop")())
print(s("pop")())
print(s("pop")())
|
如果加入一些方便調用“對象方法”的語法糖,這看起來很像是面向對象的語法。當然 Lua 中有自己的面向對象語法和機制,所以幾乎看不到有人寫這樣的 Lua 代碼,但是對于 Scheme 等沒有內建面向對象支持也沒有內建復雜數據抽象機制的語言,使用閉包來進行抽象是非常重要的手段。
簡化代碼
我們來考慮一個常見的問題。在一個窗口上有一個按鈕控件,當點擊按鈕時會產生事件,如果我們選擇在按鈕中處理這個事件,那就必須在按鈕控件中保存處理這個事件時需要的各個對象的引用。另一種選擇是把這個事件轉發給父窗口,由父窗口來處理這個事件,或是使用監聽者模式。無論哪種方式,編寫代碼都不太方便,甚至要借助一些工具來幫助生成事件處理的代碼框架。用閉包來處理這個問題則比較方便,可以在生成按鈕控件的同時就寫下事件處理代碼。比如在 Ruby 中可以這樣寫:
清單 12. 簡化代碼
song = Song.new
start_button = MyButton.new("Start") { song.play }
stop_button = MyButton.new("Stop") { song.stop }
|
更多
閉包的應用遠不止這些,這里列舉的只能算是冰山一角而已,并且更多的用法還不斷發現中。要想了解更多的用法,多看一些代碼應該是個不錯的選擇。
總結
閉包能優雅地解決很多問題,很多主流語言也順應潮流,已經或將要引入閉包支持。相信閉包會成為更多人愛不釋手的工具。閉包起源于函數語言,也許掌握一門函數語言是理解閉包的最佳途徑,而且通過學習函數語言可以了解不同的編程思想,有益于寫出更好的程序。
注解
- 自由變量是指除局部變量以外的變量。
- 英文原詞是 binding,也有人把它翻譯為綁定。
- 一個函數中沒有自由變量時,引用環境不會發生變化。
- 出自 Python 社區。
- 在Ruby中一切都是對象,數字也是對象。