第 17 章 委托
本章内容:
- 初始委托
- 用委托回调静态方法
- 用委托回调实例方法
- 委托揭秘
- 用委托回调许多方法(委托链)
- 委托定义不要太多(泛型委托)
- C# 为委托提供的简化语法
- 委托和反射
本章要讨论回调函数。回调函数式一种非常有用的编程机制,它的存在已经有很多年了。Microsoft .NET Framework 通过 委托
来提供回调函数机制。不同于其他平台(比如非托管C++)的回调机制,委托的功能要多得多。例如,委托确保回调方法是类型安全的(这是 CLR 最重要的目标之一)。委托还允许顺序调用多个方法,并支持调用静态方法和实例方法。
17.1 初始委托
C “运行时”的 qsort
函数获取指向一个回调函数的指针,以便对数组中的元素进行排序。在 Microsoft Windows 中,窗口过程、钩子过程和异步过程调用等都需要回调函数。在 .NET Framework 中,回调方法的应用更是广泛。例如,可以登记回调方法来获得各种各样的通知,例如未处理的异常、窗口状态变化、菜单项选择、文件系统变化、窗体控件事件和异步操作已完成等。
在非托管 C/C++ 中,非成员函数的地址只是一个内存地址。这个地址不携带任何额外的信息,比如函数期望收到的参数个数、参数类型、函数返回值类型以及函数的调用协定。简单地说,非托管 C/C++ 回调函数不是类型安全的(不过它们确实是一种非常轻量级的机制)。
.NET Framework 的回调函数和非托管 Windows 编程环境的回调函数一样有用,一样普遍。但是,.NET Framework 提供了称为委托的类型安全机制。为了理解委托,先来看看如何使用它。以下代码①演示了如何声明、创建和使用委托:
① 这个程序最好不要通过在 Visual Studio 中新建 “Windows 窗体应用程序”项目来生成。用文本编辑器输入代码,另存为 name.cs。启动“VS2013 开发人员命令提示”,输入
csc name.cs
生成,输入name
执行。这样可同时看到控制台和消息框的输出。 —— 译注
|
|
下面来看看代码做的事情。在顶部,注意看internal
委托Feedback
的声明。委托要指定一个回调方法签名。在本例中,Feedback
委托指定的方法要获取一个Int32
参数,返回void
。在某种程度上,委托和非托管 C/C++ 中代表函数地址的typedef
很相似。
Program
类定义了私有静态方法Counter
,它从整数from
计数到整数to
。方法的fb
参数代表Feedback
委托对象引用。方法遍历所有整数。对于每个整数,如果fb
变量不为null
,就调用由fb
变量指定的回调方法。传入这个回调方法的是正在处理的那个数据项的值,也就是数据项的编号。设计和实现回调方法时,可选择任何恰当的方式处理数据项。
17.2 用委托回调静态方法
理解Counter
方法的设计及其工作方式之后,再来看看如何利用委托回调静态方法。本节重点是上一节示例代码中的StaticDelegateDemo
方法。
在StaticDelegateDemo
方法中第一次调用Counter
方法时,为第三个参数(对应于 Counter
的 fb
参数)传递的是null
。由于Counter
的fb
参数收到的是null
,所以处理每个数据项时都不调用回调方法。
StaticDelegateDemo
方法再次调用Counter
,为第三个参数传递新构成的Feedback
委托对象。委托对象是方法的包装器(wrapper),使方法能通过包装器来间接回调。在本例中,静态方法的完整名称Program.FeedbackToConsole
被传给 Feedback
委托类型的构造器,这就是要包装的方法。new
操作符返回的引用作为Counter
的第三个参数来传递。现在,当Counter
执行时,会为序列中的每个数据项调用Program
类型的静态方法FeedbackToConsole
。FeedbackToConsole
方法本身的作用很简单,就是向控制台写一个字符串,显示正在进行处理的数据项。
注意
FeedbackToConsole
方法被定义成Program
类型内部的私有方法,但Counter
方法能调用Program
的私有方法,这明显没有问题,因为Counter
和FeedbackToConsole
在同一个类型中定义。但即使Counter
方法在另一个类型中定义,也不会出问题!简单地说,在一个类型中通过委托来调用另一个类型的私有成员,只要委托对象是由具有足够安全性/可访问性的代码创建的,便没有问题。
在 StaticDelegateDemo
方法中,对 Counter
方法的第三个调用和第二个调用几乎完全一致。唯一的区别在于 Feedback
委托对象包装的是静态方法 Program.FeedbackToMsgBox
。FeedbackToMsgBox
构造一个字符串来指出正在处理的数据项,然后在消息框中显示该字符串。
这个例子中的所有操作都是都是类型安全的。例如,在构造Feedback
委托对象时,编译器确保Program
的 FeedbackToConsole
和FeedbackToMsgBox
方法的签名兼容于Feedback
委托定义的签名。具体地说,两个方法都要获取一个参数(一个Int32
),而且两者都要有相同的返回类型(void
)。将FeedbackToConsole
的定义改为下面这样:
|
|
C#编译器将不会编译以上代码,并报告以下错误:error CS0123:"FeedbackToConsole"的重载均与委托Feedback"不匹配
。
将方法绑定到委托时,C# 和 CLR 都允许引用类型的协变性(covariance)和逆变性(contravariance)。协变性是指方法能返回从委托的返回类型派生的一个类型。逆变性是指方法获取的参数可以是委托的参数类型的基类。例如下面这个委托:
delegate Object MyCallback(FileStream s);
完全可以构造该委托类型的一个实例并绑定具有以下原型的方法:
String SomeMethod(Stream s);
在这里,SomeMethod
的返回类型(String
)派生自委托的返回类型(Object
);这种协变性是允许的。SomeMethod
的参数类型(Stream
)是委托的参数类型(FileStream
)的基类;这种逆变性是允许的。
注意,只有引用类型才支持协变性与逆变性,值类型或void
不支持。所以,不能把下面的方法绑定到MyCallback
委托:
Int32 SomeOtherMethod(Stream s);
虽然SomeOtherMethod
的返回类型(Int32
)派生自(MyCallback
)的返回类型(Object
),但这种形式的协变性是不允许的,因为Int32
是值类型。显然,值类型和 void
之所以不支持,是因为它们的存储结构是变化的,而引用类型的存储结构始终是一个指针。幸好,视图执行不支持的操作,C#编译器会报错。
17.3 用委托回调实例方法
委托除了能调用静态方法,还能为具体的对象调用实例方法。为了理解如何回调实例方法,先来看看 17.1 节的示例代码中的 InstanceDelegateDemo
方法。
注意InstanceDelegateDemo
方法构造了名为p
的Program
对象。这个Program
对象没有定义任何实例字段或属性;创建它纯粹是为了演示。在Counter
方法调用中构造新的Feedack
委托对象时,向Feedback
委托类型的构造函数传递的是p.FeedbackToFile
。这导致委托包装对FeedbackToFile
方法的引用,这是一个实例方法(而不是静态方法)。当Counter
调用由其fb
实参标识的回调方法时,会调用FeedbackToFile
实例方法,新构造的对象p
的地址作为隐式的 this
参数传给这个实例方法。
FeedbackToFile
方法的工作方法类似于FeedbackToConsole
和FeedbackToMsgBox
,不同的是它会打开一个文件,并将字符串附加到文件末尾。(方法创建的 Status 文件可在与可执行程序相同的目录中找到。)
再次声明,本例旨在演示委托可以包装对实例方法和静态方法的调用。如果是实例方法,委托要知道方法操作的是具体哪个对象实例。包装实例方法很有用,因为对象内部的代码可以访问对象的实例成员。这意味着对象可以维护一些状态,并在回调方法执行期间利用这些状态信息。
17.4 委托揭秘
从表面看,委托似乎很容易使用:用 C#的delegate
关键字定义,用熟悉的new
操作符构造委托实例,用熟悉的方法调用语法来调用回调函数(用引用了委托对象的变量替代方法名)。
但实际情况比前几个例子演示的要复杂一些。编译器和 CLR 在幕后做了大量工作来隐藏复杂性。本节要解释编译器和 CLR 如何协同工作来实现委托。掌握这些知识有助于加深对委托的理解,并学会如何更高效地使用。另外,还要介绍通过委托来实现的一些附加功能。
首先重新审视这一行代码:
internal delegate void Feedback(Int32 value);
看到这行代码后,编译器实际会像下面这样定义一个完整的类:
|
|
编译器定义的类有 4 个方法:一个构造器、Invoke
、BeginInvoke
和EndInvoke
。本章重点解释构造器和Invoke
。BeginInvoke
和EndInvoke
方法将留到第 27 章讨论。
事实上,可用 ILDasm.exe 查看生成的程序集,验证编译器真的会自动生成这个类,如果 17-1 所示。
图 17-1 ILDasm.exe 显示了编译器为委托生成的元数据
在本例中,编译器定义了 Feedback
类,它派生自 FCL 定义的System.MulticastDelegate
类型(所有委托类型都派生自MulticastDelegate
)。
重要提示
System.MulticastDelegate
派生自System.Delegate
,后者又派生自System.Object
。是历史原因造成有两个委托类。这实在是令人遗憾———— FCL 本该只有一个委托类。没有办法,我们对这两个类都要有所了解。即使创建的所有委托类型都将MulticastDelegate
作为基类,个别情况下仍会使用Delegate
类(而非MulticastDelegate
类)定义的方法处理自己的委托类型。例如,Delegate
类的两个静态方法Combine
和Remove
(后文将解释其用途)的签名都指出要获取Delegate
参数。由于你创建的委托类型派生自MulticastDelegate
,后者又派生自Delegate
,所以你的委托类型的实例是可以传给这两个方法的。
这个类的可访问性是private
,因为委托在源代码中声明为internal
。如果源代码改成使用public
可见性,编译器生成的Feedback
类也会变成公共类。要注意的是,委托类既可嵌套在一个类型中定义,也可在全局范围中定义。简单地说,由于委托是类,所以凡是能够定义类的地方,都能定义委托。
由于所有委托类型都派生自MulticastDelegate
,所以它们继承了MulticastDelegate
的字段、属性和方法。在所有这些成员中,有三个非公共字段是最重要的。表 17-1 总结了这些重要字段。
表 17-1 MulticastDelegate
的三个重要的非公共字段
字段 | 类型 | 说明 |
---|---|---|
_target |
System.Object |
当委托对象包装一个静态方法时,这个字段为null 。当委托对象包装一个实例方法时,这个字段引用的是回调方法要操作的对象。换言之,这个字段指出要传给实例方法的隐式参数 this 的值 |
_methodPtr |
System.IntPtr |
一个内部的整数值,CLR用它标识要回调的方法 |
_invocationList |
System.Object |
该字段通常为 null 。构造委托链时它引用一个委托数组(详情参见下一节) |
注意,所有委托都有一个构造器,它获取两个参数:一个是对象引用,另一个是引用了回调方法的整数。但如果仔细查看前面的源代码,会发现传递的是Program.FeedbackToConsole
或p.FeedbackToFile
这样的值。根据迄今为止学到的编程知识,似乎没有可能通过编译!
然而,C# 编译器知道要构造的是委托,所以会分析源代码来确定引用的是哪个对象和方法。对象引用被传给构造器的 object
参数,标识了方法的一个特殊 IntPtr
值(从 MethodDef
或 MemberRef
元数据 token 获得)被传给构造器的 method
参数。对于静态方法,会为 object
参数传递 null
值。在构造器内部,这两个实参分别保存在 _target
和 _methodPtr
私有字段中。除此以外,构造器还将 _invocationList
字段设为null
,对这个字段的讨论将推迟到 17.5 节 “用委托回调多个方法(委托链)”进行。
所以,每个委托对象实际都是一个包装器,其中包装了一个方法和调用该方法时要操作的对象。例如,在执行以下两行代码之后:
|
|
fbStatic
和 fbInstance
变量将引用两个独立的、初始化好的 Feedback
委托对象,,如图 17-2 所示。
图 17-2 在两个变量引用的委托中,一个包装静态方法,另一个包装实例方法
知道委托对象如何构造并了解其内部结构之后,再来看看回调方法时如何调用的。为方便讨论,下面重复了 Counter
方法的定义:
|
|
注意if
语句首先检查fb
是否为null
。不为null
就调用①回调方法。null
检查必不可少,因为fb
只是可能引用了Feedback
委托对象的变量;它也可能为null
。这段代码看上去像是调用了一个名为fb
的函数,并向它传递一个参数(val
)。但事实上,这里没有名为 fb
的函数。再次提醒你注意注意,因为编译器知道 fb
是引用了委托对象的变量,所以会生成代码调用该委托对象的Invoke
方法。也就是说,编译器在看到以下代码时:
① 这里的“调用”是
invoke
,参考 8.6.2 节的译注对invoke
和call
的解释。 ———— 译注
fb(val);
它将生成以下代码,好像源代码本来就是这么写的一样:
fb.Invoke(val);
为了验证编译器生成代码来调用委托类型的 Invoke
方法,可利用 ILDasm.exe 检查为 Counter
方法创建的 IL 代码。下面列出了 Counter
方法的 IL 代码。IL_0009 处的指令就是对 Feedback
的 Invoke
方法的调用。
|
|
其实,完全可以修改 Counter
方法来显式调用 Invoke
方法,如下所示:
|
|
前面说过,编译器是在定义 Feedback
类的时候定义 Invoke
的。在 Invoke
被调用时,它使用私有字段 _target
和 _methodPtr
在指定对象上调用包装好的回调方法。注意,Invoke
方法的签名和委托的签名匹配。由于Feedback
委托要获取一个Int32
参数并返回void
,所以编译器生成的Invoke
方法也要获取一个Int32
并返回void
。
17.5 用委托回调多个方法(委托连)
委托本身就很有用,再加上对委托链的支持,用处就更大了!委托链是委托对象的集合。可利用委托链调用集合中的委托所代表的全部方法。为了理解这一点,请参考 17.1 节的实例代码中的 ChainDelegateDemo1
方法。在 Console.WriteLine
语句之后,我构造了三个委托对象并让变量 fb1
,fb2
和 fb3
分别引用每个对象,如图 17-3 所示。
图 17-3 fb1,fb2 和 fb3 变量引用的委托对象的初始状态
指向Feedback
委托对象的引用变量fbChain
旨在引用委托链(或者说委托对象集合),这些对象包装了可回调的方法。fbChain
初始化为null
,表明目前没有要回调的方法。使用Delegate
类的公共静态方法Combine
将委托添加到链中:
fbChain = (Feedback) Delegate.Combine(fbChain, fb1);
执行这行代码时,Combine
方法发现试图合并的是null
和fb1
。在内部,Combine
直接返回fb1
中的值,所以fbChain
变量现在引用fb1
变量所引用的委托对象,如果 17-4 所示。
图 17-4 在委托链中插入第一个委托后委托对象的状态
再次调用 Combine
方法在链中添加第二个委托:
fbChain = (Feedback) Delegate.Combine(fbChain, fb2);
在内部,Combine
方法发现fbChain
已引用了一个委托对象,所以 Combine
会构造一个新的委托对象。新委托对象对它的私有字段 _target
和 _methodPtr
进行初始化,具体的值对于目前的讨论来说并不重要。重要的是,_invocationList
字段被初始化为引用一个委托对象数组。数组的第一个元素(索引0)被初始化引用包装了 FeedbackToConsole
方法的委托(也就是 fbChain
目前引用的委托)。数组的第二个元素(索引 1)被初始化为引用包装了FeedbackToMsgBox
方法的委托(也就是 fb2
引用的委托)。最后,fbChain
被设为引用新建的委托对象,如果 17-5 所示。
图 17-5 在委托链中插入第二个委托之后委托对象的状态
为了在链中添加第三个委托,我再次调用 Combine
方法。
fbChain = (Feedback) Delegate.Combine(fbChain, fb3);
同样地,Combine
方法发现fbChain
已引用了一个委托对象,因而又构造一个新的委托对象,如果 17-6 所示。和前面一样,新委托对象对私有字段 _target
和_methodPtr
进行初始化,具体的值就目前来说并不重要。_invocationList
字段被初始化为引用一个委托对象数组。该数组的第一个元素和第二个元素(索引 0 和 1)被初始化为引用 fb1
和 fb2
所引用的委托。数组的第三个元素(索引 2)被初始化为引用包装了FeedbackToFile
方法的委托(这是fb3
所引用的委托)。最后,fbChain
被设为引用这个新建的委托对象。注意,之前新建的委托及其_invocationList
字段引用的数组现在可以进行垃圾回收。
在ChainDelegateDemo1
方法中,用于设置委托链的所有代码执行完毕之后,我将fbChain
变量传给 Counter
方法:
Counter(1, 2, fbChain);
Counter
方法内部的代码会在Feedback
委托对象上隐式调用Invoke
方法,具体已在前面讲述过了。在 fbChain
引用的委托上调用Invoke
时,该委托发现私有字段_invocationList
不为null
,所以会执行一个循环来遍历数组中的所有元素,并依次调用每个委托包装的方法。在本例中,FeedbackToConsole
首先被调用,随后是FeedbackToMsgBox
,最后是FeedbackToFile
。
图 17-6 委托链完成后委托对象的最终状态
以伪代码的形式,Feedback
的 Invoke
方法基本上是像下面这样实现的:
|
|
注意,还可调用 Delegate
的公共静态方法 Remove
从链中删除委托。ChainDelegateDemo1
方法在结尾处对此进行了演示。
fbChain = (Feedback) Delegate.Remove(fbChain, new Feedback(FeedbackToMsgBox));
Remove
方法被调用时,它扫描第一个实参(本例是fbChain
)所引用的那个委托对象内部维护的委托数组(从末尾向索引 0 扫描)。Remove
查找的是其_target
和 _methodPtr
字段与第二个实参(本例是新建的Feedback
委托)中的字段匹配的委托。如果找到匹配的委托,并且(在删除之后)数组中只剩余一个数据项,就返回那个数据项。如果找到匹配的委托,并且(在删除之后)数组中只剩余一个数据项,就返回那个数据项。如果找到匹配的委托,并且数组中还剩余多个数据项,就新建一个委托对象————其中创建并初始化的 _invocationList
数组中还剩余多个数据项,当然被删除的数据项除外————并返回对这个新建委托对象的引用。如果从链中删除了仅有的一个元素,Remove
会返回null
。注意,每次 Remove
方法调用只能从链中删除一个委托,它不会删除有匹配的_target
和 _methodPtr
字段的所有委托。
前面展示的例子中,委托类型 Feedback
的返回值都是 void
。但完全可以像下面这样定义 Feedback
委托:
public delegate Int32 Feedback(Int32 value);
如果是这样定义的,那么该委托的 Invoke
方法就应该像下面这样(又是伪代码形式):
|
|
数组中的每个委托被调用时,其返回值被保存到 result
变量中。循环完成后,result
变量只包含调用的最后一个委托的结果(前面的返回值会被丢弃),该值返回给调用 Invoke
的代码。
17.5.1 C# 对委托链的支持
为方便 C# 开发人员,C# 编译器自动为委托类型的实例重载了 +=
和 -=
操作符。这些操作符分别调用 Delegate.Combine
和 Delegate.Remove
。可用这些操作符简化委托链的构造。在 17.1 节的示例代码中,ChainDelegateDemo1
和 ChainDelegateDemo2
方法生成的 IL 代码完全一样。唯一的区别是 ChainDelegateDemo2
方法利用 C# 的+=
和-=
操作符简化了源代码。
要想证明两个方法生成的 IL 代码一样,可利用 ILDasm.exe 查看生成的 IL 代码。会看到 C#编译器用 Delegate
类型的 Combine
和 Remove
公共静态方法调用分别替换了 +=
和 -=
操作符。
17.5.2 取得对委托链调用的更多控制
此时,想必你以已理解了如何创建委托对象链,以及如何调用链中的所有对象。链中的所有项都会被调用,因为委托类型的Invoke
方法包含了对数组中的所有项进行遍历的代码。这是一个很简单的算法。尽管这个简单的算法足以应付很多情形,但也有它的局限性。例如,除了最后一个返回值,其他所有回调方法的返回值都会被丢弃。但局限并不止于此。如果被调用的委托中有一个抛出了异常或阻塞了相当长一段时间,会出现什么情况呢?由于这个简单的算法是顺序调用链中的每一个委托,所以一个委托对象出现问题,链中后续的所有对象都调用不了。显然,这个算法还不够健壮。①
① 健壮性(鲁棒性)和可靠性是有区别的,两者对应的英文单词分别是 robustness 和 reliability。 健壮性主要描述一个系统对于参数变化的不敏感性,而可靠性主要描述一个系统的正确性,也就是在你固定提供一个参数时,它应该产生稳定的、能预测的输出。例如一个程序,它的设计目标是获取一个参数并输出一个值。假如它能正确完成这个设计目标,就说它是可靠的。但在这个程序执行完毕后,假如没有正确释放内存,或者说系统没有自动帮它释放占用的资源,就认为这个程序及其“运行时”不具备健壮性或者鲁棒性。————译注
由于这个算法有的时候不胜其任,所以 MulticastDelegate
类提供了一个实例方法 GetInvocationList
,用于显式调用链中的每一个委托,并允许你使用需要的任何算法:
|
|
GetInvocationList
方法操作从 MulticastDelegate
派生的对象,返回包含 Delegate
引用的一个数组,其中每个引用都指向链中的一个委托对象。在内部,GetInvocationList
构造并初始化一个数组,让它的每个元素都引用链中的一个委托,然后返回对该数组的引用。如果_invaocationList
字段为null
,返回的数组就只有一个元素,该元素引用链中唯一的委托,即委托实例本身。
可以很容易地写一个算法来显式调用数组中每个对象。以下代码进行了演示:
|
|
|
|
17.6 委托定义不要太多(泛型委托)
许多年前,Microsoft 在刚开始开发 .NET Framework 的时候引入了委托类型。随着时间的推移,他们定义了许多委托。事实上,现在仅仅在 MSCorLib.dll 中,就有接近 50 个委托类型。下面只列出其中少数几个:
|
|
发现这几个委托的共同点了吗?它们其实都是一样的:这些委托类型的变量所引用的方法都是获取一个 Object
,并返回 void
。没理由定义这么多委托类型,留一个就可以了!
事实上, .NET Framework 现在支持泛型,所以实际只需几个泛型委托(在 System
命名空间中定义)就能表示需要获取多达 16 个参数的方法:
|
|
所以,.NET Framework 现在提供了 17 个 Action
委托,它们从无参数到最多 16 个参数。如需获取 16 个以上的参数,就必须定义自己的委托类型,但这种情况极其罕见。除了 Action
委托,.NET Framework 还提供了 17 个 Func
函数,允许回调方法返回值:
|
|
建议尽量使用这些委托类型,而不是在代码中定义更多的委托类型。这样可减少系统中的类型数量,同时简化编码。然而,如需使用ref
或out
关键字以传引用的方式传递参数,就可能不得不定义自己的委托:
delegate void Bar(ref Int32 z);
如果委托要通过 C#的 params
关键字获取数量可变的参数,要为委托的任何参数指定默认值,或者要对委托的泛型类型参数进行约束,也必须定义自己的委托类型。
获取泛型实参并返回值的委托支持逆变和协变,而且建议总是利用这些功能,因为它们没有副作用,而且使你的委托适用于更多情形。欲知逆变和协变的详情,请参见 12.5 节“委托和接口的逆变和协变泛型类型实参”。
17.7 C#为委托提供的简化语法
许多程序员因为语法奇怪而对委托有抗拒感。例如下面这行代码:
button1.Cilck += new EventHandler(button1_Click);
其中的button1_CLick
是方法,看起来像下面这样:
|
|
第一行代码的思路是向按钮控件登记 button1_Click
方法的地址,以便在按钮被单击时调用方法。许多程序员认为,仅仅为了指定 button1_Click
方法的地址,就构造一个EventHandler
委托对象,这显得有点儿不可思议。然而,构造 EventHandler
委托对象是 CLR 要求的,因为这个对象提供了一个包装器,可确保(被包装的)方法只能以类型安全的方式调用。这个包装器还支持调用实例方法和委托链。遗憾的是,很多程序员并不想仔细研究这些细节。程序员更喜欢像下面这样写代码:
button1.Click += button1_Click;
幸好,Microsoft C# 编译器确实为程序员提供了用于处理委托的一些简化语法。本节将讨论所有这些简化语法。本节将讨论所有这些简化语法。但开始之前我要声明一点,后文描述的基本上只是 C# 的 语法糖①,这些简化语法为程序员提供了一种更简单的方式生成 CLR 和其他编程语言处理委托时所必须的 IL 代码。这些简化语法是 C# 特有的,其他编译器可能还没有提供额外的委托简化语法。
① 一般而言,越是高级的语言,提供的简化语法越多,以方便写程序,这就是所谓的 “语法糖”。————译注
17.7.1 简化语法 1: 不需要构造委托对象
如前所述,C# 允许指定回调方法的名称,不必构造委托对象包装器。例如:
|
|
ThreadPool
类的静态QueueUserWorkItem
方法期待一个WaitCallback
委托对象引用,委托对象中包装的是对SomeAsyncTask
方法的引用。由于 C#编译器能自己进行推断,所以可以省略构造WaitCallback
委托对象的代码,使代码的可读性更佳,也更容易理解。当然,当代码编译时,C# 编译器还是会生成 IL 代码来新建 WaitCallback
委托对象———— 只是语法得到了简化而已。
17.7.2 简化语法2:不需要定义回调方法(lambda 表达式)
在前面的代码中,回调方法名称 SomeAsyncTask
传给 ThreadPool
的 QueueUserWorkItem
方法。C# 允许以内联(直接嵌入)的方式写回调方法的代码,不必在它自己的方法中写。例如,前面的代码可以这样重写:
|
|
注意,传给 QueueUserWorkItem
方法的第一个实参是代码(我把它倾斜显示了)!更正式地说,这是一个 C# lambada 表达式,可通过 C# lambda 表达式操作符 => 来轻松识别。lambda 表达式可在编译器语句会看到一个委托的地方使用。编译器看到这个 lambda 表达式之后,会在类(本例是 AClass
)中自定义一个新的私有方法。这个新方法称为匿名函数,因为方法名称由编译器自动创建,而且你一般不知道这个名称。①但可利用 ILDasm.exe 这样的工具检查编译器生成的代码。写完前面的代码并编译之后,我通过 ILDasm.exe 看到 C# 编译器将该方法命名为 <CallbackWithoutNewingADelegateObject>b__0
,它获取一个 Object
,返回void
。
① 作者在这里故意区分了匿名函数和匿名方法。一般情况下,两者可以互换着使用。如果非要区分,那么编译器生成的全都是“匿名函数”,这是最开始的叫法。从 C# 2.0 开始引入了“匿名方法”功能,它的作用就是简化生成匿名函数而需要写的代码。在新的 C#版本中(3.0 和以后),更是建议用 lambda 表达式来进一步简化语法,不再推荐使用 C# 2.0 引入的“匿名方法”。但归根结底,所有这些语法糖都会为了更简单地生成匿名函数。————译注
编译器选择的方法名以下<
符号开头,这是因为在 C# 中,标识符是不能包含<
符号的;这就确保了你不会碰巧定义一个编译器自动选择的名称。顺便说一句,虽然 C# 禁止标识符包含<
符号,但 CLR 允许,这是为什么不会出错的原因。还要注意,虽然可将方法名作为字符串来传递,通过反射来访问方法,但 C#语言规范指出,编译器生成名称的方法是没有任何保证的。例如,每次编译代码,编译器都可能为方法生成一个不同的名称。
注意C# 编译器向方法应用了 System.Runtime.CompilerServices.CompilerGeneratedAttribute
特性,指出该方法由编译器生成,而非程序员写的。=>
操作符右侧的代码被放入编译器生成的方法中。
注意 写 lambda 表达式时没有办法向编译器生成的方法应用定制特性。此外,不能向方法应用任何方法修饰符(比如
unsafe
)。但这一般不会有什么问题,因为编译器生成的匿名函数总是私有方法,而且方法要么是静态的,要么是非静态的,具体取决于方法是否访问了任何实例成员。所以,没必要向方法应用public
,protected
,internal
,virtual
,sealed
,override
或abstract
之类的修饰符。
最后,如果写前面的代码并编译,C# 编译器会将这些代码改写为下面这样(注释是我自己添加的):
|
|
lambda 表达式必须匹配 WaitCallback
委托:获取一个 Object
并返回 void
。但在指定参数名称时,我简单地将obj
放在=>
操作符的左侧。在=>
操作符右侧,Console.WriteLine
碰巧本来就返回 void
。然而,如果在这里放一个返回值不为void
的表达式,编译器生成的代码会直接忽略返回值,因为编译器生成的方法必须用 void
返回类型来满足 WaitCallback
委托。
另外还要注意,匿名函数被标记为 private
,禁止非类型内定义的代码(尽管反射能揭示出方法确实存在)。另外,匿名函数被标记为static
,因为代码没有访问任何实例成员(也不能访问,因为 CallbackWithoutNewingADelegateObject
本身是静态方法)。不过,代码可引用类中定义的任何静态字段或静态方法。下面是一个例子:
|
|
如果 CallbackWithoutNewingADelegateObject
方法不是静态的,匿名函数的代码就可以包含对实例成员的引用。不包含实例成员引用,编译器仍会生成静态匿名函数,因为它的效率比实例方法高。之所以更高效,是因为不需要额外的this
参数。但是,如果匿名函数的代码确实引用了实例成员,编译器就会生成非静态匿名函数:
|
|
=>
操作符左侧供指定传给 lambda 表达式的参数的名称。下例总结了一些规则:
|
|
对于最后一个例子,假定 Bar
的定义如下:
delegate void Bar(out Int32 z);
=>
操作符右侧供指定匿名函数主体。通常,主体包含要么简单、要么复杂的表达式,并最终返回非void
值。刚才的代码为所有 Func
委托变量赋值的都会返回String
的 lambda 表达式。匿名函数主体还经常只由一个语句构成。调用 ThreadPool.QueueUserWorkItem
时就是这种情况,我向它传递了调用 Console.WriteLine
(返回 void
)的一个 lambda 表达式。
如果主体由两个或多个语句构成,必须用大括号将语句封闭。在用了大括号的情况下,如果委托期待返回值,还必须在主体中添加 return
语句,例如:
Func<Int32, Int32, String> f7 = (n1, n2) => { Int32 sum = n1 + n2; return sum.ToString(); };
重要提示 lambda 表达式的主要优势在于,它从你的源代码中移除了一个“间接层”(a level of indirection),或者说避免了迂回。正常情况下,必须写一个单独的方法,命名该方法,再在需要委托的地方传递这个方法名。方法名提供了引用代码主体的一种方式,如果要在多个地方引用同一个代码主体,单独写一个方法并命名确实是理想的方案。但如果只需在代码中引用这个主体一次,那么 lambda 表达式允许直接内联那些代码,不必为它分配名称,从而提高了编程效率。
注意 C# 2.0 问世时引入了一个称为匿名方法的功能。和 C# 3.0 引入的 lambda 表达式相似,匿名方法描述的也是创建匿名函数的语法。新规范(C#语言规范 7.14 节)建议开发人员使用新的 lambda 表达式语法,而不是使用旧的匿名方法语法,因为 lambda 表达式语法更简洁,代码更容易写、读和维护。当然,Microsoft C# 编译器仍然支持用这两种语法创建匿名函数,以兼容当年为 C# 2.0 写的代码。在本书中,我只解释并使用 lambda 表达式语法。
17.7.3 简化语法 3:局部变量不需要手动包装到类中即可传给回调方法
前面展示了回调代码如何引用类中定义的其他成员。但有时还希望回调代码引用存在于定义方法中的局部参数或变量。下面是一个有趣的例子:
|
|
这个例子生成地演示了 C# 如何简单地实现一个非常复杂的任务。方法定义了一个参数 numToDo
和两个局部变量 squares
和 done
。而且 lambda 表达式的主体引用了这些变量。
现在,想象 lambda 表达式主体中的代码在一个单独的方法中(确实如此,这是 CLR 要求的)。变量的值如何传给这个单独的方法?唯一的办法是定义一个新的辅助类,这个类要为打算传给回调代码的每个值都定义一个字段。此外,回调代码还必须定义成辅助类中的实例方法。然后, UsingLocalVariablesInTheCallbackCode
方法必须构造辅助类的实例,用方法定义的局部变量的值来初始化该实例中的字段。然后,构造绑定到辅助对象/实例方法的委托对象。
注意 当 lambda 表达式造成编译器生成一个类,而且参数/局部变量被转变成该类的字段后,变量引用的对象的生存期被延长了。正常情况下,在方法找中最后一次使用/局部变量之后,这个参数/局部变量就会“离开作用域”,结束其生命期。但是,将变量转变成字段后,只要包含字段的那个对象不“死”,字段引用的对象也不会“死”。这在大多数应用程序中不是大问题,但有时要注意一下。
这项工作非常单调乏味,而且容易出错。但理所当然地,它们全部由 C# 自动完成。写前面的代码时,C# 编译器实际是像下面这样重写了代码(注释是我添加的):
|
|
重要提示 毫无疑问,C# 的 lambda 表达式功能很容易被程序员滥用。我开始使用 lambda 表达式时,绝对是花了一些时间来熟悉它的。毕竟,你在一个方法中写的代码实际不在这个方法中。除了有违直觉,还使调试和单步执行变得比较有挑战性。但事实上,Visual Studio 调试器还是非常不错的。我对自己源代码中的 lambda 表达式进行单步测试时,它处理得相当好。
我为自己设定了一个规则:如果需要在回到方法中包含 3 行以上的代码,就不使用 lambda 表达式。相反,我会手动写一个方法,并为其分配自己的名称。但如果使用得当,匿名方法确实能显著提高开发人员的效率和代码的可维护性。在以下代码中,使用 lambda 表达式感觉非常自然。没有它们,这样的代码会很难写、读和维护。
|
|
17.8 委托和反射
本章到目前为止,使用委托都要求开发人员事先知道回调方法的原型。例如,假如 fb
是引用了一个 Feedback
委托的变量(参见本章 17.1 节的示例程序),那么为了调用这个委托,代码应该像下面这样写:
fb(item); // item 被定义 Int32
可以看出,编码时必须知道回调方法需要多少个参数,以及参数的具体类型。还好,开发人员几乎总是知道这些信息,所以像前面那样写代码是没有问题的。
不过在个别情况下,这些信息在编译时并不知道。第 11 章“事件”讨论 EventSet
类型时曾展示了一个例子。这个例子用字典来维护一组不同的委托类型。在运行时,为了引发事件,要在字典中查找并调用一个委托。但编译时不可能准确地知道要调用哪个委托,哪些参数必须传给委托的回调方法。
幸好 System.Delegate.MethodInfo
提供了一个 CreateDelegate
方法,允许在编译时不知道委托的所有必要信息的前提下创建委托。下面是 MethodInfo
为该方法定义的重载:
|
|
创建好委托后,用 Delegate
的 DynamicInvoke
方法调用它,如下所示:
|
|
使用反射 API(参见第 23 章 “程序集加载和反射”),首先必须获取引用了回调方法的一个 MethodInfo
对象。然后,调用CreateDelegate
方法来构造由第一个参数delegateType
所标识的Delegate
派生类型的对象。如果委托包装了实例方法,还要向CreateDelegate
传递一个target
参数,指定作为this
参数传给实例方法的对象。
System.Delegate
的DynamicInvoke
方法允许调用委托对象的回调方法,传递一组在运行时确定的参数。调用 DynamicInvoke
时,它会在内部保证传递的参数与回调方法期望的参数兼容。如果兼容,就调用回调方法:否则抛出ArgumentException
异常。DynamicInvoke
返回回调方法所返回的对象。
以下代码演示了如何使用CreateDelegate
方法和DynamicInvoke
方法:
|
|