C# 的委托和事件

随着使用的逐渐增多,慢慢受不了在对这些概念的模糊的情况下使用了,所以找了些官方文档来学习一下C#中的这些概念,主要是为了区分委托(Delegate),事件(Event)。

其实我个人在读文档之前一直是对这些概念很模糊的,甚至一度被网上的各种博客绕晕了,甚至把Action,Func和委托都混淆了。

终于在我去读了官方的文档之后,我才对这些概念有了一些认知。

首先抛出最重要的结论,委托和事件都是为了提供后处理函数的方式,事件其实也是基于委托的多播的,而每次使用委托都要定义一个新的委托类型不方便,所以又提供了两种强类型的委托,就是Action和Func。

什么是委托

委托是一种引用类型,表示对具有特定参数列表和返回类型的方法的引用。 在实例化委托时,你可以将其实例与任何具有兼容签名和返回类型的方法相关联。 你可以通过委托实例调用方法。

委托用于将方法作为参数传递给其他方法。 事件处理程序就是通过委托调用的方法。 你可以创建一个自定义方法,当发生特定事件时,某个类(如 Windows 控件)就可以调用你的方法。 下面的示例演示了一个委托声明:

1
public delegate int PerformCalculation(int x, int y);

可将任何可访问类或结构中与委托类型匹配的任何方法分配给委托。 该方法可以是静态方法,也可以是实例方法。 此灵活性意味着你可以通过编程方式来更改方法调用,还可以向现有类中插入新代码。

这段代码的作用是声明了一个新的委托,注意,是声明,效果相当于声明了一个新的类型,可以理解成声明了一个新的class,并没有实例化。

将方法作为参数进行引用的能力使委托成为定义回调方法的理想选择。 可编写一个比较应用程序中两个对象的方法。 该方法可用在排序算法的委托中。 由于比较代码与库分离,因此排序方法可能更常见。

委托具有以下属性:

  • 委托类似于 C++ 函数指针,但委托完全面向对象,不像 C++ 指针会记住函数,委托会同时封装对象实例和方法。

  • 委托允许将方法作为参数进行传递。

  • 委托可用于定义回调方法。

  • 委托可以链接在一起;例如,可以对一个事件调用多个方法。

  • 方法不必与委托类型完全匹配。 有关详细信息,请参阅使用委托中的变体

  • 使用 Lambda 表达式可以更简练地编写内联代码块。 Lambda 表达式(在某些上下文中)可编译为委托类型。 若要详细了解 lambda 表达式,请参阅 lambda 表达式

委托的使用

委托是安全封装方法的类型,类似于 C 和 C++ 中的函数指针。 与 C 函数指针不同的是,委托是面向对象的、类型安全的和可靠的。 委托的类型由委托的名称确定

1
public delegate void Del(string message);

委托对象通常可采用两种方式进行构造,一种是提供带有方法名的delegate,另一种是使用 lambda 表达式。 对委托进行实例化后,对委托的调用会被委托传递给方法。 调用方传递到委托的参数将传递到该方法,并且委托会将方法的返回值(如果有)返回到调用方。 这被称为调用委托。 实例化的委托可以按封装的方法本身进行调用。 例如:

1
2
3
4
5
6
7
8
9
10
// Create a method for a delegate.public static void DelegateMethod(string message)
public static void DelegateMethod(string message)
{
Console.WriteLine(message);
}
// 实例化一个委托
Del handler = DelegateMethod;

// Call the delegate.
handler( Hello World );

委托类型派生自 .NET 中的 Delegate 类。 委托类型是密封的,它们不能被派生,也不能从其派生出自定义类。 由于实例化的委托是一个对象,因此可以作为参数传递或分配给一个属性。 这允许方法接受委托作为参数并在稍后调用委托。 这被称为异步回调,是在长进程完成时通知调用方的常用方法。 当以这种方式使用委托时,使用委托的代码不需要知道要使用的实现方法。 功能类似于封装接口提供的功能。

当委托构造为封装实例方法时,委托将同时引用实例和方法。 委托不知道除其所封装方法以外的实例类型,因此委托可以引用任何类型的对象,只要该对象上有与委托签名匹配的方法。 当委托构造为封装静态方法时,委托仅引用方法。 请考虑以下声明:

1
2
3
4
5
public class MethodClass
{
public void Method1(string message) { }
public void Method2(string message) { }
}

加上之前显示的静态 DelegateMethod,我们现在已有三个 Del 实例可以封装的方法。

调用时,委托可以调用多个方法。 这被称为多播。 若要向委托的方法列表(调用列表)添加其他方法,只需使用加法运算符或加法赋值运算符(“+”或“+=”)添加两个委托。 例如:

1
2
3
4
5
6
7
8
var obj = new MethodClass();
Del d1 = obj.Method1;
Del d2 = obj.Method2;
Del d3 = DelegateMethod;

//Both types of assignment are valid.
Del allMethodsDelegate = d1 + d2;
allMethodsDelegate += d3;

