這篇文章主要是複習一下 C# 委派(delegate)的基本觀念,同時也示範從 C# 1.0、2.0、到 3.0 的委派寫法。 我們會看到更直覺的建立委派物件的語法、匿名方法、以及 Lambda 表示式。
2015-01-15 更新:本文已收錄至電子書《C# 本事》
為什麼要用委派?
好,正式一點,我們將外包函式稱為「委派方法」。對類別設計者來說,這種設計方式可將那些變化不定的繁瑣細節從類別中移出去,使類別保持乾淨、穩定。
從呼叫端的角度來看:當你在使用某個類別時,該類別已經設計好一種模式,在你呼叫某個方法之前,它會要求你先提供一個符合特定簽名(signature;即參數與傳回值)的方法,才能達成你想要執行的工作。因此,即使你不是類別設計者,也要了解委派的用法。
傳統的委派寫法
這裡的傳統寫法指的是從 C# 1.0 就提供的委派寫法,這不是說到了 C# 3.0 就全變了樣--基本的程式撰寫模型還是一樣,只是寫法稍有變化。在撰寫委派機制時,基本上都離不開四個步驟:
StringList 類別大概會長這樣:
注意第 1 行的宣告,這一行就是前面說的步驟 1:宣告委派型別。這行程式碼的意思是:定義一個名為 Predicate 的委派型別,而這個委派型別所要「包裝」的函式必須傳入一個字串,並傳回一個布林值,代表該字串是否符合比對條件。 注意我說「委派型別」,是的,雖然只有一行,但這寫法確實是在定義一個類別--編譯器會將它編譯成一個繼承自 System.MulticastDelegate 的類別,而從這個父類別 MulticastDelegate 的名稱便可約略看出,這個委派型別的 instance(以下皆以「委派物件」稱之)可以一次引動(invoke)多個委派方法。這點稍後會再說明。
Find 方法需要傳入一個 Predicate 委派物件,它會用一個 for 迴圈逐一走訪串列中的每個字串,並透過該委派物件得知目前處理的字串是否符合呼叫端的比對條件。這也就是前面說的,StringList 的 Find 方法把字串比對的工作外包給呼叫端了,因為只有呼叫端才知道它想要找甚麼樣的字串。
StringList 類別設計好之後,接著來看用戶端會怎麼使用這個類別。我們會看到前面所說的四個步驟中的後面三個步驟。
由於我們的 StringList 類別已經預先內建了三個字串:"Apple"、"Mango"、"Banana",所以我們可以直接示範尋找以 "go" 結尾的字串。 範例程式碼如下:
注意第 10 行,也就是建立委派物件的程式碼,這行可以這樣理解:建立一個委派物件,這個委派物件會記住(保存)你提供的函式(此處即為 FindMango 方法),以便將來需要時可以呼叫它。但是,前面提過,委派型別是繼承自 System.MulticastDelegate 類別,這隱約透露著委派物件不只能記住一個函式。事實上,委派物件內部有一個串列,所以它能夠存放多個函式參考。如果你還是覺得不太明白,不妨把委派物件想像成一個代理人,這個代理人手上有一份工作清單,而你可以任意加入多項工作到這份清單裡,到時候只要呼叫這個代理人的 Invoke 方法,它就會逐一執行工作清單中的每一項任務。
以剛才的第 10 行程式碼來說,其作用就只是交代一項工作(指定一個函式參考)而已。如果要加入多項工作,就必須使用另一個運算子:+=。例如:
其記憶體布局如下圖所示:
我刻意重複加入了 FindMango,是為了強調:委派物件的呼叫清單不會濾掉重複的函式參考,亦即同一個函式可以重複加入多次。當你呼叫委派物件的 Invoke 方法,它就會逐一呼叫清單中的每一個函式。另外要牢記的是:在撰寫程式時,程式的執行結果絕對不可依賴這些委派函式的執行順序;它們的執行順序不見得是你認為的那樣。喔對了,既然有 +=,當然也有 -=;二者寫法相同,只是前者會將函式參考加入呼叫清單,後者則是從清單中移除函式參考。
以上就是 .NET 委派程式設計的基本觀念,也是 .NET 事件訂閱/發行的程式設計模型的基礎。我想談到這裡應該差不多了,接著來看 C# 2.0 和 3.0 的寫法。
C# 2.0 的寫法
C# 2.0 在建立委派物件的語法可以更簡潔、也更直覺。例如前面範例的第 10 行可以改成這樣:
沒錯!建立委派物件時不需要用 new 了,當編譯器看到變數的型別是委派型別時,便會自動幫你加上 new 的動作。因此,原本的程式碼和改寫後的程式碼所編譯成的 IL code 都完全一樣。了解這點之後,我們可以再改一下程式碼,將第 10~12 行合併為一行,像這樣:
用白話來解讀這行程式碼,可以這麼說:我要在一堆水果名稱中找找看有沒有芒果,而比對「芒果」的動作請用我提供的 FindMango 函式。由於你已經看過 C# 1.0 的委派寫法,所以你很清楚背後其實有建立委派物件的動作,但從表面上看來,這種寫法好像就只是在傳遞函式指標,對於有寫過 C/C++ 的人來說,應該會覺得很親切吧!(至少我是這麼覺得啦)正如前面所說的,你可以將委派物件想像成一個代理人,手上有一份你交代他要執行的函式(指標/參考),等到適當時機時,就可以透過他來執行這些事先指定的函式(請看第一個範例程式碼的第 22 行)。
C# 2.0 還增加了匿名方法(anonymous methods),所以 DelegateDemoVer1 範例程式碼還可以改寫成這樣:
你可以看到,原本步驟 2 的 FindMango 函式不見了,取而代之的是直接合併於步驟 3(建立委派物件)的程式碼中的匿名方法。附帶一提,如果匿名方法的程式碼太長(比如說,超過 20 行),我想還是明白定義成具名函式比較好。寫程式的方便性固然是我們想要的,但也應該同時顧及程式碼的易讀性。請再看一眼第 12 行的程式碼,問自己日後有沒有可能誤以為那個 return 是返回整個 Run 方法?
再強調一遍,C# 1.0 的委派寫法不是不能用了,也沒有所謂「標準寫法」,這裡只是要示範運用 C# 的新語法,你可以視需要選擇你認為最適合的寫法。
C# 3.0 的寫法
先把 DelegateDemoVer2 改寫後的程式碼列出來好了:
主要的改變在第 7 行,它取代了前一個使用匿名方法的範例程式碼的第 10~13 行。程式碼其實沒有省多少,因為原本的匿名方法其實也可以寫成一行。不過,不知道你的感覺是什麼,我第一次看到這樣的語法還真是覺得不適應--那個類似箭頭的等於加大於的符號(=>)是什麼啊?
這是 C# 3.0 的 Lambda 表示式--先別管它是什麼,就我們所知的線索,我們已經知道第 7 行所取代的程式碼,其作用是建立一個委派物件,並且內嵌一個委派方法,那麼我們可以這樣解讀:
既然寫起來沒有省多少打字工夫,解讀時還有點費力(看習慣之後應該就好了),那為什麼要用這種寫法?其實 Lambda 表示式還可以更簡潔:如果匿名方法的程式碼只有一行,我們可以把包住程式區塊的大括弧去掉,變成這樣:
此外,編譯器大都有辦法推測參數型別,因此,如果傳入的參數只有一個,我們甚至可以省掉參數的型別宣告,以及那一對小括弧。於是最終的版本可簡化成這樣:
是不是簡潔多了呢?
OK,現在可以來說一下甚麼是 Lambda 表示式了。如果前面講的你有看過且大致瞭解,那麼 MSDN 官方文件的這段文字應該就很清楚了:
簡單地說,Lambda Expression 讓程式設計師可以用更簡潔的語法來建立委派物件和匿名方法。
小結
在這篇文章裡,我用一個簡單的 StringList 類別當作範例,經過一路修改,我們看到了 C# 2.0 的新語法在委派程式設計方面的改進(更直覺),也碰觸到了 C# 3.0 新增的 Lambda 表示式(更簡潔,但你的眼睛得想辦法適應)。不過,這篇的主角還是委派,所以 Lambda 的部分就點到為止,也許下次吧。Happy coding :)
2015-01-15 更新:本文已收錄至電子書《C# 本事》
相關文章
2015-01-15 更新:本文已收錄至電子書《C# 本事》
為什麼要用委派?
從類別設計者的角度來看:在設計類別時,可能會碰到某個方法在執行時需要額外的處理,但你不想/無法將這部份的處理寫死在類別裡(因為變化太多或無法預先得知其處理規則),此時就得將這個部分「外包」給呼叫端。也就是說,呼叫端必須事先提供(註冊)一個函式,等到你的方法在執行時,就會回頭去呼叫(callback)那個事先指定的外包函式。
好,正式一點,我們將外包函式稱為「委派方法」。對類別設計者來說,這種設計方式可將那些變化不定的繁瑣細節從類別中移出去,使類別保持乾淨、穩定。
從呼叫端的角度來看:當你在使用某個類別時,該類別已經設計好一種模式,在你呼叫某個方法之前,它會要求你先提供一個符合特定簽名(signature;即參數與傳回值)的方法,才能達成你想要執行的工作。因此,即使你不是類別設計者,也要了解委派的用法。
傳統的委派寫法
這裡的傳統寫法指的是從 C# 1.0 就提供的委派寫法,這不是說到了 C# 3.0 就全變了樣--基本的程式撰寫模型還是一樣,只是寫法稍有變化。在撰寫委派機制時,基本上都離不開四個步驟:
- 宣告委派型別。你需要使用關鍵字 delegate 來定義委派型別的名稱,以及傳入參數和傳回值。
- 定義一個符合委派型別的 signature 的方法(可為 instance method 或 static method),這裡簡稱為委派方法。
- 建立委派物件,並指定委派方法。
- 透過委派物件執行委派方法。
StringList 類別大概會長這樣:
1: public delegate bool Predicate(string s); // 步驟 1: 定義委派型別. 2: 3: public class StringList 4: { 5: // 我知道用 ArrayList 看起來有點笨,但我想還是先不要把泛型扯進來。 6: private ArrayList strings; 7: 8: public StringList() 9: { 10: // 在建構元裡面就填好字串內容...只是為了示範,實際上通常不會這樣寫. 11: strings = new ArrayList(); 12: strings.Add("Banana"); 13: strings.Add("Apple"); 14: strings.Add("Mango"); 15: } 16: 17: public string Find(Predicate p) 18: { 19: for (int i = 0; i < strings.Count; i++) 20: { 21: string s = (string) strings[i]; 22: bool isMatch = p(s); // 步驟 4: 執行委派任務. 等同於 p.Invoke(s) 23: if (isMatch) // 目前的字串符合呼叫端的比對條件? 24: { 25: return s; 26: } 27: } 28: return ""; // 找不到,傳回空字串 29: } 30: }
注意第 1 行的宣告,這一行就是前面說的步驟 1:宣告委派型別。這行程式碼的意思是:定義一個名為 Predicate 的委派型別,而這個委派型別所要「包裝」的函式必須傳入一個字串,並傳回一個布林值,代表該字串是否符合比對條件。 注意我說「委派型別」,是的,雖然只有一行,但這寫法確實是在定義一個類別--編譯器會將它編譯成一個繼承自 System.MulticastDelegate 的類別,而從這個父類別 MulticastDelegate 的名稱便可約略看出,這個委派型別的 instance(以下皆以「委派物件」稱之)可以一次引動(invoke)多個委派方法。這點稍後會再說明。
Find 方法需要傳入一個 Predicate 委派物件,它會用一個 for 迴圈逐一走訪串列中的每個字串,並透過該委派物件得知目前處理的字串是否符合呼叫端的比對條件。這也就是前面說的,StringList 的 Find 方法把字串比對的工作外包給呼叫端了,因為只有呼叫端才知道它想要找甚麼樣的字串。
StringList 類別設計好之後,接著來看用戶端會怎麼使用這個類別。我們會看到前面所說的四個步驟中的後面三個步驟。
由於我們的 StringList 類別已經預先內建了三個字串:"Apple"、"Mango"、"Banana",所以我們可以直接示範尋找以 "go" 結尾的字串。 範例程式碼如下:
1: /// <summary> 2: /// 示範 C# 1.0 的委派寫法. 3: /// </summary> 4: public class DelegateDemoVer1 5: { 6: public void Run() 7: { 8: StringList fruits = new StringList(); 9: 10: Predicate p = new Predicate(FindMango); // 步驟 3: 建立委派物件 11: 12: string s = fruits.Find(p); 13: 14: Console.WriteLine(s); 15: } 16: 17: // 步驟 2: 撰寫符合委派型別所宣告的委派方法。 18: bool FindMango(string s) 19: { 20: return s.EndsWith("go"); 21: } 22: }
注意第 10 行,也就是建立委派物件的程式碼,這行可以這樣理解:建立一個委派物件,這個委派物件會記住(保存)你提供的函式(此處即為 FindMango 方法),以便將來需要時可以呼叫它。但是,前面提過,委派型別是繼承自 System.MulticastDelegate 類別,這隱約透露著委派物件不只能記住一個函式。事實上,委派物件內部有一個串列,所以它能夠存放多個函式參考。如果你還是覺得不太明白,不妨把委派物件想像成一個代理人,這個代理人手上有一份工作清單,而你可以任意加入多項工作到這份清單裡,到時候只要呼叫這個代理人的 Invoke 方法,它就會逐一執行工作清單中的每一項任務。
以剛才的第 10 行程式碼來說,其作用就只是交代一項工作(指定一個函式參考)而已。如果要加入多項工作,就必須使用另一個運算子:+=。例如:
Predicate p = new Predicate(FindMango);
p += new Predicate(FindApple);
p += new Predicate(FindMango);
其記憶體布局如下圖所示:
我刻意重複加入了 FindMango,是為了強調:委派物件的呼叫清單不會濾掉重複的函式參考,亦即同一個函式可以重複加入多次。當你呼叫委派物件的 Invoke 方法,它就會逐一呼叫清單中的每一個函式。另外要牢記的是:在撰寫程式時,程式的執行結果絕對不可依賴這些委派函式的執行順序;它們的執行順序不見得是你認為的那樣。喔對了,既然有 +=,當然也有 -=;二者寫法相同,只是前者會將函式參考加入呼叫清單,後者則是從清單中移除函式參考。
以上就是 .NET 委派程式設計的基本觀念,也是 .NET 事件訂閱/發行的程式設計模型的基礎。我想談到這裡應該差不多了,接著來看 C# 2.0 和 3.0 的寫法。
C# 2.0 的寫法
C# 2.0 在建立委派物件的語法可以更簡潔、也更直覺。例如前面範例的第 10 行可以改成這樣:
10: Predicate p = FindMango; // 步驟 3: 建立委派物件
沒錯!建立委派物件時不需要用 new 了,當編譯器看到變數的型別是委派型別時,便會自動幫你加上 new 的動作。因此,原本的程式碼和改寫後的程式碼所編譯成的 IL code 都完全一樣。了解這點之後,我們可以再改一下程式碼,將第 10~12 行合併為一行,像這樣:
10: string s = fruits.Find(FindMango); // C# 2.0 在使用委派物件時更直覺!
用白話來解讀這行程式碼,可以這麼說:我要在一堆水果名稱中找找看有沒有芒果,而比對「芒果」的動作請用我提供的 FindMango 函式。由於你已經看過 C# 1.0 的委派寫法,所以你很清楚背後其實有建立委派物件的動作,但從表面上看來,這種寫法好像就只是在傳遞函式指標,對於有寫過 C/C++ 的人來說,應該會覺得很親切吧!(至少我是這麼覺得啦)正如前面所說的,你可以將委派物件想像成一個代理人,手上有一份你交代他要執行的函式(指標/參考),等到適當時機時,就可以透過他來執行這些事先指定的函式(請看第一個範例程式碼的第 22 行)。
C# 2.0 還增加了匿名方法(anonymous methods),所以 DelegateDemoVer1 範例程式碼還可以改寫成這樣:
1: /// <summary> 2: /// 示範 C# 2.0 的委派寫法. 3: /// </summary> 4: public class DelegateDemoVer2 5: { 6: public void Run() 7: { 8: StringList fruits = new StringList(); 9: 10: Predicate p = delegate(string s) // 步驟 3: 建立委派物件(使用匿名方法) 11: { 12: return s.EndsWith("go"); 13: }; 14: Console.WriteLine(fruits.Find(p)); 15: } 16: }
你可以看到,原本步驟 2 的 FindMango 函式不見了,取而代之的是直接合併於步驟 3(建立委派物件)的程式碼中的匿名方法。附帶一提,如果匿名方法的程式碼太長(比如說,超過 20 行),我想還是明白定義成具名函式比較好。寫程式的方便性固然是我們想要的,但也應該同時顧及程式碼的易讀性。請再看一眼第 12 行的程式碼,問自己日後有沒有可能誤以為那個 return 是返回整個 Run 方法?
再強調一遍,C# 1.0 的委派寫法不是不能用了,也沒有所謂「標準寫法」,這裡只是要示範運用 C# 的新語法,你可以視需要選擇你認為最適合的寫法。
C# 3.0 的寫法
先把 DelegateDemoVer2 改寫後的程式碼列出來好了:
1: public class DelegateDemoVer3 2: { 3: public void Run() 4: { 5: StringList fruits = new StringList(); 6: 7: Predicate p = (string s) => { return s.EndsWith("go"); }; // 步驟 3: 建立委派物件(C# 3.0 only) 8: Console.WriteLine(fruits.Find(p)); 9: } 10: }
主要的改變在第 7 行,它取代了前一個使用匿名方法的範例程式碼的第 10~13 行。程式碼其實沒有省多少,因為原本的匿名方法其實也可以寫成一行。不過,不知道你的感覺是什麼,我第一次看到這樣的語法還真是覺得不適應--那個類似箭頭的等於加大於的符號(=>)是什麼啊?
這是 C# 3.0 的 Lambda 表示式--先別管它是什麼,就我們所知的線索,我們已經知道第 7 行所取代的程式碼,其作用是建立一個委派物件,並且內嵌一個委派方法,那麼我們可以這樣解讀:
建立一個委派物件,此委派物件內部要保存一個函式參考,該函式是一個用大括弧 { } 包住的匿名方法,而此匿名方法需要傳入(=>)一個字串參數。嗯,把 => 符號解讀成「把左邊的參數傳入右邊的匿名方法」,這種理解方式應該有點幫助 ;腦袋先能轉換,看的時候就不會覺得刺眼了。事實上,在MSDN 官方文件中就有說,這個 => 符號是讀作 "goes to"。
既然寫起來沒有省多少打字工夫,解讀時還有點費力(看習慣之後應該就好了),那為什麼要用這種寫法?其實 Lambda 表示式還可以更簡潔:如果匿名方法的程式碼只有一行,我們可以把包住程式區塊的大括弧去掉,變成這樣:
7: Predicate p = (string s) => s.EndsWith("go");
此外,編譯器大都有辦法推測參數型別,因此,如果傳入的參數只有一個,我們甚至可以省掉參數的型別宣告,以及那一對小括弧。於是最終的版本可簡化成這樣:
7: Predicate p = s => s.EndsWith("go");
是不是簡潔多了呢?
OK,現在可以來說一下甚麼是 Lambda 表示式了。如果前面講的你有看過且大致瞭解,那麼 MSDN 官方文件的這段文字應該就很清楚了:
「Lambda 運算式」(Lambda Expression) 是一種匿名函式,它可以包含運算式和陳述式 (Statement),而且可以用來建立委派 (Delegate) 或運算式樹狀架構型別。
簡單地說,Lambda Expression 讓程式設計師可以用更簡潔的語法來建立委派物件和匿名方法。
小結
在這篇文章裡,我用一個簡單的 StringList 類別當作範例,經過一路修改,我們看到了 C# 2.0 的新語法在委派程式設計方面的改進(更直覺),也碰觸到了 C# 3.0 新增的 Lambda 表示式(更簡潔,但你的眼睛得想辦法適應)。不過,這篇的主角還是委派,所以 Lambda 的部分就點到為止,也許下次吧。Happy coding :)
2015-01-15 更新:本文已收錄至電子書《C# 本事》
相關文章
謝謝您的文章,讓我對委派機制更清楚
回覆刪除小星您好,
回覆刪除很高興這篇文章對你有幫助 :)
Happy 牛 Year!
nice article and easy to understand
回覆刪除thanks.
typo:C# 2.0 還增加了匿名方法(anonymous methods),所以 DelefateDemoVer1 範例程式碼還可以改寫成這
高手
回覆刪除我一直不是很了解Delegate的用法和使用時機以及c#3.0的Lambda,看了這篇文章後,有種頓悟的感覺,謝謝~
回覆刪除解說真是詳盡、簡捷又明瞭。作者一定花了不少時間撰寫本文。最近研究C#.NET,看到Delegate真是霧煞煞。從前用慣了Powerbuilder內定的EVENT呼叫方式,看Delegate實在搞不懂。
回覆刪除謝謝,很棒的文章,受益良多。
回覆刪除=> 函式導向語言使用
回覆刪除可以看一下Haskell或其他函式導向語言語法。
google到這篇教學文章,是我看過最棒的!
回覆刪除這位大大,寫得太棒了!
回覆刪除一看就懂,建議您出書企吧!
小弟一定捧場的!!!
Sorry,小弟只專注於這篇文章~~~
回覆刪除不識大師級的您,早就有大作了!
不知大師您是否有LINQ的大作,亦或建議的工具書?!
「大師」不敢當,您太客氣啦!
回覆刪除可以參考看看:LINQ最佳實務講座-by 呂高旭 (悅知)
想請問一下Michael 大哥就是有關我在拿你的實例來練習時,demov1的版本這句Predicate p = new Predicate(FindMango);
回覆刪除C#會出現:需要有物件參考,才能使用非靜態欄位、屬性、方法,請問我是否那邊沒處理到呢
你可以檢查一下,是不是在的 static 方法中呼叫物件方法?
回覆刪除請確定是否有建立物件,然後才呼叫方法,像這樣:
DelegateDemoVer1 demo = new DelegateDemoVer1()
demo.Run();
之前看了一堆Delegate的文章越看越糊塗,
回覆刪除今天拜讀您的文章,看一次就通了。
相當感謝!
很高興對您有幫助 ^^
回覆刪除Dear Huanlin Tsai,
回覆刪除雖然拜讀後,對Invoke and Delegate的了解有很大的幫助,
但以下的程式語法是怎麼化簡的還是弄不太清楚。
尤其是Delegate{}加大括弧。
有辦法由簡轉繁嗎?
原程式http://coad.net/Blog/Resources/SerialPortTerminal.zip
///RichTextBox rtfTerminal
private void Log(LogMsgType msgtype, string msg)
{
rtfTerminal.Invoke(new EventHandler(delegate
{
rtfTerminal.SelectedText = string.Empty;
rtfTerminal.SelectionFont = new Font(rtfTerminal.SelectionFont, FontStyle.Bold);
rtfTerminal.SelectionColor = LogMsgTypeColor[(int)msgtype];
rtfTerminal.AppendText(msg);
rtfTerminal.ScrollToCaret();
}));
}
這是化簡為繁版:
回覆刪除private void Log(LogMsgType msgtype, string msg)
{
EventHandler handler = new EventHandler(MyEventHandler);
rtfTerminal.Invoke(handler);
}
void MyEventHandler(object sender, EventArgs e)
{
rtfTerminal.SelectedText = string.Empty;
rtfTerminal.SelectionFont = new Font(rtfTerminal.SelectionFont, FontStyle.Bold);
rtfTerminal.SelectionColor = LogMsgTypeColor[(int)msgtype];
rtfTerminal.AppendText(msg);
rtfTerminal.ScrollToCaret();
}
Dear Huanlin Tsai,
回覆刪除這樣對Invoke和Delegate初學的我又有更進一步的認識了。
很感謝您的註解!
Dear Shallow,
回覆刪除Glad it helped ^_^
大師您好,閱讀您的文章,真的太清楚了,讓我這剛開始學 C# 的人都瞭解,所謂:"前人種樹後人乘涼"。
回覆刪除不敢不敢! 很高興對你有幫助 ^_^
回覆刪除找了好多委派的文章都看不懂,看了大大的文章真是有如神助~!
回覆刪除非常謝謝,看了您的文章終於看懂委派了。
回覆刪除很高興有幫助 :)
回覆刪除好文!!感謝
回覆刪除非常感謝,解了我多年之謎
回覆刪除很高興有幫助 :)
刪除文章超清楚,仔細一看原來是我有買過書的原作者,難怪...我只買好書,哈哈
回覆刪除多謝支持 ^^
回覆刪除您的文章真是寫的超級棒~~我之前看過一堆文章都看的似懂非懂~~只有您的文章清楚明白,讚讚讚~~忍不住要上來推一下!
回覆刪除感謝您的支持!! ^_^
回覆刪除