.NET C#基础(3):事件 - 不便处理的事就委托出去
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);
}
}
}