此时,allMethodsDelegate 的调用列表中包含三个方法,分别为 Method1Method2DelegateMethod。 原有的三个委托(d1d2d3)保持不变。 调用 allMethodsDelegate 时,将按顺序调用所有三个方法。 如果委托使用引用参数,引用将按相反的顺序传递到所有这三个方法,并且一种方法进行的任何更改都将在另一种方法上见到。 当方法引发未在方法内捕获到的异常时,该异常将传递到委托的调用方,并且不会调用调用列表中的后续方法。 如果委托具有返回值和/或输出参数,它将返回上次调用方法的返回值和参数。 若要删除调用列表中的方法,请使用减法运算符或减法赋值运算符( 或 -=)。 例如:

1
2
3
4
5
//remove Method1
allMethodsDelegate -= d1;

// copy AllMethodsDelegate while removing d2
Del oneMethodDelegate = allMethodsDelegate - d2;

由于委托类型派生自 System.Delegate,因此可以在委托上调用该类定义的方法和属性。 例如,若要查询委托调用列表中方法的数量,你可以编写:

1
int invocationCount = d1.GetInvocationList().GetLength(0);

调用列表中具有多个方法的委托派生自 MulticastDelegate,该类属于 System.Delegate 的子类。 由于这两个类都支持 GetInvocationList,因此在其他情况下,上述代码也将产生作用。

多播委托广泛用于事件处理中。 事件源对象将事件通知发送到已注册接收该事件的接收方对象。 若要注册一个事件,接收方需要创建用于处理该事件的方法,然后为该方法创建委托并将委托传递到事件源。 事件发生时,源调用委托。 然后,委托将对接收方调用事件处理方法,从而提供事件数据。 给定事件的委托类型由事件源确定。 有关详细信息,请参阅事件

更多的使用方式可以参考:如何声明,实例化和使用委托

强类型委托:Action,Func

抽象的 Delegate 类提供用于松散耦合和调用的基础结构。 通过包含和实施添加到委托对象的调用列表的方法的类型安全性,具体的委托类型将变得更加有用。 使用 delegate 关键字并定义具体的委托类型时,编译器将生成这些方法。

实际上,无论何时需要不同的方法签名,这都会创建新的委托类型。 一段时间后此操作可能变得繁琐。 每个新功能都需要新的委托类型。

幸运的是,没有必要这样做。 .NET Core 框架包含几个在需要委托类型时可重用的类型。 这些是泛型定义,因此需要新的方法声明时可以声明自定义。

第一个类型是 Action 类型和一些变体:

1
2
3
4
public delegate void Action();
public delegate void Action<in T>(T arg);
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
// Other variations removed for brevity.

Action 委托的变体可包含多达 16 个参数,如 Action。 重要的是这些定义对每个委托参数使用不同的泛型参数:这样可以具有最大的灵活性。 方法参数不需要但可能是相同的类型。

对任何具有 void 返回类型的委托类型使用一种 Action 类型。

此框架还包括几种可用于返回值的委托类型的泛型委托类型:

1
2
3
4
public delegate TResult Func<out TResult>();
public delegate TResult Func<in T1, out TResult>(T1 arg);
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);
// Other variations removed for brevity

Func 委托的变体可包含多达 16 个输入参数,如 Func。 按照约定,结果的类型始终是所有 Func 声明中的最后一个类型参数。

对任何返回值的委托类型使用一种 Func 类型。

还有一种专门的委托类型 Predicate,此类型返回单个值的测试结果:

1
public delegate bool Predicate<in T>(T obj);

你可能会注意到对于任何 Predicate 类型,均存在一个在结构上等效的 Func 类型,例如

1
2
Func<string, bool> TestForString;
Predicate<string> AnotherTestForString;

你可能认为这两种类型是等效的。 它们不是。 这两个变量不能互换使用。 一种类型的变量无法赋予另一种类型。 C# 类型系统使用的是已定义类型的名称,而不是其结构。

什么是事件

和委托类似,事件是后期绑定机制。 实际上,事件是建立在对委托的语言支持之上的。

事件是对象用于(向系统中的所有相关组件)广播已发生事情的一种方式。 任何其他组件都可以订阅事件,并在事件引发时得到通知。

你可能已在某些编程中使用过事件。 许多图形系统都具有用于报告用户交互的事件模型。 这些事件会报告鼠标移动、按钮点击和类似的交互。 这是使用事件的最常见情景之一,但并非唯一的情景。

可以定义应针对类引发的事件。 使用事件时,需要注意的一点是特定事件可能没有任何注册的对象。 必须编写代码,以确保在未配置侦听器时不会引发事件。

通过订阅事件,还可在两个对象(事件源和事件接收器)之间创建耦合。 需要确保当不再对事件感兴趣时,事件接收器将从事件源取消订阅。

事件支持的设计目标

事件的语言设计针对这些目标:

  • 在事件源和事件接收器之间启用非常小的耦合。 这两个组件可能不会由同一个组织编写,甚至可能会通过完全不同的计划进行更新。

  • 订阅事件并从同一事件取消订阅应该非常简单。

  • 事件源应支持多个事件订阅服务器。 它还应支持不附加任何事件订阅服务器。

