把委托说透(3):委托与事件
在把委托说透(1)和(2)中,先后介绍了委托的语法和本质,本文重点介绍.NET中与委托息息相关的概念——事件。在此之前,首先需要补充(2)中遗漏的一部分内容,即C#在语法上对委托链的支持。
C#编译器为委托类型提供了+=和-=两个操作符的重载,分别对应Delegate.Combine和Delegate.Remove方法,使用这两个操作符可以大大简化委托链的构造和移除。
好了,有了+=和-=,我们就可以开始今天的话题了。
什么是事件?
事件(event)是类型中的一种成员,定义了事件成员的类型允许类型(或者类型的实例)在某些特定事情发生的时候通知其他对象。如Button类型的Click事件,在按钮被点击的时候,程序中的其他对象可以得到一个通知,并执行相应的动作。事件就是支持这种交互的类型成员。
CLR中的事件模型是建立在委托这一机制之上的,这种关联存在其必然性。
我们知道,委托是对方法的抽象,它将方法的调用与实现相分离。方法的调用者(即委托的执行者)并不知道方法的内部是如何实现的,而方法的实现者也不知道该方法会在何时被调用。
事件也是如此。事件被触发后会执行什么样的操作,是由触发者决定的,如点击一个按钮之后是插入一条记录还是用户登录。事件的拥有者只知道什么情况下会触发事件,但并不知道事件的具体实现。因此用委托来实现事件的机制就是自然而然的事情了。
事件与委托的关系到底是什么样呢?委托是与类、接口同一级别的概念,而事件属于类型的成员,与方法、属性、字段等是同一级别的概念。一个与事件相关联的委托的定义如下:
public delegate void FooEventHandler(object sender, FooEventArgs e);
而相应事件成员的定义为:
public event FooEventHandler Foo;
可见,事件用event关键字定义,其类型为一个委托类型,即事件是通过委托来实现的。
一个完整的事件定义和使用的例子如下:
public delegate void FooEventHandler(object sender, FooEventArgs e); public class FooEventArgs : EventArgs { } public class Bar { public event FooEventHandler Foo; protected virtual void OnFoo(FooEventArgs e) { FooEventHandler handler = Foo; if (handler != null) handler(this, e); } public void SomeMethod() { // ... OnFoo(new FooEventArgs()); // ... } } public class Client { public Client() { Bar b = new Bar(); b.Foo += new FooEventHandler(b_Foo); } void b_Foo(object sender, FooEventArgs e) { throw new NotImplementedException(); } }
我们注意到在SomeMethod方法中并没有直接调用委托,而是调用了一个辅助方法OnFoo。在该方法中,先将Foo事件的引用传递给新定义的委托,然后再进行空判断,在委托不为null的情况下才进行调用。这样做是为了保证线程和类型的安全,我们在下面将会介绍。
还有一个需要注意的地方是,客户端为事件注册方法时,使用的是+=操作符。在本文开头已经介绍,+=对应Delegate.Combine方法,回顾(2)中阐述的委托链的构造,我们可以得出如下结论:在为事件注册方法时,实际上是在构造一个委托链。
事件的设计规范
《Framework Design Guidelines 2nd Edition》一书应该成为我们设计.NET程序的规范手册。书中对于事件的定义采取了如下的规定:
事件的命名
由于通常事件以为着某种行为,因此事件的名称应该为一个动词,并用动词的时态来指明事件发生的时间。《Framework Design Guidelines 2nd Edition》对事件命名的建议如下:
1. 用动词或动词短语来为事件命名。如Clicked、Painting、DroppedDown等等。
2. 用现在时和将来时来表示“之前”和“之后”的概念,不要用Before和Arfter前缀。例如在窗体关闭之前触发的事件可以命名为Closing,而窗体关闭之后触发的事件则应该命名为Closed。
3. 为事件处理程序(委托)的名称添加EventHandler后缀。如
4. 使用sender和e来命名时间的两个参数。如上例。
5. 为事件的数据参数类型的名称添加EventArgs后缀。如上例。
事件的设计
1. 通常情况下,事件所对应的委托的返回值为void,并且包含两个参数:第一个参数为触发事件的对象,通常为事件的拥有者(即上例中的Bar对象)。第二个参数为事件相关的数据,由事件的拥有者传递给事件的调用者。
2. 在.NET 2.0及以后的版本中自定义事件时,使用System.EventHandler<TEventArgs>委托,而不要自定义新的委托类型。因此上例中如果在.NET 2.0下应该定义为:
public event EventHandler<FooEventArgs> Foo;
在.NET 2.0以前,由于不支持泛型,我们仍然需要像上面例子中那样定义。
3. 为事件自定义一个EventArgs的子类,作为传递数据的参数。如果不需要传递任何参数,可以直接使用EventArgs类。
4. 为每个事件编写一个受保护的虚方法作为触发方法,如上例中的OnFoo方法。这仅适用于unsealed类的非静态事件,并不适用于struct、sealed class和静态事件。这样做的原因是,通过override为子类提供一种处理事件的方式。按照惯例,该虚方法以On开头,以事件名称结尾,如OnFoo方法。
为了确保委托在调用时不抛出NullReferenceException,在OnXxx方法中通常都会对委托进行判空操作,如
if (Xxx != null) Xxx(this, e);
然而仅仅这样是不够的,因为事件处理程序的添加和移除并不是线程安全的,因此在多线程环境下,Xxx委托在判空之后很可能被Remove,导致Xxx在调用时可能为null。由于Remove方法将会构造一个新的委托实例,而不会改变原委托的引用,因此需要先将委托的引用传递给一个新的委托,再对这个新委托进行判空和调用等操作,这样即使原委托被Remove,也不会NullReferenceException。
FooEventHandler handler = Foo; if (handler != null) handler(this, e);
5. 触发事件的方法有且仅有一个参数,XxxEventArgs参数。
6. 在触发非静态事件时,sender参数不要为null。对于静态事件,sender参数要为null。
7. 触发事件时,如果不需要传递任何数据,数据参数可以为EventArgs.Empty,不要为null。
事件的应用举例
在前面随笔的评论中,有同学提出希望列举委托在窗体间传值的例子。好吧,我们就举一个简单的WinForm窗体传值的例子。
我们首先新建一个Windows From应用程序,并新建两个窗体MainForm和SubForm,在MainForm中建立两个Button,在SubForm中添加一个RichTextBox。如下图所示:
当点击“开始”的时候,会弹出SubForm,点击“传值”的时候,会将当前时间显示在SubForm的RichTextBox中。
需求大体就是这样了,我们该如何设计呢?
点击“传值”按钮后,会引起SubForm的变化。SubForm只负责显示,它并不知道引起变化的原因。MainForm负责引起变化,并将变化传递给SubForm,但它并不关心SubForm如何进行处理。这与我们之前对事件的描述十分相似:
事件被触发后会执行什么样的操作,是由触发者决定的,如点击一个按钮之后是插入一条记录还是用户登录。事件的拥有者只知道什么情况下会触发事件,但并不知道事件的具体实现。
因此,在这个示例中,我们可以通过事件来实现传值。我们首先创建数据参数类SendEventArgs,它包含一个Message属性,用来保存数据。
public class SendEventArgs : EventArgs { public string Message { get; private set; } public SendEventArgs(string message) { this.Message = message; } }
然后在MainForm中添加一个事件:Send。
public event EventHandler<SendEventArgs> Send;
protected virtual void OnSend(SendEventArgs e) { EventHandler<SendEventArgs> handler = Send; if (handler != null) handler(this, e); }
private void btnBegin_Click(object sender, EventArgs e) { SubForm subForm = new SubForm(this); subForm.Show(); } private void btnSend_Click(object sender, EventArgs e) { SendEventArgs sendEventArgs = new SendEventArgs(DateTime.Now.ToString()); OnSend(sendEventArgs); }
btnBegin按钮用来打开一个SubForm,并将当前MainForm实例作为参数传入。btnSend按钮用来构造Send事件的数据参数,并调用Send事件的触发方法。
在SubForm中,有一个MainForm类型的私有字段,用于保存构造函数里传入的参数。
private MainForm parent;
构造函数中除了给parent字段赋值外,还要注册parent的Send事件的处理程序:
public SubForm(MainForm main) { InitializeComponent(); this.parent = main; parent.Send += new EventHandler<SendEventArgs>(parent_Send); }
parent_Send处理程序负责向RichTextBox中添加信息:
private void parent_Send(object sender, SendEventArgs e) { this.rtbTime.AppendText(e.Message); this.rtbTime.AppendText(Environment.NewLine); }
最后我们在SubForm的Closing事件里移除parent_Send,这样就可以打开多个SubForm了。
private void SubForm_FormClosing(object sender, FormClosingEventArgs e) { parent.Send -= new EventHandler<SendEventArgs>(parent_Send); }
总结
本文重点讲解了.NET中的事件,并对事件的设计进行了规范,最终通过一个示例加深了我们对事件的理解。
您是否从以上示例中感觉到了观察者模式的影子呢?本系列接下来的一篇随笔中,我们将会讨论委托与设计模式的微妙联系。