一、從函數對象到委托
松本大叔說:要理解閉包,從函數指針開始!
1.1 函數指針及其作用
原文中使用了C語言的函數對象,這里我們主要從.NET平台來說。在.NET中,委托這個概念對C++程序員來說並不陌生,因為它和C++中的函數指針非常類似,很多碼農也喜歡稱委托為安全的函數指針。無論這一說法是否正確,委托的的確確實現了和函數指針類似的功能,那就是提供了程序回調指定方法的機制。
下面的代碼展示了委托的基本使用:

// 定義的一個委托 public delegate void TestDelegate(int i); public class Program { public static void Main(string[] args) { // 定義委托實例 TestDelegate td = new TestDelegate(PrintMessage); // 調用委托方法 td(0); td(1); Console.ReadKey(); } public static void PrintMessage(int i) { Console.WriteLine("這是第{0}個方法!", i.ToString()); } }
運行結果如下圖所示:
也許很多初學者都了解委托的概念,但是卻不知道為什么要有委托,委托到底有什么作用?這里我們通過數據結構里面最經典的冒泡排序來看看委托在提高程序擴展性/通用性方面的作用。
Step1.我們可以比較容易地寫出下面的這段冒泡排序代碼,它針對int類型數組進行升序排序:

public static void BubbleSort(int[] arr) { int i, j; int temp; bool isExchanged = true; for (j = 1; j < arr.Length && isExchanged; j++) { isExchanged = false; for (i = 0; i < arr.Length - j; i++) { if (arr[i] > arr[i + 1]) { // 核心操作:交換兩個元素 temp = arr[i]; arr[i] = arr[i + 1]; arr[i + 1] = temp; // 附加操作:改變標志 isExchanged = true; } } } }
Step2.但是我們不能只能只對int類型進行排序吧,難道對double類型還得重寫?於是我們加入.NET中的模板—泛型:

public static void BubbleSort(T[] arr) { int i, j; T temp; bool isExchanged = true; for (j = 1; j < arr.Length && isExchanged; j++) { isExchanged = false; for (i = 0; i < arr.Length - j; i++) { if (arr[i].CompareTo(arr[i + 1]) > 0) { // 核心操作:交換兩個元素 temp = arr[i]; arr[i] = arr[i + 1]; arr[i + 1] = temp; // 附加操作:改變標志 isExchanged = true; } } } }
Step3.但是我們不能只進行升序排序吧,如果要降序排序是不是要還得重寫排序算法,於是我們加入.NET中的函數指針—委托:

public static void BubbleSort(T[] arr, Comparison<T> comp) { int i, j; T temp; bool isExchanged = true; for (j = 1; j < arr.Length && isExchanged; j++) { isExchanged = false; for (i = 0; i < arr.Length - j; i++) { if (comp(arr[i], arr[i + 1]) > 0) { // 核心操作:交換兩個元素 temp = arr[i]; arr[i] = arr[i + 1]; arr[i + 1] = temp; // 附加操作:改變標志 isExchanged = true; } } } }
其中,Comparison委托是.NET中的一個預定義委托,主要用來進行元素的比較。對Comparison不熟悉的朋友,可以看看《.NET中那些所謂的新語法之預定義委托》。
這時,我們如果想要進行升序排序,只需通過以下方式調用:
int[] smallDatas = { 3, 6, 5, 9, 7, 1, 8, 2, 4 }; SortingHelper<int>.BubbleSort(smallDatas, new Comparison<int>((p1, p2) => p1 - p2));
運行結果如下:
過了一段時間,我們想要進行降序排序,只需改變委托的實現即可復用代碼:
int[] smallDatas = { 3, 6, 5, 9, 7, 1, 8, 2, 4 }; SortingHelper<int>.BubbleSort(smallDatas, new Comparison<int>((p1, p2) => p2 - p1));
運行結果如下:
兩次重構之后,我們的這個冒泡排序代碼的通用性就提高了不少,可以看到委托在其中起到了很大的作用。
1.2 函數指針的局限
這里松本大叔舉了一個例子,我這里使用C#語言來描述。對一個由各個節點構成的鏈表進行兩種不同方式的遍歷,一是通過一般的循環,二是通過函數指針(這里主要是指委托),本質的部分是從main方法開始的。
(1)節點定義
/// <summary> /// 鏈表節點定義 /// </summary> public class Node { public Node next; public int value; }
(2)委托定義
// 委托類型定義-函數指針類型 public delegate void funct(int num);
(3)使用委托的自定義遍歷方法與符合委托定義的方法
static void Foreach(Node list, funct func) { while (list != null) { func(list.value); list = list.next; } } static void F(int num) { Console.WriteLine("Node[?]={0}", num); }
(4)主入口:main方法
const int size = 4; static void Main(string[] args) { int i = 1; Node head = new Node(); head.value = 0; head.next = null; // 創建鏈表 while (i < size) { Node node = new Node(); node.value = i; node.next = head; head = node; i++; } i = 0; Node list = head; // 遍歷鏈表 while (list != null) { Console.WriteLine("Node[{0}]={1}", i++, list.value); list = list.next; } // 自定義遍歷 Foreach(head, F); Console.ReadKey(); }
該程序的運行結果如下:
其中前面4行是while循環的輸出結果,而后4行則是自定義Foreach循環的輸出結果。可以明顯看出,在while循環的輸出結果中,可以顯示出索引,而Foreach的結果中只能顯示"?"。這是因為:與while語句不通,Foreach的循環實際上是在另一函數中執行的,因此無法從函數中訪問位於外部的局部變量 i。當然,如果 i 是一個全局變量就不存在這個問題了,不過為了這個目的而使用副作用很大的全局變量也並不是一個好主意。因此,“對外部(局部)變量的訪問”是函數指針(這里主要指委托)的最大弱點。
我們已經知道了函數指針的缺點,那么為了克服這個缺點,就可以開始認知這次的主題—閉包。
二、JavaScript閉包初探
談到閉包,得使用一種支持閉包的語言,而這方面,JavaScript絕對是棒棒噠!但是松本大叔說:要理解閉包,得先了解兩個術語:作用域和生存周期。
2.1 作用域(Scope)
作用域指的是變量的有效范圍,也就是某個變量可以被訪問的范圍。在JavaScript中,保留字var所表示的變量所表示的變量聲明所在的最內側代碼塊就是作用域的單位,而沒有進行顯示聲明的變量就是全局變量。
作用域是嵌套的,因此位於內側的代碼塊可以訪問以其自身為作用域的變量,以及以外側代碼塊為作用域的變量。
下圖中我們將匿名函數賦值給了一個變量(當然,如果不賦值而直接作為參數傳遞也是可以的),這個函數對象也有自己的作用域:
我靠,JavaScript中可以直接定義函數對象,那么,上面程序中的Foreach方法用JavaScript就可以更直接地寫出來。
function foreach(list, func) { while (list) { func(list.val); list = list.next; } } var list = null; // 變量聲明 for (var i = 0; i < 4; i++) { // list初始化 list = {val: i, next: list}; } var i = 0; // i初始化 // 從函數對象中訪問外部變量 foreach(list, function (n) { console.log("node(" + i + ")=" + n); i++; });
在JavaScript中,完成了C#中Foreach方法無法實現的索引實現功能。因此,從函數對象中能夠對外部變量進行訪問(引用、更新)是閉包的構成要件之一。
2.2 生存周期(Extent)
所謂生存周期,就是變量的壽命。相對於表示程序中變量可見范圍的作用域來說,生存周期這個概念指的是一個變量可以在多長的周期范圍內存在並能夠被訪問。
下圖中的一個例子是一個返回函數對象的函數extent(這個extent函數的返回值是一個函數對象)。函數對象會對extent中的一個局部變量n進行累加,並顯示它的值。
function extent() { var n = 0; // 局部變量 return function () { n++; console.log("n=" + n); // 對n的訪問 } } f = extent(); // 返回函數對象 f(); // n = 1 f(); // n = 2
下圖是在chrome瀏覽器中的log信息結果:
奇了怪了,局部變量n是在extent函數中聲明的,而extent函數已經執行完畢了,變量脫離了作用域之后不應該就消失了嗎?但是從結果來看,即便在函數執行完畢之后,局部變量n似乎還在某個地方繼續活着。
這就是生命周期,換句話說,這個從屬於外部作用域中的局部變量,被函數對象給“封閉”在里面了。閉包(Closure)原本就是封閉的意思,被封閉起來的變量的壽命,與封閉它的函數對象壽命相等(當封閉這個變量的函數不再被訪問,被GC回收掉時,那么這個變量也就壽終正寢了)。
在函數對象中,將局部變量這一環境封閉起來的結構被稱為閉包。因此,JavaScript的函數對象才是真正的閉包。
2.3 閉包與面向對象
當函數每次被執行時,作為隱藏上下文的局部變量n就會被引用和更新。也就是說,這意味着“函數(過程)與數據結合起來了”,它是形容面向對象中的“對象”時經常使用的表達。對象是在數據中以方法的形式內含了過程,而閉包則是在過程中以環境的形式內含了數據,即對象與閉包是同一事物的正反兩面。
上面的JavaScript程序如果采用面向對象來實現的話,就會變成下面的樣子:
function extent() { return { val: 0, call: function () { this.val++; console.log("val="+this.val); } }; } f = extent(); // 返回函數對象 f.call(); // val = 1 f.call(); // val = 2
運行結果如下圖,和閉包形式的結果一致。
三、.NET中的閉包
閉包可以體現在JavaScript中,帶來的好處是對變量的封裝和隱蔽,同時將變量的值保存在內存中。同樣,閉包也可以發生在.NET中。
3.1 借助匿名委托實現閉包
在.NET中,函數並不是第一級成員,所以並不能像JavaScript那樣通過在函數中內嵌子函數的方式實現閉包。通常而言,形成閉包有一些必要條件:
(1)嵌套定義的函數
(2)匿名函數
(3)將函數作為參數或者返回值
剛好,.NET中提供了匿名委托,可以用來形成閉包,請看下面一個例子:
delegate void MessageDelegate(); static void Main(string[] args) { string value = "Hello Closure"; MessageDelegate message = delegate() { Show(value); }; message(); Console.ReadKey(); } private static void Show(string message) { Console.WriteLine(message); }
反編譯上述代碼為IL代碼如下:
.class private auto ansi beforefieldinit Program extends [mscorlib]System.Object { .method private hidebysig static void Main(string[] args) cil managed { // 省略 } .class auto ansi sealed nested private beforefieldinit <>c__DisplayClass1 extends [mscorlib]System.Object { .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() .method public hidebysig specialname rtspecialname instance void .ctor() cil managed { // 省略 } .method public hidebysig instance void <Main>b__0() cil managed { // 省略 } .field public string value } }
可以看到,通過匿名方法將自動形成一個類,自由變量value被包裝在這個類中,並升級為實例成員(即使創建該變量的方法執行結束,它也不會被釋放,而是在所有回調函數執行之后才被GC回收),從而形成閉包。
自由變量value的生命周期也會隨之被延長,並不局限於一個局部變量。生命周期的延遲,是閉包帶來的福利,但是也往往帶來潛在的問題,造成更多的消耗。
3.2 閉包與函數的關系
像對象一樣操作函數,是閉包發揮的最大作用,從而實現了模塊化的編程方式。不過,閉包與函數並不是一回事兒:
(1)閉包是函數與其引用環境組合而成的實體。不同的引用環境和相同的函數可以組合產生不同的閉包實例。
(2)函數是一段可執行的代碼體,在運行時不會由於上下文環境發生變化。
3.3 閉包的福利與問題
在.NET中,閉包有着多方面的應用,典型的體現在以下幾個方面:
(1)定義控制結構,實現模塊化應用
static void Main(string[] args) { List<int> values = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; int result1 = 0; int result2 = 100; values.ForEach(x => result1 += x); values.ForEach(x => result2 -= x); Console.WriteLine(result1); Console.WriteLine(result2); Console.ReadKey(); }
運行結果是:
55
45
上例的ForEach方法為遍歷數組元素提供了數組基礎,對於加法和減法運算而言,在閉包中改變引用環境變量的值,達到最小粒度的模塊控制效果。看看是不是跟松本大叔在最開始提到的函數對象及其作用保持了一致?
(2)多個函數共享相同的上下文環境,進而實現通過上下文變量達到數據交流的作用
static void Main(string[] args) { int value = 100; IList<Func<int>> funcs = new List<Func<int>>(); funcs.Add(() => value + 1); funcs.Add(() => value - 2); foreach (var f in funcs) { value = f(); Console.WriteLine(value); } Console.ReadKey(); }
運行結果為:
101
99
數據共享為不同函數的操作間傳遞數據帶來了方便,但是它是一把雙刃劍,在不需要共享數據的場合又會帶來問題。還是通過上例,value變量將在兩次不同的操作中()=>value+1和()=>value-1間共享數據。如果不希望兩次操作間傳遞數據,需要注意引入中間量協調:
static void Main(string[] args) { int value = 100; IList<Func<int>> funcs = new List<Func<int>>(); funcs.Add(() => value + 1); funcs.Add(() => value - 2); foreach (var f in funcs) { int val = f(); Console.WriteLine(val); } Console.ReadKey(); }
這下結果就變為:
101
98
四、小結
閉包是優雅的,帶來代碼格局的函數式體驗;但是,閉包也是復雜的,帶來潛在的某些問題。TA就像一把雙刃劍,用好閉包的關鍵,在於深入地理解閉包,即在於揮劍人自己。
參考資料
(1)本文全文源自Ruby之父松本行弘的《代碼的未來》一書!
(2)王濤,《你必須知道的.NET》