你会发现事件的目标与委托的目标非常相似。 因此,事件语言支持基于委托语言支持构建。

事件的语言支持

用于定义事件以及订阅或取消订阅事件的语法是对委托语法的扩展。

定义使用 event 关键字的事件:

1
public event EventHandler<FileListArgs> Progress;

该事件(在此示例中,为 EventHandler<FileListArgs>)的类型必须为委托类型。 声明事件时,应遵循许多约定。 通常情况下,事件委托类型具有无效的返回。 事件声明应为谓词或谓词短语。 当事件报告已发生的事情时,请使用过去时。 使用现在时谓词(例如 Closing)报告将要发生的事情。 通常,使用现在时表示类支持某种类型的自定义行为。 最常见的方案之一是支持取消。 例如,Closing 事件可能包括指示是否应继续执行关闭操作的参数。 其他方案可能会允许调用方通过更新事件参数的属性来修改行为。 你可以引发一个事件以指示算法将采取的建议的下一步操作。 事件处理程序可以通过修改事件参数的属性授权不同的操作。

想要引发事件时,使用委托调用语法调用事件处理程序:

1
Progress?.Invoke(this, new FileListArgs(file));

委托部分中所介绍的那样,?. 运算符可以轻松确保在事件没有订阅服务器时不引发事件。

通过使用 += 运算符订阅事件:

1
2
3
4
EventHandler<FileListArgs> onProgress = (sender, eventArgs) =>
Console.WriteLine(eventArgs.FoundFile);

fileLister.Progress += onProgress;

处理程序方法通常为前缀“On”,后跟事件名称,如上所示。

使用 -= 运算符取消订阅:

1
fileLister.Progress -= onProgress;

请务必为表示事件处理程序的表达式声明局部变量。 这将确保取消订阅删除该处理程序。 如果使用的是 lambda 表达式的主体,则将尝试删除从未附加过的处理程序,此操作为无效操作。

更多的事件使用方式,如怎样取消事件的执行等,可以看:标准.NET事件模式以及新的事件模式

新的事件模式主要是放开了事件参数的限制,可以不必须继承自EventArgs,并且注意异步事件处理器

区分委托和事件

它们都提供了一个后期绑定方案:在该方案中,组件通过调用仅在运行时识别的方法进行通信。 它们都支持单个和多个订阅服务器方法。 这称为单播和多播支持。 二者均支持用于添加和删除处理程序的类似语法。 最后,引发事件和调用委托使用完全相同的方法调用语法。 它们甚至都支持与 ?. 运算符一起使用的相同的 Invoke() 方法语法。

鉴于所有这些相似之处,很难确定何时使用何种语法

侦听事件是可选的

在确定要使用的语言功能时,最重要的考虑因素为是否必须具有附加的订阅服务器。 如果代码必须调用订阅服务器提供的代码,则在需要实现回调时,应使用基于委托的设计。 如果你的代码在不调用任何订阅服务器的情况下可完成其所有工作,则应使用基于事件的设计。

请考虑本部分中生成的示例。 必须为使用 List.Sort() 生成的代码提供 comparer 函数,以便对元素进行正确排序。 必须与委托一起提供 LINQ 查询,以便确定要返回的元素。 二者均使用与委托一起生成的设计。

请考虑 Progress 事件。 它会报告任务进度。 无论是否具有侦听器,该任务将继续进行。 FileSearcher 是另一个示例。 即使没有附加事件订阅服务器,它仍将搜索和查找已找到的所有文件。 即使没有任何订阅服务器侦听事件,UX 控件仍正常工作。 它们都使用基于事件的设计。

返回值需要委托

另一个注意事项是委托方法所需的方法原型。 如你所见,用于事件的委托均具有无效的返回类型。 你还看到,存在创建事件处理程序的惯用语,该事件处理程序通过修改事件参数对象的属性将信息传回到事件源。 虽然这些惯用语可发挥作用,但它们不像从方法返回值那样自然。

请注意,这两种试探法可能经常同时存在:如果委托方法返回值,则可能会以某种方式影响算法。

事件具有专用调用

包含事件的类以外的类只能添加和删除事件侦听器;只有包含事件的类才能调用事件。 事件通常是公共类成员。 相比之下,委托通常作为参数传递,并存储为私有类成员(如果它们全部存储)。

事件侦听器通常具有较长的生存期

事件侦听器通常具有较长的生存期的这一理由不太充分。 但是,你可能会发现,当事件源将在很长一段时间内引发事件时,基于事件的设计会更加自然。 可以在许多系统上看到基于事件的 UX 控件设计示例。 订阅事件后,事件源可能会在程序的整个生存期内引发事件。 (当不再需要事件时,可以取消订阅事件。)

将其与许多基于委托的设计(其中委托用作方法的参数,且在返回该方法后不再使用此委托)进行比较。