委托和事件 - 未经审查的故事 - 第一部分






4.59/5 (53投票s)
2000年11月20日

405438

2240
这是旨在完全理解委托和事件的一系列文章的一部分。
引言
我们都或多或少地接触过事件驱动编程。C# 通过支持事件和委托,为经常提到的事件驱动编程世界增添了价值。本文是一系列文章的一部分,旨在全面理解委托和事件的运作方式。第一部分帮助您理解多播委托在 UI 交互中的作用。本文将重点关注确定将事件处理程序添加到常用 UI 控件时究竟发生了什么。我们将解释一个简单的模拟,说明当 `AddOnClick` 或任何类似事件被添加到 `Button` 类时,幕后可能发生的情况。这将帮助您更好地理解使用多播委托进行事件处理的本质。提供的所有示例都使用 C#。
事件
事件是操作的结果。关于事件,有两个重要的术语:事件源和事件接收者。引发事件的对象称为事件源,响应事件的对象称为事件接收者。您是否注意到上述解释中有一个卡顿?好的,我的事件源引发了事件,我的事件接收者接收了它,但它们之间的通信通道是什么?以您通过电话与朋友交谈为例。现在您是事件源,您的朋友是事件接收者,电话线是通信通道。同样,事件源和事件接收者之间的通信通道是委托。事件的内部机制及其与委托的关系将在后续文章中讨论。此处对事件的解释仅用于帮助读者理解和可视化事件究竟是什么。
委托(Delegates)
暂时忘记计算机。想象一下现实世界中的委托。什么是委托?委托是代表某个国家、地区或州在另一个国家、地区或州的人。现在将这个定义扩展到 C#。在 C# 中,委托充当事件源和事件目标之间的中介。
更精确地说,委托类似于函数指针。它们可以称为类型安全的函数指针。委托除了事件处理外还有其他用途。但本文将重点介绍与事件处理相关的委托。用作事件源和事件接收者之间中介的委托类称为事件处理程序。
委托的类型
委托基本上有两种类型:单播委托和多播委托。让我们首先从更广泛的层面理解这两种委托的定义。单播委托只能调用一个函数。多播委托是能够成为链表一部分的委托。多播委托指向该链表的头部。这意味着当调用多播委托时,它可以调用构成链表一部分的所有函数。假设我有几个希望在特定事件发生时接收通知的客户端。将它们全部放入多播委托中,可以在特定事件发生时帮助调用所有客户端。
为了支持单播委托,基类库包含一个名为 `System.Delegate` 的特殊类类型。为了支持多播委托,基类库包含一个名为 `SystemMultiCastDelegate` 的特殊类类型。
单播委托
单播委托的签名如下所示。斜体字母可以用您自己的名称和参数替换。
public delegate Boolean DelegateName (parm1, parm2)
当编译器编译上述语句时,它会在内部生成一个新的类类型。这个类称为 `DelegateName`,并从 `System.Delegate` 派生。为了确保这一点,请检查 ILDisassembler 代码以确认正在发生这种情况。
例如,让我们创建一个名为 `MyDelegate` 的单播委托,它将指向 `MyFunction` 函数。代码如下:
public delegate Boolean MyDelegate(Object sendingobj, Int32 x); public class TestDelegateClass { Boolean MyFunction(Object sendingobj, Int32 x) { //Perform some processing return (true); } public static void main(String [] args) { //Instantiate the delegate passing the method to invoke in its constructor MyDelegate mdg = new MyDelegate(MyFunction); // Construct an instance of this class TestDelegateClass tdc = new TestDelegateClass(); // The following line will call MyFunction Boolean f = mdg(this, 1); } }
上述代码如何工作?
`System.Delegate` 包含几个字段。其中最重要的两个是 `Target` 和 `Method`。
`Target` 字段标识一个对象上下文。在上述场景中,它将指向创建的 `TestDelegateClass`。如果被调用的方法是静态方法,则此字段将为 null。
`Method` 字段标识要调用的方法。此字段始终具有值。它绝不为 null。
对于聪明的读者,如果您观察 IL Disassembler 代码,您会看到 `MyDelegate` 的构造函数包含两个参数。但在这里我们只传递了一个参数?有什么猜测为什么会这样吗?是的,您说对了!这是由编译器在内部完成的。编译器将上述调用解析为传递两个必需参数的调用。如果您在 C++(托管扩展)中观察,必须显式传递对象和函数地址。但在 VB 和 C# 中,我们已经摆脱了这一点,由编译器填写了必要的详细信息。
在上面的代码示例中,我们看到了:
Boolean f = mdg(this, 1);
当编译器遇到这一行时,会发生以下情况:
- 编译器识别出 `mdg` 是一个委托对象。
- 委托对象具有如上所述的 `target` 和 `method` 字段。
- 编译器生成调用 `System.Delegate` 派生类(即 **MyDelegate**)的 `Invoke` 方法的代码。
- `Invoke` 方法内部使用委托的 `Method` 字段标识的 **MethodInfo** 类型,并调用 **MethodInfo** 的 `invoke` 方法。
- **MethodInfo** 的 `invoke` 方法会接收委托的 `Target` 字段(标识方法)和一个包含要调用方法的参数的 `variants` 数组。在我们的例子中,`variants` 数组将是一个 `Object` 和一个 `Int32` 值。
上面的讨论应该让您清楚通过单播委托调用方法时内部发生了什么。
多播委托
多播委托的签名如下所示。斜体字母可以用您自己的名称和参数替换。
public delegate void DelegateName (parm1, parm2)
关于单播委托的解释同样适用于多播委托。这里有一个小小的补充。由于多播委托代表一个链表,因此有一个额外的字段 `prev` 指向另一个多播委托。这就是链表维护的方式。
如果您注意到,返回类型已从 `Boolean` 更改为 `void`。您猜到为什么会这样吗?原因是,由于多个多播委托会连续调用,我们无法等待获取被调用每个方法的返回值。
单播委托的示例代码在多播委托的情况下也能正常工作。唯一的区别是需要更改委托签名,并且方法签名需要更改为返回 `void` 而不是 `Boolean`。
多播委托的强大之处在于将委托组合成链表。`System.Delegate` 类提供了 `Combine` 方法作为静态方法。函数签名如下。
public static Delegate Combine(Delegate a, Delegate b);
上述方法的作用是组合委托 `a` 和 `b`,并使 `b` 的 `prev` 字段指向 `a`。它返回链表的头部。这反过来需要转换为我们的委托类型。
与 `Combine` 方法类似,我们有一个 `remove` 方法,它可以从链表中移除一个委托,并为您提供修改后的较小链表,其中包含指向头部的指针。`remove` 方法的签名如下:
public static Delegate Remove(Delegate source, Delegate value);
这是一个说明多播委托使用的小示例:
using System; class MCD1 { public void dispMCD1(string s) { Console.WriteLine("MCD1"); } } class MCD2 { public void dispMCD2(string s) { Console.WriteLine("MCD2"); } } public delegate void OnMsgArrived(string s); class TestMultiCastUsingDelegates { public static void Main(string [] args) { MCD1 mcd1=new MCD1(); MCD2 mcd2=new MCD2(); // Create a delegate to point to dispMCD1 of mcd1 object OnMsgArrived oma=new OnMsgArrived(mcd1.dispMCD1); // Create a delegate to point to dispMCD2 of mcd2 object OnMsgArrived omb=new OnMsgArrived(mcd2.dispMCD2); OnMsgArrived omc; // Combine the two created delegates. Now omc would point to the head of a linked list // of delegates omc=(OnMsgArrived)Delegate.Combine(oma,omb); Delegate [] omd; // Obtain the array of delegate references by invoking GetInvocationList() omd=omc.GetInvocationList(); OnMsgArrived ome; // Now navigate through the array and call each delegate which in turn would call each of the // methods for(int i=0;i<omd.Length;i++) { // Now call each of the delegates ome=(OnMsgArrived)omd[i]; ome("string"); } } }
本文中的示例将围绕多播委托展开。在继续深入之前,您必须已理解单播委托和多播委托的基础知识。
问题场景
下面的代码展示了一个将按钮添加到窗体的示例。该按钮与一个事件处理程序关联,该处理程序负责处理按下按钮时发生的一切。当窗体显示并且按下按钮时,会出现一个显示“Button Clicked”的消息框。
编译说明:使用以下命令编译csc /r:System.DLL;System.WinForms.DLL;Microsoft.Win32.Interop.DLL FormTest.cs
/* Creating a form and adding a button to it and associating the button with an event handler */ using System; using System.WinForms; public class FormTest : Form { //Create a button private Button button1 = new Button(); public static void Main (string [] args) { //Run the application Application.Run(new FormTest()); } public FormTest() { //Set up the Form this.Text = "Hello WinForms World"; //Set up the button button1.Text = "Click Me!"; //Register the event handler button1.AddOnClick(new System.EventHandler(buttonClicked)); //Add the controls to the form this.Controls.Add(button1); } //The event handling method private void buttonClicked(object sender, EventArgs evArgs) { MessageBox.Show("Button clicked"); } }
我们将讨论的主题是,在单击事件引发到显示“Button clicked”的方法被显示之间,调用的路由方式。
练习的组成部分
本节将讨论构成该架构的组成部分:
1. 委托事件处理程序
一个委托事件处理程序,用于将调用路由到适当的方法。我们称之为 `ButtonEventHandler`。此委托的声明应遵循多播委托的声明方式。声明如下。
public delegate void ButtonEventHandler(object sender, ButtonEventArgs e);
参数
`sender` - 标识事件的来源
`ButtonEventArgs` - 标识事件参数。此类派生自 `EventArgs`。`EventArgs` 是事件数据的基类。与生成的事件相关的数据将包含在此类中。我使用一个单独的类是因为我将在派生类中使用一个字符串成员变量,该变量标识了操作。
2. EventArgs 派生类
在前一点中已提到使用派生类作为 `EventArgs` 的原因。该类如下所示:
public class ButtonEventArgs : EventArgs { public string msg; //identifies the action public ButtonEventArgs(string message) { msg=message; } }
3. 按钮类
此类是对 NGWS SDK 按钮类的模拟。像按钮这样的控件类必须能够在执行诸如单击等操作时生成事件。现在逻辑地思考,按钮被按下,并且应该显示指示该情况的消息。这里的按钮是事件源。暂时忘记事件接收者。现在需要什么呢?是的!您猜对了,需要一个委托来将调用转发给适当的事件接收者。现在,如果您想向按钮添加委托,可以通过按钮类中的 `Add` 方法进行。同样,如果您想移除委托,可以通过按钮类中的 `Remove` 方法进行。根据 NGWSSDK 文档,这些方法必须命名为 `AddOn
public void AddOnClick(ButtonEventHandler handler); public void RemoveOnClick(ButtonEventHandler handler); public void AddOnPress(ButtonEventHandler handler); public void RemoveOnPress(ButtonEventHandler handler);
至此,我们添加了根据按钮类中的单击和按下事件添加和移除委托的方法。
现在还需要什么?最重要的一部分,引发事件的方法。此类方法遵循的命名约定是 `On
方法的签名将如下所示:
protected virtual void OnPress(ButtonEventArgs e); protected virtual void OnClick(ButtonEventArgs e);
为什么上述两个方法被声明为 `protected`?是为了帮助子类在不附加委托的情况下重写此事件。
SDK `Button` 类中发生了什么。当按钮被按下时,会创建一个事件数据,然后调用 `OnClick` 方法,并将事件数据作为参数传递。`OnClick` 方法反过来通过委托调用事件接收者。您是否注意到上面缺少了一个环节?如果 `OnClick` 方法接收了事件数据,谁来创建事件数据?答案是,事件数据可以由 SDK 创建。出于我们的目的,由于我们无法接入 SDK,我们将通过创建两个方法来模拟 SDK 的行为,这两个方法的唯一目的是创建事件数据并调用适当的 `OnClick` 或 `OnPress` 方法。这些方法的签名如下:
public void click(); public void press();
现在让我们看一下整个按钮类:
class button { private ButtonEventHandler beh=null; // For click private ButtonEventHandler bep=null; // For press public void AddOnClick(ButtonEventHandler handler) { Console.WriteLine("Adding click event handler"); beh=(ButtonEventHandler)Delegate.Combine(beh, handler); } public void RemoveOnClick(ButtonEventHandler handler) { Console.WriteLine("Removing click event handler"); beh=(ButtonEventHandler)Delegate.Remove(beh, handler); } protected virtual void OnClick(ButtonEventArgs e) { if (beh!=null) { beh(this, e); } } public void AddOnPress(ButtonEventHandler handler) { Console.WriteLine("Adding Press Event handler"); bep=(ButtonEventHandler)Delegate.Combine(bep, handler); } public void RemoveOnPress(ButtonEventHandler handler) { Console.WriteLine("Removing Press Event handler"); bep=(ButtonEventHandler)Delegate.Remove(bep, handler); } protected virtual void OnPress(ButtonEventArgs e) { if (bep!=null) { bep(this, e); } } public void click() { ButtonEventArgs bea=new ButtonEventArgs("clicked"); OnClick(bea); } public void press() { ButtonEventArgs bea=new ButtonEventArgs("pressed"); OnPress(bea); } }
现在让我们逐行分析上面的代码:
private ButtonEventHandler beh=null; // For click private ButtonEventHandler bep=null; // For press
以上两行声明了两个委托。这两个委托都是多播委托。它们代表了事件发生时必须执行的一系列方法调用。它们被初始化为 `null`,表示在类首次实例化时它们不指向任何地方。我在这里使用两个委托是因为我认为使用单独的委托来跟踪单独的事件。在上面的代码片段中,委托 `beh` 将处理单击调用,委托 `bep` 将处理按下调用。
public void AddOnClick(ButtonEventHandler handler) { Console.WriteLine("Adding click event handler"); beh=(ButtonEventHandler)Delegate.Combine(beh, handler); }
`AddOn` 方法用于添加当事件发生时需要调用的方法列表。因此,它自然会接收一个委托作为参数,该委托又指向一个函数。根据事件发生时需要调用的方法的数量,此方法会被调用多次,并传递适当的委托参数。这种情况可以通过图示更好地解释:
beh ------> ButtonEventHandler1 -----> &Fn1() ButtonEventHandler2 -----> &Fn2() ButtonEventHandler3 -----> &Fn3() ButtonEventHandler4 -----> &Fn4()
如上图所示,`beh` 指针指向一个委托,该委托又指向一个函数。在多播委托的情况下,有一个 `prev` 字段,在委托构造时初始化为 `null`。现在,当您调用代码片段中所示的 `Combine` 方法时,`handler` 的 `prev` 字段将指向 `beh`。因此,当使用 `Combine` 方法组合多个委托时,内部发生的情况是创建了一个链表。这就是为什么当我们在 `OnClick` 或 `OnPress` 方法中调用委托时,会发现所有组合的函数都会被调用。这使得多个客户端能够接收事件通知。这就是多播委托的原理。
`RemoveOn` 方法的解释同样适用。
4. 窗体模拟
在我们文章开头解释的窗体示例中,我们有一个从 `Form` 派生的窗体类,它构造了一个按钮并附加了事件处理程序。我们将模拟相同的情况,但唯一的区别是我们不会创建 GUI,而是有一个客户端,它添加我们模拟的按钮类并向其附加事件处理方法。为了换个方式,我将不再零散地解释源代码,而是将解释放在源代码本身中。
客户端类的代码如下:
public class DelegatesAndEvents { //Declare the simulated button class private button button1=new button(); //Have a private constrcutor private DelegatesAndEvents() { /* Now start adding onclick and onpress events on the button class by passing the appropriate methods to which calls should be routed. What is being done below is that three methods are being added to the same OnClick event so that when the Click event is raised the delegate would call all the methods */ button1.AddOnClick(new ButtonEventHandler(ClickEventHandler1)); button1.AddOnClick(new ButtonEventHandler(ClickEventHandler2)); button1.AddOnClick(new ButtonEventHandler(ClickEventHandler3)); button1.AddOnPress(new ButtonEventHandler(PressEventHandler)); button1.click(); button1.press(); } public static void Main(String [] args) { DelegatesAndEvents d=new DelegatesAndEvents(); } public void ClickEventHandler1(object sender, ButtonEventArgs e) { Console.WriteLine("click event handler1"); Console.WriteLine(e.msg); Console.WriteLine("after"); } public void ClickEventHandler2(object sender, ButtonEventArgs e) { Console.WriteLine("click event handler2"); Console.WriteLine(e.msg); Console.WriteLine("after"); } public void ClickEventHandler3(object sender, ButtonEventArgs e) { Console.WriteLine("click event handler3"); Console.WriteLine(e.msg); Console.WriteLine("after"); } public void PressEventHandler(object sender, ButtonEventArgs e) { Console.WriteLine("press event handler"); Console.WriteLine(e.msg); Console.WriteLine("after"); } }
输出
结论
当附加事件处理程序时,幕后发生了什么?委托如何在此过程中提供帮助?如何在类中模拟事件处理,当某个事件被引发时,如何调用多个方法等等,这些都是本文讨论的核心要点。我希望这些解释能带来一些清晰度。有些方面只有运行时内部才知道,我们没有努力深入探讨它们。例如,当按钮实际被单击时,单击事件是如何被引发的。本文仅侧重于理解委托和事件在事件处理程序上下文中的相互关系所绝对必需的内容。