一、前言
目前在Unity游戲開發中,比較流行的兩種語言就是Lua和C#。通常的做法是:C#做些核心的功能和接口供Lua調用,Lua主要做些UI模塊和一些業務邏輯。這樣既能在保持一定的游戲運行效率的同時,又可以讓游戲具備熱更新的功能。無論我們有意或者無意,其實我們經常會在Unity游戲開發中使用到閉包。那么,馬三今天就要和大家來談談Lua和C#中的閉包,下面首先讓我們先來談談Lua中的閉包。
二、Lua中的閉包
相信,對於經常使用Javascript的前端開發者來說,閉包這個概念一定不會陌生,在Javascript開發中,一些高級的應用都需要閉包來實現。而對於傳統的C++開發者或者C#開發者來說,閉包這個詞或多或少都會有些玄之又玄的感覺。那么,在開講之前,讓我們先來了解幾個Lua中基礎知識和概念,這樣有助於我們理解Lua閉包。
1.一些前提概念
詞法定界:當一個函數內嵌套另一個函數的時候,內函數可以訪問外部函數的局部變量,這種特征叫做詞法定界。如下面這段代碼,func2作為func1的內嵌函數,可以自由地訪問屬於func1的局部變量i :
function func1() local i = 100 --upvalue local func2 = function() print(i+1) end i = 101 return func2 end local f = func1() print(f()) --輸出102
第一類值:在Lua中,函數是一個值,它可以存在於變量中、可以作為函數參數,也可以作為返回值return。還是以上面的代碼舉例,我們將一個內嵌在func1中的函數賦值給局部變量func2,並將func2這個變量在函數結尾return。
upvalue:內嵌函數可以訪問外部函數已經創建的局部變量,而這些局部變量則稱為該內嵌函數的外部局部變量(即upvalue)。在我們的第一個例子中,func1的局部變量i就是內嵌函數func2的upvalue。
2.什么是Lua閉包
好了有了以上的概念以后,我們也該引入Lua中閉包的概念了。閉包是由函數和與其相關的引用環境組合而成的實體,閉包=函數+引用環境。
在第一個例子中,func1函數返回了一個函數,而這個返回的函數就是閉包的組成部分中的函數;引用環境就是變量i所在的環境。實際上,閉包只是在形式和表現上像函數,但實際上不是函數。我們都知道,函數就是一些可執行語句的組合體,這些代碼語句在函數被定義后就確定了,並不會再執行時發生變化,所以函數只有一個實例。而閉包在運行時可以有多個實例,不同的引用環境和相同的函數組合可以產生不同的實例,就好比相同的類代碼,可以創建不同的類實例一樣。
用一句比較通俗和不甚嚴謹的話來講:子函數可以使用父函數中的局部變量,這種行為就叫做閉包。這種說法其實就說明了閉包的一種表象,讓我們從外在形式上,能更好的理解什么是閉包。
對於學習C++或者是C#之類的語言入門的朋友,可能對閉包理解起來比較吃力(至少馬三是這樣,一會明白一會糊塗,看了很多文章、寫了很多代碼以后才理解,笨得要命~ o(≧口≦)o)。其實我們可以把Lua中的閉包和C++中的類做一下類比。閉包是數據和行為的結合體,這就好比C++中的類,有一些成員變量(Lua中的upvalue)+成員方法(Lua中的內嵌函數)。這樣就使得閉包具有較好的抽象能力,在某些場合下,我們需要記住某次調用函數完成以后數據的狀態,就好比C++中的static類型的變量,每次調用完成以后,static類型的變量並不會被清除。使用閉包就可以很好的完成該功能,比如利用Lua閉包特性實現一個簡單地迭代器,在下面的小節中我們會介紹到。
3.典型Lua閉包例子
1.閉包的數據隔離
function counter() local i = 0 return function() --匿名函數,閉包 i = i + 1 return i end end counter1 = counter() counter2 = counter() -- counter1,counter2 是建立在同一個函數,同一個局部變量的不同實例上面的兩個不同的閉包 -- 閉包中的upvalue各自獨立,調用一次counter()就會產生一個新的閉包 print(counter1()) -- 輸出1 print(counter1()) -- 輸出2 print(counter2()) -- 輸出1 print(counter2()) -- 輸出2
上面的代碼中,注釋已經解釋地很詳細了。盡管看起來counter1,counter2是由同一個函數和同一個局部變量創建的閉包。但是其實它們是不同實例上面的兩個不同的閉包。閉包中的upvalue各自獨立,調用一次counter()就會產生一個新的閉包。有點像工廠函數一樣,每調用一次counter()都會new出來一個新的對象,不同的對象之間的數據,當然也就是隔離的了。
2.閉包的數據共享
function shareVar(n) local function func1() print(n) end local function func2() n = n + 10 print(n) end return func1,func2 end local f1,f2 = shareVar(1024) --創建閉包,f1,f2兩個閉包共享同一份upvalue f1() -- 輸出1024 f2() -- 輸出1034 f1() -- 輸出1034 f2() -- 輸出1044
乍一看起來,這個概念和第一個概念矛盾啊,其實他們之間並不矛盾。在Lua中,同一閉包創建的其他的閉包共享一份upvalue。閉包在創建之時其需要的變量就已經不在堆棧上,而是引用更外層外部函數的局部變量(即upvalue)。在上面的例子中,f1,f2共享同一份upvalue,這是因為f1、f2都是由同一個閉包shareVar(1024)創建的,所以他們引用的upvalue(變量n)實際也是同一個變量,而它們的upvalue引用都會指向同一個地方。說白了就是func1和func2的引用環境是一樣,它們的上下文是一樣的。再類比一下我們比較熟悉的C++,就好比C++類中有兩個不同的成員函數,它們都可以對類中的同一個成員變量進行訪問和修改。這第二點概念尤其要和第一點概念進行區分,它們很容易混淆。
3.利用閉包實現迭代器功能
--- 利用閉包實現iterator,iterator是一個工廠,每次調用都會產生一個新的閉包,該閉包內部包括了upvalue(t,i,n) --- 因此每調用一次該函數都會產生閉包,那么該閉包就會根據記錄上一次的狀態,以及返回table中的下一個元素 function iterator(t) local i = 0 local n = #t return function() i = i + 1 if i <= n then return t[i] end end end testTable = {1,2,3,"a","b"} -- while中使用迭代器 iter1 = iterator(testTable) --調用迭代器產生一個閉包 while true do local element = iter1() if nil == element then break; end print(element) end -- for中使用迭代器 for element in iterator(testTable) do --- 這里的iterator()工廠函數只會被調用一次產生一個閉包函數,后面的每一次迭代都是用該閉包函數,而不是工廠函數 print(element) end
利用閉包我們可以很方便地實現一個迭代器,例如上面代碼中的iterator。iterator是一個工廠,每次調用都會產生一個新的閉包,該閉包內部包括了upvalue(t,i,n),因此每調用一次該函數都會產生閉包,那么該閉包就會根據記錄上一次的狀態,以及返回table中的下一個元素,從而實現了迭代器的功能。需要額外注意的是:迭代器只是一個生成器,他自己本身不帶循環。我們還需要在循環里面去調用它才行。
在while循環的那段例子代碼中,我們首先調用迭代器創建一個閉包,然后不斷地調用它就可以獲取到表中的下一個元素了,就好像是游標一樣。而由於 for ... in ... do 的這種寫法很具有迷惑性,所以在for循環中使用迭代器的話,我們需要注意:這里的iterator()工廠函數只會被調用一次產生一個閉包函數,后面的每一次迭代都是用該閉包函數,而不是工廠函數。相信許多朋友此時會和馬三一樣產生一個疑問,為什么在for循環中使用迭代器,iterator()工廠函數只會被調用一次呢?難道不是每次判斷執行條件的時候都去執行一次iterator函數嗎?其實這和Lua語言對for...in...do這種控制結構的內部實現方式有關。for in在自己內部保存三個值:迭代函數、狀態常量、控制變量。for...in 這種寫法其實是一種語法糖,在《Programming in Lua》中給出的等價代碼是:
do local _f,_s,_var = iter,tab,var while true do local _var,value = _f(_s, _var) if not _var then break end body end end
怎么樣,for...in 的內部實現代碼和我們在while中調用Iterator的方式是不是很類似?Iterator(table)函數返回一個匿名函數作為迭代器,該迭代函數會忽略掉傳給它的參數table和nil,table和控制變量已被保存在迭代函數中,因此將上面的for循環展開后應該是這個樣子:
iter = iterator(testTable) element,value = iter(nil,nil)--忽略參數,value置為nil if(element) then repeat print(element) element,value = iter(nil,element)--忽略參數 until(not element) end
三、C#中的閉包
我們在上面花了很大的篇幅來介紹Lua的閉包,其實在C#中也是有閉包概念的。由於我們已經有了之前的Lua閉包基礎,所以再理解C#中的閉包概念也就不那么困難了。照例在開講之前我們還是先介紹一些C#中的基礎知識與概念,一邊有助於我們的理解。
1.一些前提概念
變量作用域:在C#里面,變量作用域有三種,一種是屬於類的,我們常稱之為field(字段/屬性);第二種則屬於函數的,我們通常稱之為局部變量;還有一種,其實也是屬於函數的,不過它的作用范圍更小,它只屬於函數局部的代碼片段,這種同樣稱之為局部變量。這三種變量的生命周期基本都可以用一句話來說明,每個變量都屬於它所寄存的對象,即變量隨着其寄存對象生而生和消亡。
對應三種作用域我們可以這樣說,類里面的變量是隨着類的實例化而生,同時伴隨着類對象的資源回收而消亡(當然這里不包括非實例化的static和const對象)。而函數(或代碼片段)的變量也隨着函數(或代碼片段)調用開始而生,伴隨函數(或代碼片段)調用結束而自動由GC釋放,它內部變量生命周期滿足先進后出的特性。
那么,有沒有例外的情況呢?答案當然是有的,它就是我們的今天的主角:C#閉包。
委托:委托是一個類,它定義了方法的類型,使得可以將方法當作另一個方法的參數來進行傳遞,這種將方法動態地賦給參數的做法,可以避免在程序中大量使用If-Else(Switch)語句,同時使得程序具有更好的可擴展性。(關於委托的講解,網上已經有很多文章了,這里不再贅述,籠統一點你可以把委托簡單地理解為函數指針)
2.什么是C#閉包?
閉包其實就是使用的變量已經脫離其作用域,卻由於和作用域存在上下文關系,從而可以在當前環境中繼續使用其上文環境中所定義的一種函數對象。(本質上和Lua閉包的概念沒有什么不同,只是換種說法罷了)
3.典型的C#閉包例子
首先讓我們來看下面這一段C#代碼:
public class TCloser { public Func<int> T1() { var n = 999; return () => { Console.WriteLine(n); return n; }; } } class Program { static void Main() { var a = new TCloser(); var b = a.T1(); Console.WriteLine(b()); } }
從上面的代碼我們不難看到,變量n實際上是屬於函數T1的局部變量,它本來的生命周期應該是伴隨着函數T1的調用結束而被釋放掉的,但這里我們卻在返回的委托b中仍然能調用它,這里正是C#閉包的特性。在T1調用返回的匿名委托的代碼片段中我們用到了n,而在編譯器看來,這些都是合法的,因為返回的委托b和函數T1存在上下文關系,也就是說匿名委托b是允許使用它所在的函數或者類里面的局部變量的,於是編譯器通過一系列操作使b中調用的函數T1的局部變量自動閉合,從而使該局部變量滿足新的作用范圍。
所以對於C#中的閉包,你就可以像之前介紹的Lua閉包那樣理解它。由於返回的匿名函數對象是在函數T1中生成的,因此相當於它是屬於T1的一個屬性。如果你把T1的對象級別往上提升一個層次就很好理解了,這里就相當於T1是一個類,而返回的匿名對象則是T1的一個屬性,對屬性而言,它可以調用它所寄存的對象T1的任何其他屬性或者方法,包括T1寄存的對象TCloser內部的其他屬性。如果這個匿名函數會被返回給其他對象調用,那么編譯器會自動將匿名函數所用到的方法T1中的局部變量的生命周轉期自動提升,並與匿名函數的生命周期相同,這樣就稱之為閉合。
如果你想了解C#編譯器是如何操作,使得閉包產生的,可以去反編譯一下C#程序,然后觀察它的IL代碼(如何反編譯並查看IL代碼,馬三已經在《【小白學C#】淺談.NET中的IL代碼》這篇博客中做了詳細的介紹) 。C#的閉包,其實只是編譯器對IL代碼做了一些操作而已,它仍然沒有脫離C#對象生命周期的規則。它將需要修改作用域的變量直接封裝到返回的類中,變成類的一個屬性n,從而保證了變量的生命周期不會隨函數T1調用結束而結束,因為變量n在這里已經成了返回的類的一個屬性了。
在C#中,閉包其實和類中其他屬性、方法是一樣的,它們的原則都是下一層可以任意調用上一層定義的各種設定,但上一層則不具備訪問下一層設定的能力。好比一個類中方法里可以自由訪問類中的所有屬性和方法,而閉包又可以訪問它的上一層即方法中的各種設定。但類不可以訪問方法的局部變量,同理,方法也不可以訪問其內部定義的匿名函數所定義的局部變量。在我們工作中經常會用到的匿名委托、Lamda和LINQ,他們本質上都會使用到閉包這個特性。
四、總結
無論是在Javascript、Lua還是C#開發中,閉包的使用相當廣泛,也正是由於閉包和各種語法糖的存在,才使得我們的代碼更加簡潔,使用更方便。靈活、可靠地使用閉包,可以為我們的程序代碼增光添彩,優化代碼結構,益處多多。總之,閉包是一個好理解而又難理解的東西,我們應該多寫多練,多參與到各類項目開發中,以提高自己的理解層次。
本篇博客中的示例代碼托管在Github:https://github.com/XINCGer/Unity3DTraining/tree/master/SomeTest/Closure 歡迎fork!
作者:馬三小伙兒
出處:http://www.cnblogs.com/msxh/p/8283865.html
請尊重別人的勞動成果,讓分享成為一種美德,歡迎轉載。另外,文章在表述和代碼方面如有不妥之處,歡迎批評指正。留下你的腳印,歡迎評論!