0. 文章目的 本文面向有一定.NET C#基礎知識的學習者,介紹.NET中事件的相關概念、基本知識及其使用方法 1. 閱讀基礎 理解C#基本語法(方法的聲明、方法的調用、類的定義) 2. 從委托說起,到底什麼是事件 2.1 方法與委托 (1)從一個案例開始說起 在討論本節主題之前,我們先來看一個實 ...
0. 文章目的
本文面向有一定.NET C#基礎知識的學習者,介紹.NET中事件的相關概念、基本知識及其使用方法
1. 閱讀基礎
理解C#基本語法(方法的聲明、方法的調用、類的定義)
2. 從委托說起,到底什麼是事件
2.1 方法與委托
(1)從一個案例開始說起
在討論本節主題之前,我們先來看一個實際問題。下麵是一個方法,作用是把兩個值相加,然後將相加的結果通過控制台程式列印出來,接著再返回相加的值:
int Add(int a, int b)
{
int n = a + b;
Console.WriteLine(n);
return n;
}
這個方法很簡單,它在你的代碼中跑的很好。但需求總是會不斷變化的,現在新的需求來了:你希望可以把結果列印到一個文件里,而不是在控制臺上列印。這對你來說也很簡單,你打開了定義此方法的文件,然後做出了修改:
int Add(int a, int b)
{
int n = a + b;
Log.WriteToFile(n);
return a + b;
}
(請不要在意Log.WriteToFile方法是否真的存在)
這一次修改後,這個方法順利地跑了幾天。然而...是的,新的需求又來了,這你發現自己需要兩個Add方法,一個版本可以通過控制台列印相加結果,另一個版本則可以將相加結果寫入文件。這對你來說依然不難,你很快做出了以下修改:
int Add1(int a, int b)
{
int n = a + b;
Console.WriteLine(n);
return a + b;
}
int Add2(int a, int b)
{
int n = a + b;
Log.WriteToFile(n);
return a + b;
}
方法名似乎有點隨意,但它們可以正確運行。但經歷了兩次修改後你意識到如果之後還有類似的需求,修改代碼的成本會越來越高。同時這時你發現了一個問題:Add1和Add2似乎有重覆的代碼,遵循應當儘可能減少重覆代碼,你決定將重覆的代碼抽出來單獨成方法:
int Add1(int a, int b)
{
int n = AddCore(a, b);
Console.WriteLine(n);
return a + b;
}
int Add2(int a, int b)
{
int n = AddCore(a, b);
Log.WriteToFile(n);
return a + b;
}
int AddCore(int a, int b)
{
return a + b;
}
然而這似乎有點不太對勁:整個代碼不僅一行都沒有變少,反而還增加了複雜度。
(2)著手解決
顯然,問題的根本不在於那一行簡單的a + b
,現在回過來觀察一下兩個方法:
int Add1(int a, int b)
{
int n = a + b;
Console.WriteLine(n);
return a + b;
}
int Add2(int a, int b)
{
int n = a + b;
Log.WriteToFile(n);
return a + b;
}
你發現兩個方法做的事基本相同,唯一的不同是它們對運算結果的輸出方式不同 - 一個通過控制台顯示,一個將結果寫入文件。這時你意識到:能否把這種輸出方式‘委托’出去,而不是在代碼中具體定義?或者說,把輸出方式像方法的參數一樣傳遞進去,在調用時自行決定使用什麼方法輸出。這樣,到底要通過控制台顯示還是寫入到文件,就可以在調用時才決定,就像下麵這樣:
int Add(int a, int b, 用來輸出用的方法)
{
int n = a + b;
調用用來輸出用的方法,並把n的值作為方法的參數,讓方法處理對n的值的輸出
return a + b;
}
要實現此目的,就需要使用.NET中的‘委托’機制。在C#中,委托的使用就類似於下麵這樣:
int Add(int a, int b, OutputFunction of)
{
int n = a + b;
of(n);
return a + b;
}
這裡我們假設OutputFunction是一個方法的委托。這樣,輸出的實際行為就可以由OutputFunction類型的of參數完成。你可以像下麵這樣使用Add方法:
Add(1, 2, Console.WriteLine); // 相當於用Console.WriteLine替換of
Add(1, 2, Log.WriteToFile); // 相當於用Log.WriteToFile替換of
(從更廣泛的概念來說,這一行為被稱之為函數回調。 )
可以認為,委托其實就是方法的代表,它用來表示了某個具體的方法。這並不奇怪,用委托表示具體方法就應該如同使用變數表示數字一樣自然:
int n = 1;
OutputFunction of = Console.WriteLine;
(3)定義委托
方法的調用只需要知道方法簽名,因此要代表方法,委托也只需要能表示方法簽名即可。實際上,委托只需要匹配方法的返回類型和參數列表即可(因為方法名已經由委托類型的變數名所替代)。因此,一個簡單的的委托定義如下:
delegate void MyDelegate(int n);
你可以委托的聲明很像方法聲明,唯一不同的是使用關鍵字deleagete指明瞭它是一個委托。這個委托可以代表的方法應該是這樣的:
- 方法沒有返回值
- 接受一個int類型的參數
回到上面的例子,如果你希望通過OutputFunction來作為代表輸出方法的委托,那麼OutputFunction的定義應該如下:
delegate void OutputFunction(int n);
(4)封裝委托
你找到了Add方法的修改方式,你決定通過委托機制對其進行封裝,現在,你將其封裝到一個Math類中,並用一個OutputFunction類型的委托欄位Printer來代表輸出行為。結合上述,Math類定義如下:
delegate void OutputFunction(int n);
class Math
{
public OutputFunction Printer;
public int Add(int a, int b)
{
int n = a + b;
Printer(n);
return a + b;
}
}
這樣,便可以像下麵這樣使用Math類:
Math math = new Math();
math.Printer = Console.WriteLine; // Printer現在代表Console.WriteLine
int n = math.Add(1, 2);
math.Printer = Log.WriteToFile; // Printer現在代表Log.WriteToFile
int m = math.Add(1, 2);
現在,你不用再擔心因為需求的變動而反覆修改Add方法了,輸出行為已經被‘委托’出去,具體要如何輸出可以在調用時輕鬆決定。
2.2 多播委托
通過上面的例子你應該對委托有了一定的基本認識。下麵再來考慮一個新的需求:如何把相加結果輸出到控制台的同時還要列印到文件里呢?一種方法是,使用一個方法包裝一下兩種輸出方法,就像下麵這樣:
void PrintAndSave(int n)
{
Console.WriteLine(n);
Log.WriteToFile(n);
}
math.Printer = PrintAndSave; // Printer現在代表PrintAndSave了
int n = math.Add(1, 2);
這是可以的,但這會帶來許多不便,其中一點就是,如果你的委托已經在某個地方被賦值了並且進行了封裝,那麼其他人在使用你的類的時候就難以正確地修改被委托的方法。為瞭解決這個矛盾,考慮另一種解決思路:不是聲明一個委托,而是聲明一個委托列表,並依次調用列表中被委托的方法,如下:
class Math
{
public List<OutputFunction> Printers = new List<OutputFunction>();
public int Add(int a, int b)
{
int n = a + b;
// 依次調用列表中被委托的方法
for (int i = 0; i < Printers.Count; i++)
{
Printers[i](n);
}
return a + b;
}
}
這樣就可以像下麵這樣使用:
Math math = new Math();
math.Printers.Add(Console.WriteLine);
math.Printers.Add(Log.WriteToFile);
int m = math.Add(1, 2);
上面這種實現實際就類似於所謂‘多播委托’的工作方式。多播委托的表現類似於委托列表,但是它的優點在於可以用更簡潔的語法完成類似工作。將上面的例子改為使用多播委托,則多播委托的聲明可以簡化為如下:
class Math
{
public OutputFunction Printers;
public int Add(int a, int b)
{
int n = a + b;
Printers(n);
return a + b;
}
}
其調用方法如下:
Math math = new Math();
math.Printers += Console.WriteLine;
math.Printers += Log.WriteToFile;
int n = math.Add(1, 2);
你可能會註意到類中的定義多播委托和定義普通的委托的委托類型完全一樣,唯一的區別似乎只在於在使用時需要使用+=符號來為多播委托添加方法(也就是+=的表現類似於對列表使用Add方法),而非使用=符號進行直接賦值。這不是書寫錯誤,而是由於歷史原因,C#中所有通過delegate聲明出來的委托都是多播委托。+=與-=做的事就是將委托加入或移出委托列表。
(4)委托就僅此而已嗎?
上面的例子的目的僅僅是為了從一個更抽象的概念上理解委托與多播委托,實際上C#中的委托還有很多可探究的地方,例如委托本質其實是一個類(Delegate),而多播委托(MulticastDelegate)是Delegate的子類,並且多播委托的實現也並非只是簡單使用一個委托列表,它的實現依賴於一種更為複雜的被稱為委托鏈的機制(在概念上更像是鏈表)。如果希望更進一步理解委托,可以參考.NET的源碼實現。
2.3 事件
(1)本質:對委托的封裝
現在會過來看之前的Math類:
class Math
{
public OutputFunction Printers;
public int Add(int a, int b)
{
int n = a + b;
Printers(n);
return a + b;
}
}
上面例子中使用一個Printers欄位作為(多播)委托,這樣做存在許多問題,其中一個最明顯的問題在於這個欄位可以被賦值,被賦值後不僅原有的委托鏈將會丟失,還可能導致null異常。也就是說,下麵的情況是有可能會發生的:
Math math = new Math();
math.Printers += Console.WriteLine;
math.Printers += Log.WriteToFile;
int n = math.Add(1, 2);
math.Printers = null;
int m = math.Add(1, 2); // 報錯
在上面的例子中,Printers被賦值為null後,之後的代碼將會在運行時報錯。原因在於此時Printers已經為null,此時Add方法中對其進行調用將會引發null異常。一個解決辦法是在調用前進行null檢查:
class Math
{
public OutputFunction Printers;
public int Add(int a, int b)
{
int n = a + b;
if (Printers != null)
{
Printers(n);
}
return a + b;
}
}
然而這依然無法解決委托鏈丟失的問題:在實際情況中,委托鏈的修改可能會在多個地方進行,不瞭解委托鏈的修改情況而隨意丟失委托鏈很可能導致程式的工作不符合預期。因此,有必要阻止外部對委托進行直接賦值,對於這類‘避免外部直接修改欄位’的問題,通常可以先考慮使用屬性:
class Math
{
public OutputFunction Printers { get; private set; }
public int Add(int a, int b)
{
int n = a + b;
if (Printers != null)
{
Printers(n);
}
return a + b;
}
}
你可能會認為上面這樣就可以避免Printers被直接賦值。事實上也確實如此,然而這會導致一個更為嚴重的問題:無法修改委托鏈。也就是說,+=與-=符也將無法使用,因為兩者實際執行的操作是將當前委托與目標委托使用Delegate類的Combine或Remove靜態方法進行組合後重新賦值,如下:
// math.Printers += Console.WriteLine
math.Printers = (OutputFunction)Delegate.Combine(math.Printers, new OutputFunction(Console.WriteLine));
// math.Printers -= Console.WriteLine
math.Printers = (OutputFunction)Delegate.Remove(math.Printers, new OutputFunction(Console.WriteLine));
(如果你覺得上面的例子難以理解,沒有關係,只需要註意到上面的操作中存在賦值符號=即可)
顯然,無法簡單地通過將委托欄位使用屬性包裝來解決問題。實際上,即便可以,也還有很多問題需要解決問題,例如,如何避免多播委托的委托鏈被外部意外修改?或者,我們可能需要控制委托的調用時機,不能讓委托被隨意調用。因此,有必要通過其他手段對委托進行封裝,一種封裝思路是,將委托設置為私有欄位,然後只暴露兩個方法用於將目標委托添加和移出委托鏈,就像下麵這樣:
class Math
{
private OutputFunction? _printers;
public void AddPrinter(OutputFunction of)
{
_printers += of;
}
public void RemovePrinter(OutputFunction of)
{
_printers -= of;
}
// ... 省略其他代碼
}
這樣,外部對於委托欄位的控制權就大大減小了。顯然,C#的設計者也想到了這種方法,並提供了更標準的封裝方式,這種使用了類似於上述封裝方式的委托便被稱之為‘事件’。利用C#提供的定義事件的語法,可以將上面的委托封裝修改為如下所示:
class Math
{
public event OutputFunction Printers
{
add
{
_printers += value;
}
remove
{
_printers -= value;
}
}
private OutputFunction? _printers;
}
同樣,就如同屬性有自動屬性這樣的簡化聲明語法一樣,事件也有簡化聲明語法,其簡化聲明語法如下:
class Math
{
public event OutputFunction Printers;
}
是的,聲明事件和聲明委托欄位的區別僅僅在於簡單地添加了一個event關鍵字。但請記住這隻是簡化語法,其本質行為依然依賴於事件的完整聲明語法以及其封裝邏輯。
(2)使用:就像使用委托一樣簡單
事件的使用和多播委托完全一致,唯一的區別在於在事件的聲明類之外,只允許添加與移出特定委托(即便是子類也是如此):
class Math
{
public event OutputFunction Printers;
public int Add(int a, int b)
{
int n = a + b;
if (Printers != null)
{
// 在事件的聲明類中,可以引發事件
// 實際上對於類內部來說,可以像對待多播委托一樣對待事件
Printers(n);
}
return a + b;
}
}
Math math = new Math();
math.Printers += Console.WriteLine;
math.Printers += Log.WriteToFile;
int n = math.Add(1, 2);
math.Printers = null; // 直接給事件賦值,是不允許的操作
math.Printers(); // 嘗試從外部引發事件,同樣是不允許的操作
你可能註意到上述例子中使用了‘引發事件’這一說法,實際上它就是指讓事件背後的多播委托調用委托鏈;而+=操作就是將委托添加到委托鏈中,這一行為被稱為‘訂閱事件’,與之相對的與-操作就是將委托從委托鏈中移出,這一行為被稱為“取消訂閱事件”;而+=後的方法也被稱之為‘事件處理程式’,事件處理程式會被委托包裝後添加到事件背後的委托鏈中。
你可能會好奇到底是‘委托’訂閱事件還是‘事件處理程式’訂閱事件,答案是委托。儘管語法上看起來是事件處理程式訂閱了委托,但在編譯時編譯器會將其使用委托包裝起來,也就是說,類似於如下:
math.Printers += Console.WriteLine;
// 上述代碼實際含義如下
math.Printers += new OutputFunction(Console.WriteLine);
基於上述,可以認為訂閱事件的本質就是將委托添加到事件背後的多播委托的委托鏈中,取消訂閱事件則是將委托從委托鏈中移出,而事件的引發的本質就是對委托鏈中的委托進行逐一調用
(3)基於委托,但比委托更嚴格
儘管事件基於委托,並且可以只使用委托與方法的封裝來模擬事件,但是事件應當遵循以下規則:
- 使用沒有返回值的委托。事件的本質是多播委托,也就是引發事件實際就是逐一調用委托鏈中的方法,這意味著從多播委托中獲取的返回值無法確定到底來自於哪個方法(如果真的有類似需求,應考慮其他實現方式),使用這種返回值可能帶來不確定的後果。
- 不依賴委托鏈的執行順序。也就是說,不應該假定事件背後的多播委托的委托鏈以何種順序調用委托,並根據此假設來執行某種操作。
3. 符合.NET準則的事件
3.1 定義
要更好地使用事件,應當定義符合.NET準則的事件。這並不難,要求只有一點:使用基於EventHandler的委托類型。EventHandler委托聲明如下:
public delegate void EventHandler(object sender, EventArgs e);
sender表示事件的發送方,通常情況下就是指類的實例(也就是說,this),EventArgs表示事件引發時的附加參數。下麵是一個符合.NET準則的事件聲明:
public event EventHandler MyEvent;
然而這是遠遠不夠的,因為EventArgs是一個非常簡單的類,它不提供任何有意義的附加信息,這意味著你需要定義自己的EventArgs來傳遞所需要的參數,並定義使用自定義EventArgs的EventHandler委托。
(1)定義EventArgs事件參數
作為規範,自定義的EventArgs應滿足下麵兩個要求:
- 派生自EventArgs
- 以事件名+EventArgs作為類名
現在假定有一個NewMessageArrived事件,則下麵是一個用於該事件的自定義EventArgs的示例,此EventArgs擁有一個string類型的Message屬性:
public class NewMessageArrivedEventArgs : EventArgs
{
public string Message { get; }
public NewMessageArrivedEventArgs(string message)
{
Message = message;
}
}
(2)定義EventHandler委托
定義好EventArgs後,還需要定義相應的委托,作為規範,委托的定義滿足以下要求:
- 返回值於參數列表形如EventHandler
- 以事件名+EventHandler為委托名
下麵是一個用於NewMessageArrived事件的自定義的EventHandler,該委托使用NewMessageArrivedEventArgs代替了原來的EventArgs:
public delegate void NewMessageArrivedEventHandler(object sender, NewMessageArrivedEventArgs e);
這樣,結合上述的自定義EventArgs與EventHandler,可以聲明一個符合.NET準則的事件:
class Messenger
{
public event NewMessageArrivedEventHandler NewMessageArrived;
}
此外,除了手動聲明委托外,還有一種做法是使用泛型EventHandler<>,其接受一個泛型參數作為事件附加參數的參數類型,泛型委托EventHandler<>的定義如下:
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);
因此可以像下麵這樣來聲明委托類型:
class Messenger
{
public event EventHandler<NewMessageArrivedEventArgs> NewMessageArrived;
}
通過泛型委托,可以簡化委托的聲明。
(3)定義事件引發方法
所謂事件的引發方法就是用於間接引發事件的方法封裝。這一定義不是必須的,但定義事件的引發方法有助於事件的使用,通常,事件的引發方法應該符合以下規則:
1. 以On+事件名為方法名
2. 只引發對應的事件
例如,對於NewMessageArrived事件,可以定義如下的事件引發方法:
void OnNewMessageArrived(string message)
{
if (NewMessageArrived != null)
{
NewMessageArrived(this, new NewMessageArrivedEventArgs(message));
}
}
同時,可使用空值傳播運算符與Invoke方法簡化判空操作:
void OnNewMessageArrived(string message)
{
NewMessageArrived?.Invoke(this, new NewMessageArrivedEventArgs(message));
}
在需要引發事件時,便可以通過調用此方法來引發事件。
class Messenger
{
public event EventHandler<NewMessageArrivedEventArgs> NewMessageArrived;
public void FetchMessage()
{
string message = ...
OnNewMessageArrived(message);
}
}
定義事件引發方法的一個明顯的優點是,如果引發方法的訪問修飾符是protected或者public,那麼便可以讓子類甚至外部引發相應的事件,這在某些時候可能有助於解決某些問題。此外,在某些時候可能有助於減小生成的IL碼的體積。
4. 事件雜談
4.1 虛事件
現在回過來看下麵的委托封裝:
class Math
{
private OutputFunction? _printers;
public void AddPrinter(OutputFunction of)
{
_printers += of;
}
public void RemovePrinter(OutputFunction of)
{
_printers -= of;
}
// ... 省略其他代碼
}
AddPrinter與RemovePrinter本質上都是普通方法,這意味著可以使用virtual修飾符將兩個方法標記為虛事件從而讓其被其子類重寫,如下:
class Math
{
private OutputFunction Printers;
public virtual void AddPrinter(OutputFunction of){ ... }
public virtual void RemovePrinter(OutputFunction of) { ... }
}
class XMath : Math
{
private OutputFunction Printers;
public override void AddPrinter(OutputFunction of){ ... }
public override void RemovePrinter(OutputFunction of) { ... }
}
同時我們知道事件的本質就是類似於上述對委托的封裝,因此,將事件聲明為virtual是可行的:
class Math
{
public virtual event OutputFunction Printers;
}
被聲明為虛事件後,其子類可以重寫事件的實現:
class XMath : Math
{
public virtual event OutputFunction Printers;
}
顯然上述代碼沒有太大的意義。要讓虛事件有意義,需要使用完整的事件聲明語法來重寫事件:
class XMath : Math
{
public override event OutputFunction Printers
{
add
{
...
}
remove
{
...
}
}
}
儘管如此,虛事件依然幾乎沒有什麼使用場合。但是,這可以幫助理解為何在介面中可以定義事件。
4.2 事件使用誤區
4.2.1 重覆訂閱事件
事件並不檢查與保證委托鏈中各個委托的唯一性,換句話說,同一個委托可以重覆訂閱一個事件。
在開始說明這一問題前,先定義一個可以發佈事件的簡單類:
delegate void MeowedHandler(string message);
class Cat
{
public event MeowedHandler Meowed;
public void Meow()
{
Meowed?.Invoke("meow meow meow");
}
}
接下來像下麵這樣使用這個類:
Cat cat = new Cat();
cat.Meowed += Console.WriteLine;
cat.Meowed += Console.WriteLine;
cat.Meowed += Console.WriteLine;
cat.Meow();
(此時你應該能理解上述代碼的工作原理)
此時若運行上述代碼,你會發現控制台輸出了三次‘meow meow meow’。原因是上述代碼中的Console.WriteLine方法向Meowed事件訂閱了三次,因此Meowed事件背後的多播委托的委托鏈中存在三次對Console.WriteLine的委托調用。
上述例子說明瞭同一個委托可以重覆訂閱一個事件,但很多情況下我們只需要將一個委托對一個事件訂閱一次。此外同一個委托被重覆調用可能會帶來各種問題。因此除非確實需要重覆訂閱事件,否則應當避免不必要的重覆訂閱。如果你無法確定事件是否被訂閱,可以考慮在每次訂閱之前先取消待訂閱,然後再訂閱,這樣做的目的在於取消上一次可能忘記取消的訂閱,如下:
Cat cat = new Cat();
cat.Meowed -= Console.WriteLine; // 如果之前忘了取消相同的委托對該事件的訂閱,這裡就順便取消了
cat.Meowed += Console.WriteLine;
cat.Meowed -= Console.WriteLine; // 如果之前忘了取消相同的委托對該事件的訂閱,這裡就順便取消了
cat.Meowed += Console.WriteLine;
cat.Meowed -= Console.WriteLine; // 如果之前忘了取消相同的委托對該事件的訂閱,這裡就順便取消了
cat.Meowed += Console.WriteLine;
cat.Meow();
上述代碼運行後只會輸出一次‘meow meow meow’。嘗試取消訂閱沒有訂閱的委托不會出現任何錯誤,因此即便事件背後的多播委托的委托鏈中沒有任何委托,進行取消訂閱的操作也不會發生錯誤。不過這樣顯然依然難以避免意外的重覆註冊,因為必須保證所有進行訂閱的地方都採用了上述的訂閱方法,也就是說,必須清楚地知道每一個訂閱點,顯然這樣的負擔過於巨大。一個解決方法是顯式定義委托訂閱事件的邏輯,讓任何委托在訂閱事件前都先嘗試將自己從委托鏈中移除,然後再加入:
class Cat
{
private MeowedHandler _meowed;
public event MeowedHandler Meowed
{
add
{
_meowed -= value;
_meowed += value;
}
remove
{
_meowed -= value;
}
}
public void Meow()
{
_meowed?.Invoke("meow meow meow");
}
}
但這一方法不適用於Lambda表達式定義的匿名方法,也就是說,對於下麵的情況,依然會輸出三次‘meow meow meow’:
Cat cat = new Cat();
cat.Meowed += c => Console.WriteLine(c);
cat.Meowed += c => Console.WriteLine(c);
cat.Meowed += c => Console.WriteLine(c);
cat.Meow();
原因在於上述的三個Lambda表達式實際上生成了三個不同的匿名方法。此外,如果你的程式可能運行在多線程環境下,可能還需要進行加鎖以保證線程安全。並且然而無論如何,這不是完美的解決方法,甚至可以說是一種糟糕的解決辦法,首先這樣做意味著事件將無法重覆訂閱,然而有時候確實有重覆訂閱事件的需求;此外,如果程式運行時出現了意外的重覆註冊,通常說明有更嚴重的邏輯問題,使用上述方法會隱藏這些錯誤。不應該隱藏錯誤,而是要讓錯誤儘可能早地被髮現從而避免更大的錯誤。
因此,如果要避免重覆訂閱事件,最恰當的方式應當是從程式編寫的邏輯上避免。
4.2.2 不及時取消訂閱
當委托訂閱事件後,委托就被加入到事件背後的多播委托的委托鏈中,除非手動取消訂閱,否則委托將一直留在委鏈條。因此如果沒有及時取消訂閱,則可能會因為委托調用時出錯而導致程式終止,例如:
class Repeater
{
public string Name { get; set; }
public void Say(string message)
{
Console.WriteLine(Name.ToUpper() + " Repeat: " + message);
}
}
Cat cat = new Cat();
Repeater repeater = new Repeater();
repeater.Name = "aaa";
cat.Meowed += repeater.Say;
cat.Meow();
repeater.Name = null;
cat.Meow(); // 空引用報錯
上述代碼中中,在第二次調用Cat的Meow方法引發Meowd事件前,repeater的Name屬性被設置null,此時其Say方法中的的有關Name.ToUpper()的調用時就會出現空引用錯誤。為了避免這一問題,應當及時進行取消訂閱。
另外一點需要說明的是,如果不及時取消訂閱,那麼由於委托鏈中會一直保有一個對該委托的所屬對象的持有,這會導致其無法被GC回收,直到取消訂閱或者事件發佈方被回收。也就是說,對於下麵的情況:
Cat cat = new Cat();
Repeater repeater = new Repeater();
cat.Meowed += repeater.Say;
repeater = null;
看起來在repeater被設置為null後,所引用的Repeater對象的引用計數應該歸0並準備被GC回收了,然而實際上在其訂閱cat的Meowed事件後,Meowed事件背後的多播委托還會對repeater所引用的對象有一個持有。這種情況下只有等到cat被GC回收才可以回收其所持有的Repeater實例。
綜上所述,為了減少潛在的調用錯誤與記憶體泄露,應當在委托不需要關註事件後及時取消對事件的訂閱。
4.2.3 事件處理程式存在耗時操作
請記住,訂閱事件的本質就是將委托添加到事件背後多播委托的委托鏈,取消訂閱事件則是將委托從中移出,而事件的引發的本質上就是對委托鏈中的委托進行逐一調用。也就是說,如果委托鏈中存在耗時的操作,會阻塞後續委托的調用。例如下述情況:
Cat cat = new Cat();
cat.Meowed += (_) => {
Thread.Sleep(255);
};
cat.Meowed += Console.WriteLine;
cat.Meow();
在Console.WriteLine訂閱Meowed事件前,一個暫停當前線程255毫秒的匿名方法先訂閱了Meowed事件,這就導致在引發事件時,Console.WriteLine必須等待前面的匿名方法完成後才會被調用,也就是說要等255毫秒後才會輸出‘meow meow meow’。除非有特殊需要,否則不應當使用耗時的方法訂閱事件。
4.2.4 依賴事件的執行順序
不要依賴事件的執行順序來達成某種操作儘管通過對事件背後委托鏈的控制可以對委托執行順序進行精細控制,但是更好的辦法應該是對需要順序執行的委托進行封裝。例如,對於如下代碼:
cat.Meowed += Console.WriteLine;
cat.Meowed += Log.WriteToFile;
cat.Meowed += (_) => Console.WriteLine("Completed");
從代碼邏輯來看,代碼的意圖很明顯:先在控制台輸出,然後再將其寫入到文件,接著在控制台輸出‘Completed’表示已完成。然而這依賴了委托鏈的調用順序,正確的做法是應當假設訂閱的各個委托之間沒有任何關係。更好的辦法是將存在依賴關係的方法包裝起來後再訂閱事件,如下:
cat.Meowed += (message) =>
{
Console.WriteLine(message);
Log.WriteToFile(message);
Console.WriteLine("Completed");
};
5. 參考代碼
下麵是基於上面示例的完整的可運行代碼,你可以嘗試運行與分析這些代碼來加強對事件機制的理解:
5.1 簡單的事件示例
using System;
namespace DelegateAndEventSample
{
delegate void OutputFunction(int n);
class Math
{
public event OutputFunction Printers;
public int Add(int a, int b)
{
int n = a + b;
Printers?.Invoke(n);
return 0;
}
}
class Program
{
static void Main(string[] args)
{
Math math = new Math();
math.Printers += Console.WriteLine;
int n = math.Add(1, 2);
math.Printers -= Console.WriteLine;
int m = math.Add(1, 2);
}
}
}
5.2 符合.NET準則的事件示例
using System;
namespace DelegateAndEventSampleX
{
// 定義事件所用的EventArgs
public class NewMessageArrivedEventArgs : EventArgs
{
public string Message { get; }
public NewMessageArrivedEventArgs(string message)
{
Message = message;
}
}
// 用於演示的類
class Messenger
{
public event EventHandler<NewMessageArrivedEventArgs> NewMessageArrived;
public string Name { get; set; }
public void FetchMessage()
{
Thread.Sleep(1000); // 等待一秒
int message = new Random().Next(0, 10); // 隨機生成一個數字
OnNewMessageArrived(message.ToString());
}
private void OnNewMessageArrived(string message)
{
NewMessageArrived?.Invoke(this, new NewMessageArrivedEventArgs(message));
}
}
class Program
{
static void Main(string[] args)
{
Messenger m = new Messenger();
m.Name = "New messenger";
m.NewMessageArrived += Print;
m.FetchMessage();
}
static void Print(object sender, NewMessageArrivedEventArgs e)
{
Console.WriteLine("Value " + e.Message + " From " + (sender as Messenger).Name);
}
}
}