1. 程式人生 > >閉包的概念

閉包的概念

for set 並行 Language 循環語句 數字0 per 部分 row

什麽是閉包?

閉包並不是什麽新奇的概念,它早在高級語言開始發展的年代就產生了。閉包(Closure)是詞法閉包(Lexical Closure)的簡稱。對閉包的具體定義有很多種說法,這些說法大體可以分為兩類:

  • 一種說法認為閉包是符合一定條件的函數,比如參考資源中這樣定義閉包:閉包是在其詞法上下文中引用了自由變量(指除局部變量以外的變量)的函數。
  • 另一種說法認為閉包是由函數和與其相關的引用環境組合而成的實體。有這樣的的定義:在實現深約束(英文原詞是 binding,也有人把它翻譯為綁定)時,需要創建一個能顯式表示引用環境的東西,並將它與相關的子程序捆綁在一起,這樣捆綁起來的整體被稱為閉包。

這兩種定義在某種意義上是對立的,一個認為閉包是函數,另一個認為閉包是函數和引用環境組成的整體。雖然有些咬文嚼字,但可以肯定第二種說法更確切。閉包只是在形式和表現上像函數,但實際上不是函數。函數是一些可執行的代碼,這些代碼在函數被定義後就確定了,不會在執行時發生變化,所以一個函數只有一個實例。閉包在運行時可以有多個實例,不同的引用環境和相同的函數組合可以產生不同的實例。所謂引用環境是指在程序執行中的某個點所有處於活躍狀態的約束所組成的集合。其中的約束是指一個變量的名字和其所代表的對象之間的聯系。那麽為什麽要把引用環境與函數組合起來呢?這主要是因為在支持嵌套作用域的語言中,有時不能簡單直接地確定函數的引用環境。這樣的語言一般具有這樣的特性:

  • 函數是一階值(First-class value),即函數可以作為另一個函數的返回值或參數,還可以作為一個變量的值。
  • 函數可以嵌套定義,即在一個函數內部可以定義另一個函數。

這些概念上的解釋很難理解,顯然一個實際的例子更能說明問題。Lua 語言的語法比較接近偽代碼,我們來看一段 Lua 的代碼:

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 的作用域中,這看起來是無法正確執行的。
上面的例子說明了把函數作為返回值時需要面對的問題。當把函數作為參數時,也存在相似的問題。下面的例子演示了把函數作為參數的情況。

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 的作用域內。這看起來也是無法正常執行的。
這兩種情況所面臨的問題實質是相同的。在這樣的語言中,如果按照作用域規則在執行時確定一個函數的引用環境,那麽這個引用環境可能和函數定義時不同。要想使這兩段程序正常執行,一個簡單的辦法是在函數定義時捕獲當時的引用環境,並與函數代碼組合成一個整體。當把這個整體當作函數調用時,先把其中的引用環境覆蓋到當前的引用環境上,然後執行具體代碼,並在調用結束後恢復原來的引用環境。這樣就保證了函數定義和執行時的引用環境是相同的。這種由引用環境與函數代碼組成的實體就是閉包。當然如果編譯器或解釋器能夠確定一個函數在定義和運行時的引用環境是相同的(一個函數中沒有自由變量時,引用環境不會發生變化),那就沒有必要把引用環境和代碼組合起來了,這時只需要傳遞普通的函數就可以了。現在可以得出這樣的結論:閉包不是函數,只是行為和函數相似,不是所有被傳遞的函數都需要轉化為閉包,只有引用環境可能發生變化的函數才需要這樣做。
再次觀察上面兩個例子會發現,代碼中並沒有通過名字來調用函數 inc_count 和 addsum,所以他們根本不需要名字。以第一段代碼為例,它可以重寫成下面這樣:

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。這實際上是語法糖,只是為了更容易定義匿名函數而已,本質上沒有區別。
借用一個非常好的說法來做個總結:對象是附有行為的數據,而閉包是附有數據的行為。

閉包的表現形式

雖然建立在相似的思想之上,各種語言所實現的閉包卻有著不同的表現形式,下面我們來看一下閉包在一些常用語言中的表現形式。

JavaScript 中的閉包

JavaScript(ECMAScript)不是通用編程語言,但卻擁有較大的用戶群體,而 Ajax 的流行也使更多的人關註 JavaScript。雖然在進行 DOM 操作時容易引發循環引用問題,但 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:

sum = 0
10.times{|n| sum += n}
print sum

