介绍.NET中事件的相关概念、基本知识及其使用方法
理解C#基本语法(方法的声明、方法的调用、类的定义)
(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指明了它是一个委托。这个委托可以代表的方法应该是这样的:
回到上面的例子,如果你希望通过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方法了,输出行为已经被‘委托’出去,具体要如何输出可以在调用时轻松决定。
通过上面的例子你应该对委托有了一定的基本认识。下面再来考虑一个新的需求:如何把相加结果输出到控制台的同时还要打印到文件里呢?一种方法是,使用一个方法包装一下两种输出方法,就像下面这样:
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);
你可能会注意到类中的多播委托类型和普通的委托类型完全一样,唯一的区别似乎只在于在使用时需要使用+=符号来为多播委托添加方法,而非使用=符号进行直接赋值。这是由于历史原因,C#中所有通过delegate声明出来的委托都是多播委托。+=与-=做的事就是将方法加入或移出委托列表。
(4)委托就仅此而已吗?
上面的例子的目的仅仅是为了从一个更抽象的概念上理解委托与多播委托,实际上C#中的委托还有很多可探究的地方,例如委托本质其实是一个类(Delegate),而多播委托(MulticastDelegate)是Delegate的子类,并且多播委托的实现也并非只是简单使用一个委托列表,它的实现依赖于一种被称为委托链的机制。如果希望更进一步理解委托,可以参考.NET的源码实现。
(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 AddDelegate(OutputFunction of) { _printers += of; } public void RemoveDelegate(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(); // 尝试从外部引发事件,同样是不允许的操作
(你可能注意到上述例子中使用‘引发事件’这一说法,实际上它就是对多播委托的调用。同样的,+=与-=操作也被改称为“注册事件”与“注销事件”。)
(3)基于委托,但比委托更严格
尽管事件基于委托,并且可以只使用委托与方法的封装来模拟事件,但是事件应当遵循以下规则:
要更好地使用事件,应当定义符合.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应满足下面两个要求:
现在假定有一个MessageSent事件,则下面是一个用于该事件的自定义EventArgs的示例,此EventArgs拥有一个string类型的Message属性:
public class MessageSentEventArgs : EventArgs { public string Message { get; } public MessageSentEventArgs(string message) { Message = message; } }
(2)定义EventHandler委托
定义好EventArgs后,还需要定义相应的委托,作为规范,委托的定义满足以下要求:
下面是一个用于MessageSent事件的自定义的EventHandler,该委托使用MessageSentEventArgs代替了原来的EventArgs:
public delegate void MessageSentEventHandler(object sender, MessageSentEventArgs e);
这样,结合上述的自定义EventArgs与EventHandler,可以声明一个符合.NET准则的事件:
class Messenger { public event MessageSentEventHandler MessageSent; }
此外,除了手动声明委托外,还有一种做法是使用泛型EventHandler<>,其接受一个泛型参数作为事件附加参数的参数类型,泛型委托EventHandler<>的定义如下:
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);
因此可以像下面这样来声明委托类型:
class Messenger { public event EventHandler<MessageSentEventArgs> MessageSent; }
通过泛型委托,可以简化委托的声明。
(3)定义事件引发方法
所谓事件的引发方法就是用于间接引发事件的方法封装。这一定义不是必须的,但定义事件的引发方法有助于事件的使用,通常,事件的引发方法应该符合以下规则:
1. 以On+事件名为方法名
2. 只引发对应的事件
例如,对于MessageSent事件,可以定义如下的事件引发方法:
void OnMessageSent(string message) { if (MessageSent != null) { MessageSent(this, new MessageSentEventArgs(message)); } }
同时,可使用空值传播运算符与Invoke方法简化判空操作:
void OnMessageSent(string message) { MessageSent?.Invoke(this, new MessageSentEventArgs(message)); }
在需要引发事件时,便可以通过调用此方法来引发事件。
class Messenger { public event EventHandler<MessageSentEventArgs> MessageSent; public void FetchMessage() { string message = ... OnMessageSent(message); } }
定义事件引发方法的另一个明显的优点是,如果引发方法的访问修饰符是protected或者public,那么便可以让子类甚至外部引发相应的事件。这在某些时候可能有助于解决某些问题。
下面是基于上面示例的完整的可运行代码,你可以尝试运行与分析这些代码来加强对事件机制的理解:
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); } } }
using System; namespace Test { // 定义事件所用的EventArgs public class MessageSentEventArgs : EventArgs { public string Message { get; } public MessageSentEventArgs(string message) { Message = message; } } // 用于演示的类 class Messenger { public event EventHandler<MessageSentEventArgs> MessageSent; public string Name { get; set; } public void FetchMessage() { Thread.Sleep(1000); // 等待一秒 int message = new Random().Next(0, 10); // 随机生成一个数字 MessageSent?.Invoke(this, new MessageSentEventArgs(message.ToString())); } } class Program { static void Main(string[] args) { Messenger m = new Messenger(); m.Name = "New messenger"; m.MessageSent += Print; m.FetchMessage(); } static void Print(object? sender, MessageSentEventArgs e) { Console.WriteLine("Value " + e.Message + " From " + (sender as Messenger)?.Name); } } }