C# 事件基础和多播委托中的异常处理






3.95/5 (14投票s)
如何声明和使用 C# 事件,并处理来自事件处理程序的异常。
引言
本文介绍了 .NET 事件的基本概念,然后展示了如何处理从事件处理程序/订阅者抛出的异常。本文的目的或重点是展示一些基本概念和异常处理。
背景
我想写这篇文章来展示如何处理来自事件处理程序/订阅者的未处理异常。虽然乍一看很容易,但除非您对委托有深入的了解,否则它并不直接。当我在搜索关于异常的信息时,找不到任何文章,所以我决定自己写一篇。
目录
在 C 和 C++ 时代,函数指针是异步调用方法或从另一个类调用方法的唯一选择。这种技术虽然强大,但并不安全。类型安全无法保证,并且方法发布者无法控制何时调用该方法。微软提出了使用委托和事件的解决方案。委托为函数指针提供了类型安全,而事件则提供了调用远程方法的控制权。正如您将看到的,事件和委托本质上是同一件事,但使用方式不同。
什么是事件?
事件可以定义为函数指针。它通知其订阅者一个事件。这意味着它存储了当“事件”发生时将被调用的方法的指针。从定义中可以看出,指针是事件系统的核心。那么,问题是如何保证类型安全呢?毕竟,指针并不被认为是一种调用方法的类型安全的方式。
答案是 C# 中的事件使用委托来指向方法。通过使用委托可以保证类型安全。委托只允许在目标方法满足委托的签名时存储指向方法的指针。这将保证在调用事件时不会调用非法方法。 .NET 中的事件基于发布-订阅模型。
声明和使用事件
让我们看看如何为我们的应用程序/类定义事件。
event
关键字允许您指定一个委托,当您的代码中发生某个“事件”时将调用该委托。要创建和使用 C# 事件,必须执行以下步骤:
- 创建或识别一个委托。如果您正在定义自己的事件,还必须确保有一个委托可以与
event
关键字一起使用。如果事件是预定义的,例如在 .NET Framework 中,那么事件的消费者只需要知道委托的名称。 - 创建一个包含以下内容的类:
- 一个由委托创建的事件。
- (可选) 一个验证使用
event
关键字声明的委托实例是否存在的方法。否则,此逻辑必须放在触发事件的代码中。 - 调用事件的方法。这些方法可以是某些基类功能的重写。
- 定义一个或多个将方法连接到事件的类。每个类将包含:
- 使用
+=
和-=
运算符将一个或多个方法与基类中的事件关联起来。 - 将与事件关联的方法的定义。
- 使用事件
- 创建包含事件声明的类的对象。
- 使用您定义的构造函数创建包含事件定义的类的对象。
此类定义了事件。
最好的理解上述步骤的方法是通过示例。
我们的第一步是定义或识别委托。如前所述,我们需要一个委托来指向一个方法。您可以创建自己的委托,或者使用 .NET Framework 中定义的委托(如果它满足您的需求)。为简单起见,我们将使用预定义的委托。EventHandler
委托是在 .NET 中最常用的委托,所以我们将使用它。可以存储在 EventHandler
中的方法的签名如下:
void publisher_MyEvent(object sender, EventArgs e);
下一步是创建一个发布者。它是一个定义事件并触发事件的类。该类定义了一个触发事件的方法,并检查委托的 null 引用。
public class Publisher
{
public event EventHandler MyEvent;
protected virtual void OnMyEvent()
{
if(MyEvent != null)
{
MyEvent(this, new EventArgs());
}
}
public void RaiseEvent()
{
OnMyEvent();
}
}
下一步,我们将创建一个订阅者,它将监听事件并实现一个当事件被触发时将被调用的方法。
public class Consumer
{
Publisher publisher = new Publisher();
public Consumer(Publisher pub)
{
publisher = pub;
publisher.MyEvent += new EventHandler(publisher_MyEvent);
}
public void RaiseEvent()
{
publisher.RaiseEvent();
}
void publisher_MyEvent(object sender, EventArgs e)
{
Console.WriteLine("Event Handler Exexuted.");
}
}
注意将事件处理程序附加到实际事件的语法。您可能还记得,.NET 具有多播委托,这意味着它可以存储对单个委托中多个方法的引用。为了实现这一点,我们使用了 +=
运算符。您也可以使用 =
,但这将删除所有先前的引用,只分配最后一个。整个事件系统的目的是通知 **所有** 事件订阅者,而不仅仅是一个。该类还定义了一个与我们的委托签名匹配的事件处理程序。publisher_MyEvent
就是那个方法。当在 Publisher
类中触发事件时,将执行此方法。
最后,我们需要使用以上两个类来查看事件系统的运行情况。您可以创建一个控制台应用程序来测试它。在您的类中使用以下 Main
方法:
public class Program
{
public static void Main(string[] args)
{
Publisher pub = new Publisher();
Consumer consumer = new Consumer(pub);
consumer.RaiseEvent();
Console.ReadLine("Press any key to exit");
}
}
到目前为止,您已经学会了如何声明、触发和处理事件。这些是您在应用程序中定义和使用事件所需了解的基础知识。
超越基础
您是否曾想过“event
”关键字的作用?
“event
”关键字实际上只是对委托类型的一个修饰符。它封装了委托字段,并为订阅者提供只读访问权限。您可能已经注意到,在我们编写的代码中,MyEvent
字段不允许除赋值处理程序之外的任何操作。这正是“event
”关键字的用途。
我们可以通过仅在类中使用委托来实现类似于事件的模式。为了阐明这一点,只需从 Publisher
类中的事件声明中删除 event
关键字并运行应用程序,您将不会在执行中看到任何差异。
public class Publisher
{
public EventHandler MyEvent;
protected virtual void OnMyEvent()
{
if(MyEvent != null)
{
MyEvent(this, new EventArgs());
}
}
public void RaiseEvent()
{
OnMyEvent();
}
}
这种方法的缺点是委托 MyEvent
成为了一个公共字段。订阅者可以在没有发布者任何控制的情况下执行该委托。这种方法也不支持线程安全。您可以创建发布者中的私有委托实例,并创建方法来访问该委托以解决问题。事件声明实现了相同的解决方案。
前面看到的事件声明只是 C# 中的一种简写表示法。完整的声明将如下所示:
public class Publisher
{
private EventHandler _myEvent;
public event EventHandler MyEvent
{
add { _myEvent += value;}
remove {_myEvent -= value;}
}
protected virtual void OnMyEvent()
{
if(_myEvent != null)
{
_myEvent(this, new EventArgs());
}
}
public void RaiseEvent()
{
OnMyEvent();
}
}
在上面的代码中,事件看起来像一个属性过程,带有 add 和 remove 访问器,而不是 get
和 set
。这向我们表明,event
关键字只是便于在发布者中访问委托。这也表明事件和委托是相似的对象。尽管如此,还有一些细微的区别。
委托与事件的区别
如前所述,事件和委托是相似的,都包含方法指针。以下是两个主要区别:
- 接口不能包含委托,但可以包含事件声明。
- 事件只能由声明它的类引发,而委托则不同。
第一点是当您希望所有实现该接口的类都有一个通用的委托时的一个便捷工具。第二点赋予了发布者对委托的完全控制权,订阅者无法在未经发布者允许的情况下调用该方法。毕竟,事件应该由发布者引发,而不是由订阅者引发。
异常处理
异常处理是任何 C# 应用程序的常见任务,事件也是该过程的一部分。正如您所见,发布者无法访问事件处理程序中编写的代码,并且发布者将无法控制从事件处理程序中抛出的异常。处理异常的责任在于订阅者。
事件只不过是一个封装的多播委托。它可以存储指向多个方法或事件处理程序的引用。这些方法将按照它们添加到事件的顺序执行。那么,如果一个处理程序抛出异常,会发生什么?其余的处理程序还会执行吗?答案是其余的方法将不会执行,异常将传播到更高级别。除非在顶层处理,否则应用程序可能会崩溃。
让我们用一个例子来重现上述场景。到目前为止,我们已经有了发布者、订阅者和一个用于测试代码的控制台应用程序。再添加一个订阅者类,并从事件处理程序中抛出一个异常。
public class Consumer1
{
Publisher publisher = new Publisher();
public Consumer1(Publisher pub)
{
publisher = pub;
publisher.MyEvent += new EventHandler(publisher_MyEvent);
}
public void RaiseEvent()
{
publisher.RaiseEvent();
}
void publisher_MyEvent(object sender, EventArgs e)
{
throw new NotImplementedException();
}
}
修改控制台应用程序以实例化 Consumer1
对象。
public class Program
{
public static void Main(string[] args)
{
Publisher pub = new Publisher();
Consumer1 con = new Consumer1(pub);
Consumer consumer = new Consumer(pub);
pub.RaiseEvent();
Console.ReadLine("Press any key to exit");
}
}
执行应用程序。您会注意到,由于 con1
对象抛出的异常,应用程序没有执行 consumer 对象的事件处理程序。那么,解决方案是什么?
要解决此问题,我们需要在一个 try-catch
块中逐个运行注册的事件处理程序。请看以下类定义:
public class Publisher
{
private EventHandler _myEvent;
public event EventHandler MyEvent
{
add { _myEvent += value;}
remove {_myEvent -= value;}
}
protected virtual void OnMyEvent()
{
if(_myEvent != null)
{
foreach(EventHandler handler in _myEvent.GetInvocationList())
{
try
{
handler(this, new EventArgs());
}
catch(Exception e)
{
Console.WriteLine("Error in the handler {0}: {1}",
handler.Method.Name, e.Message);
}
}
}
}
public void RaiseEvent()
{
OnMyEvent();
}
}
如果您现在运行代码,您将看到来自第一个处理程序的错误和来自第二个处理程序的消息。
当订阅者使用 +=
运算符订阅事件时,内部会为该订阅者创建一个委托,并将其添加到事件的调用列表中。因此,事件本身包含一个要调用的委托列表。可以通过调用 GetInvocationList()
方法获得此列表。此方法返回一个委托数组。然后,您需要逐个执行这些委托,并自己处理任何未处理的异常。
线程安全注意事项
上面的代码是线程安全的吗?答案是 **否**。有几种技术可以用来使发布者线程安全。我将使用 lock
语句来锁定资源,使对委托的调用成为线程安全的。
使用 lock 进行同步
您可以在调用委托上的方法之前使用 lock
来锁定委托。OnMyEvent
方法将如下所示:
protected virtual void OnMyEvent()
{
EventHandler eventHandler = null;
lock(this)
//lock the current instance so that other threads cannot change del.
{
eventHandler = _myEvent;
}
if(eventHandler != null)
{
foreach(EventHandler handler in eventHandler.GetInvocationList())
{
try
{
handler(this, new EventArgs());
}
catch(Exception e)
{
Console.WriteLine("Error in the handler {0}: {1}",
handler.Method.Name, e.Message);
}
}
}
}
您还可以使用各种其他技术来进行线程同步,包括 volatile
、ReadWriterLock
类等。请阅读 WeakEvents.aspx 以获得详细讨论。
其他相关文章
有关委托和事件的深入讨论和信息,请参阅以下文章:event_fundamentals.aspx。