10.times 表示調用對象10的 times 方法(在Ruby中一切都是對象,數字也是對象),緊跟在這個調用後面的大括號裏面的部分就是Block。所謂 Block 是指緊跟在函數調用之後用大括號或 do/end 括起來的代碼,Block 的開始部分(左大括號或 do)必須和函數調用在同一行。Block 也可以接受參數,參數列表必須用兩個豎杠括起來放在最前面。Block 會被作為它前面的函數調用的參數,而在這個函數中可以使用關鍵字 yield 來調用該 Block。在這個例子中,10.times 會以數字0到9為參數調用 Block 10次。
Block 實際上就是匿名函數,它可以被調用,可以捕獲上下文。由於語法上要求 Block 必須出現在函數調用的後面,所以 Block 不能直接作為函數的的返回值。要想從一個函數中返回 Block,必須使用 proc 或 lambda 函數把 Block 轉化為對象才行。

Python 中的閉包

Python 因其簡單易學、功能強大而擁有很多擁護者,很多企業和組織在使用這種語言。Python 使用縮進來區分作用域的做法也十分有特點。下面是一個 Python 的例子:

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 語句,像下面的代碼這樣:

def addx(x):
 return lambda y: x + y

add8 = addx(8)
add9 = addx(9)

print add8(100)
print add9(100)

Perl 中的閉包

Perl 是老牌文本處理語言了,在 WEB 開發方面也有一席之地。不過 Perl6 的開發進行比較慢,也許一些用戶開始轉投其它語言了。下面是一個 Perl 的例子:

sub addx {
 my $x = shift;
 return sub { shift() + $x };
}

$add8 = addx(8);
$add9 = addx(9);

print $add8->(100);
print $add9->(100);

閉包的應用

閉包可以用優雅的方式來處理一些棘手的問題,有些程序員聲稱沒有閉包簡直就活不下去了。這雖然有些誇張,卻從側面說明閉包有著強大的功能。下面列舉了一些閉包應用。

加強模塊化

閉包有益於模塊化編程,它能以簡單的方式開發較小的模塊,從而提高開發速度和程序的可復用性。和沒有使用閉包的程序相比,使用閉包可將模塊劃分得更小。比如我們要計算一個數組中所有數字的和,這只需要循環遍歷數組,把遍歷到的數字加起來就行了。如果現在要計算所有元素的積呢?要打印所有的元素呢?解決這些問題都要對數組進行遍歷,如果是在不支持閉包的語言中,我們不得不一次又一次重復地寫循環語句。而這在支持閉包的語言中是不必要的。

抽象

閉包是數據和行為的組合,這使得閉包具有較好抽象能力,下面的代碼通過閉包來模擬面向對象編程。函數 make_stack 用來生成 stack 對象,它的返回值是一個閉包,這個閉包作為一個 Dispatcher,當以 “push” 或 “pop” 為參數調用時,返回一個與函數 push 或 pop 相關聯的閉包,進而可以操作 data 中的數據。

簡化代碼

我們來考慮一個常見的問題。在一個窗口上有一個按鈕控件,當點擊按鈕時會產生事件,如果我們選擇在按鈕中處理這個事件,那就必須在按鈕控件中保存處理這個事件時需要的各個對象的引用。另一種選擇是把這個事件轉發給父窗口,由父窗口來處理這個事件,或是使用監聽者模式。無論哪種方式,編寫代碼都不太方便,甚至要借助一些工具來幫助生成事件處理的代碼框架。用閉包來處理這個問題則比較方便,可以在生成按鈕控件的同時就寫下事件處理代碼。

更多

閉包的應用遠不止這些,這裏列舉的只能算是冰山一角而已,並且更多的用法還不斷發現中。要想了解更多的用法,多看一些代碼應該是個不錯的選擇。

總結

閉包能優雅地解決很多問題,很多主流語言也順應潮流,已經或將要引入閉包支持。相信閉包會成為更多人愛不釋手的工具。閉包起源於函數語言,也許掌握一門函數語言是理解閉包的最佳途徑,而且通過學習函數語言可以了解不同的編程思想,有益於寫出更好的程序。

閉包(closure)是函數式編程的重要的語法結構。函數式編程是一種編程範式 (而面向過程編程和面向對象編程也都是編程範式)。在面向過程編程中,我們見到過函數(function);在面向對象編程中,我們見過對象(object)。函數和對象的根本目的是以某種邏輯方式組織代碼,並提高代碼的可重復使用性(reusability)。閉包也是一種組織代碼的結構,它同樣提高了代碼的可重復使用性。

不同的語言實現閉包的方式不同。Python以函數對象為基礎,為閉包這一語法結構提供支持的 (我們在特殊方法與多範式中,已經多次看到Python使用對象來實現一些特殊的語法)。Python一切皆對象,函數這一語法結構也是一個對象。在函數對象中,我們像使用一個普通對象一樣使用函數對象,比如更改函數對象的名字,或者將函數對象作為參數進行傳遞。

函數對象的作用域

和其他對象一樣,函數對象也有其存活的範圍,也就是函數對象的作用域。函數對象是使用def語句定義的,函數對象的作用域與def所在的層級相同。比如下面代碼,我們在line_conf函數的隸屬範圍內定義的函數line,就只能在line_conf的隸屬範圍內調用。

Python
1 2 3 4 5 6 7 def line_conf(): def line(x): return 2*x+1 print(line(5)) # within the scope line_conf() print(line(5)) # out of the scope

line函數定義了一條直線(y = 2x + 1)。可以看到,在line_conf()中可以調用line函數,而在作用域之外調用line將會有下面的錯誤:

NameError: name ‘line’ is not defined

說明這時已經在作用域之外。

同樣,如果使用lambda定義函數,那麽函數對象的作用域與lambda所在的層級相同。

閉包

函數是一個對象,所以可以作為某個函數的返回結果。

Python
1 2 3 4 5 6 7 def line_conf(): def line(x): return 2*x+1 return line # return a function object my_line = line_conf() print(my_line(5))

上面的代碼可以成功運行。line_conf的返回結果被賦給line對象。上面的代碼將打印11。

如果line()的定義中引用了外部的變量,會發生什麽呢?

Python
1 2 3 4 5 6 7 8 9 def line_conf(): b = 15 def line(x): return 2*x+b return line # return a function object b = 5 my_line = line_conf() print(my_line(5))

我們可以看到,line定義的隸屬程序塊中引用了高層級的變量b,但b信息存在於line的定義之外 (b的定義並不在line的隸屬程序塊中)。我們稱b為line的環境變量。事實上,line作為line_conf的返回值時,line中已經包括b的取值(盡管b並不隸屬於line)。

上面的代碼將打印25,也就是說,line所參照的b值是函數對象定義時可供參考的b值,而不是使用時的b值。

一個函數和它的環境變量合在一起,就構成了一個閉包(closure)。在Python中,所謂的閉包是一個包含有環境變量取值的函數對象。環境變量取值被保存在函數對象的__closure__屬性中。比如下面的代碼:

Python
1 2 3 4 5 6 7 8 9 10 def line_conf(): b = 15 def line(x): return 2*x+b return line # return a function object b = 5 my_line = line_conf() print(my_line.__closure__) print(my_line.__closure__[0].cell_contents)

__closure__裏包含了一個元組(tuple)。這個元組中的每個元素是cell類型的對象。我們看到第一個cell包含的就是整數15,也就是我們創建閉包時的環境變量b的取值。

下面看一個閉包的實際例子:

Python
1 2 3 4 5 6 7 8 def line_conf(a, b): def line(x): return ax + b return line line1 = line_conf(1, 1) line2 = line_conf(4, 5) print(line1(5), line2(5))

這個例子中,函數line與環境變量a,b構 成閉包。在創建閉包的時候,我們通過line_conf的參數a,b說明了這兩個環境變量的取值,這樣,我們就確定了函數的最終形式(y = x + 1和y = 4x + 5)。我們只需要變換參數a,b,就可以獲得不同的直線表達函數。由此,我們可以看到,閉包也具有提高代碼可復用性的作用。

如果沒有閉包,我們需要每次創建直線函數的時候同時說明a,b,x。這樣,我們就需要更多的參數傳遞,也減少了代碼的可移植性。利用閉包,我們實際上創建了泛函。line函數定義一種廣泛意義的函數。這個函數的一些方面已經確定(必須是直線),但另一些方面(比如a和b參數待定)。隨後,我們根據line_conf傳遞來的參數,通過閉包的形式,將最終函數確定下來。

閉包與並行運算

閉包有效的減少了函數所需定義的參數數目。這 對於並行運算來說有重要的意義。在並行運算的環境下,我們可以讓每臺電腦負責一個函數,然後將一臺電腦的輸出和下一臺電腦的輸入串聯起來。最終,我們像流 水線一樣工作,從串聯的電腦集群一端輸入數據,從另一端輸出數據。這樣的情境最適合只有一個參數輸入的函數。閉包就可以實現這一目的。

並行運算正稱為一個熱點。這也是函數式編程又 熱起來的一個重要原因。函數式編程早在1950年代就已經存在,但應用並不廣泛。然而,我們上面描述的流水線式的工作並行集群過程,正適合函數式編程。由 於函數式編程這一天然優勢,越來越多的語言也開始加入對函數式編程範式的支持。

閉包的概念