第 49 讲:委托(二):委托的加减法及基类型介绍
没有办法,委托是一个非常重要的概念。在你学习和使用窗体程序的时候,其实你应该知道的是 C# 写这些东西非常方便。以前的 WinForm、现在的 WPF、UWP、甚至是跨平台 UI 框架 MAUI,都是 C# 开发的;而且这些窗体程序的框架,底层代码大量依赖委托和事件。如果你不学好这些的话,以后也会寸步难行。所以一定要开动脑筋多思考。
Part 1 委托的加法
委托除了前面指代方法,进行回调(Callback)的特效以外,委托还有一个特别棒的、前文没有介绍的特效:一个委托实例并不是只能调用它回调的那一个唯一的方法。实际上,一个委托实例可以无限往后追加回调函数,到时候通过一次 Invoke
调用,所有方法全部得到调用。下面我们来演示一下这个例子。
首先,我们预留 4 个处理不相同的方法,它们的签名是一样的:无参数无返回值。
接下来,我们定义一个无参数无返回值的委托类型:
Handler
+
运算符,然后累计 handler
注意第 2、3、4 行代码。我们使用 +=
运算符,它等价于 handler = handler + new Handler(方法名)
的语句。我们执行三次,这表示把 A
、B
和 D
四个方法全部累计到 handler
委托对象里去。
接着,我们使用 Invoke
对委托对象 handler
进行回调。
我们开始运行程序,你可以看到,1、2、3、4 会顺次输出到屏幕上。这就是整个程序的执行效果。那么我们回头来说一下整个委托加法运算的意思。
委托加法是将两个同类型的委托对象通过这个运算符,将里面的回调函数归并一起,形成一个委托对象。比如
这样的句子。假设 handler1
和 handler2
分别存储了回调函数 A
和回调函数 B
的话,那么最终 handler
这个对象里就存储了 A
和 B
两个回调函数。那么,一旦我们启动对 handler
这个整合的委托对象的回调(即调用 handler.Invoke()
)的话,那么你就可以按次序看到 A
方法被调用,然后是 B
方法被调用。这个就是委托的加法运算,它和字符串拼接是差不多效果的。
另外补充一下。因为是类似字符串拼接,所以是顺次拼接进去得到合并结果,所以原始情况下回调函数的顺序是如何的,加法得到的结果里,回调函数的顺序也是相对一样的,不会发生变动。
另外。如果一个委托对象本身就包含了很多回调函数了,那么作为被加数或者加数放在委托加法运算里的话,那么我们这些方法也会顺次加进去。比如说
handler1
已经存储了A
和B
两个回调函数了,而handler2
里存储了C
回调函数的话,那么整个加法就会得到一个委托对象,顺次存储了A
、B
和C
三个回调函数。然后在开始回调(调用handler.Invoke()
)的时候,A
、B
和C
会按照顺序得到调用。
Part 2 委托的减法
这个可能就没办法和字符串作对比和比较了。因为字符串没有减法运算的关系,我们无法参考来理解。委托的减法是这样的行为:
如果两个相同委托类型的对象作减法,假设是 handler1 - handler2
的话:
如果
handler1
里的回调函数列表的某个子序列和handler2
一样,那么这个子序列的所有回调函数全部会被删除;如果不满足前一条规则(即其它情况下),
handler1
不会作任何行为,得到的结果也和handler1
这个被减数是一样的结果。
这两点是什么意思呢?委托实际上存储的是一系列的回调函数,它们用一个表表示出来(当然这个表里也可以只存储一个回调函数,就类似于我们上一讲内容讲到的那种情况)。其中连续的若干回调函数被称为一个子序列。如果被减数 handler1
包含 handler2
连顺序都一样的回调函数序列的话,那么这一段子序列就会被减掉。比如 handler1
包含 A
到 F
六个方法,而 handler2
包含 C
、D
的话,那么 handler1 - handler2
的结果就是 A
、B
、E
、F
顺次构成的序列作为回调函数列表的委托对象,因为 C
和 D
在 handler1
里包含这个子序列。
另外,如果 handler1
还是 A
到 F
,但 handler2
的回调函数列表是 D
和 C
(注意顺序反过来了,我们可能是先对 handler2
实例化了 D
作为回调函数,然后才用加法运算把 C
累计到 handler2
里)的话,由于 handler1
的子序列不存在 D
和 C
这种情况,所以 handler1 - handler2
的结果和 handler1
原来的结果完全一样。
所以,委托的加法是直接拼接起来,但委托的减法则会看回调函数列表的次序。当然,如果减法情况下,handler2
只包含一个回调函数,那么肯定只要 handler1
里有这个回调函数,那么百分之百都可以减掉。稍微需要注意的是次序的问题。
Part 3 多播委托的基本概念
我们把一个委托对象包含多个回调函数的时候的情况称为多播委托(Multicast Delegate)。“多播”一词来自于计算机网络里的“单播”、“多播”、“广播”、“组播”的“多播”,表示一种传递的过程,因为每一个回调函数都会得到执行,好像第一个回调函数执行完了就传递给第二个,让第二个执行;第二个回调函数执行完毕了就传递给第三个执行。当然,和这个概念对应的就是单播委托(Unicast Delegate)了,表示一个委托类型的对象只包含一个回调函数的时候的情况。
Part 4 委托的基类型:MulticastDelegate
类
正是因为多播委托的名字叫做 multicast delegate 的关系,所以委托的基类型是 MulticastDelegate
类。这里稍微注意一点。虽然有的时候委托类型的对象可以只含有一个回调函数(即这个对象是个单播委托),但是这个委托对象的类型仍然是从 MulticastDelegate
类型派生出来的。这是因为这个类型本身就具有可多播委托的潜力和能力,所以它可以用在多播委托上,因此它必然是从这个类型派生的;并不是说它只有一个回调函数就不走这里派生了。希望你把这个概念搞清楚。
MulticastDelegate
其实也没有什么要说的,因为它只是为了提供一种多播委托执行的约束和底层的支持,所以它本身对我们实现代码,调用逻辑来说都没有多大的帮助和作用,你只需要知道它是客观存在的就可以了。
Part 5 委托的最终基类型:Delegate
类
我们前文说过,委托类型的继承关系很复杂,因为它的基类型就有两个,一个是 MulticastDelegate
类,另外一个则是 Delegate
类。我们还说过,MulticastDelegate
类型还是从 Delegate
类型派生下来的。下面我们来说一下 Delegate
这个类的用法和基本内容。
Delegate
类型也是一个抽象类。这个抽象类里提供了很多有关委托基本操作和行为的方法,比如我们要学习的有:
Delegate.Combine
静态方法和Delegate.Remove
静态方法Delegate.DynamicInvoke
实例方法Delegate.Equals
实例方法、Delegate.operator ==
和Delegate.operator !=
运算符
其它的还有一些别的方法,不过没有必要讲,因为用不上不说而且还比较麻烦,部分还是超纲的东西。下面我们来挨个说明。
5-1 Combine
方法:委托加法的底层
Combine
方法实际上就是委托加法运算的底层操作。如果我们要把两个委托类型的对象加起来,我们使用加法会非常方便;不过底层的代码是这样的:
是的。我们使用 Delegate
自带的静态方法 Combine
来结合两个同委托类型的对象。最后得到了一个相同委托类型的结果,并赋值给左侧。因为 Delegate
类型的这个方法我们是无法从代码层面知道它的具体委托类型的,所以它传入的参数实际上是两个 Delegate
类型的对象。同理,因为不清楚类型的原因,返回值类型也是 Delegate
类型的对象。实际上,Delegate
类型我们是无法确定具体是什么类型的,这一点和之前介绍传入 object
的道理完全一样,因为想把方法通用化,所以就这么干了。
正是因为返回值类型是 Delegate
这个抽象类型的缘故,我们需要强制转换才能赋值给具体类型,因此才有了这里的 (Delegate)
强制转换运算符。
5-2 Remove
方法:委托减法的底层
既然有加法,就有减法对应的操作。和 Combine
方法的套路完全一样,Remove
方法翻译的时候也是方法传参带强转。
5-3 DynamicInvoke
方法:对不知道具体类型的委托进行调用
倘若我们并不知道不清楚一个委托类型的具体类型,因为它固定从 MulticastDelegate
类型派生,而它又是 Delegate
的子类型,所以 Delegate
类型的这个方法可能对你会很有帮助。
如果方法我们只知道签名,但委托的类型不定的时候,我们基本寸步难行。为了避免这样的问题发生,C# 设计了一个特别神奇的方法:DynamicInvoke
。这个方法在你不知道委托类型,而只是知道委托类型的回调函数签名的时候,就可以直接用。
@delegate
变量的回调函数方法是无参数无返回值时,就可以直接使用 DynamicInvoke
方法进行对方法的调用。如果方法带有返回值,你还可以把整个调用表达式作为一个数值写在赋值运算符的右侧。只是说,因为你的类型不知道的关系,为了通用性的缘故,这个 DynamicInvoke
方法最终返回的类型是 object
5-4 委托的相等性比较,以及委托的结构不一致性
如果两个委托类型的对象要想一样(相等),需要满足下面的条件:
两个委托对象的类型一样;
两个委托对象的回调函数列表包含的方法一样;
两个委托对象的回调函数的回调次序也必须一样。
但凡其中有一个不满足,两个委托对象都是不相等的。下面我们来看一个例子。
如代码所示,我们有三个委托对象 handler
、handler2
和 handler3
。请问,三个委托类型的对象是不是一样的?
答案是,两两都不一样。第一个和第二个不相等原因很简单:handler2
最后删除了 A
方法,所以回调函数列表就已经不同了;而 handler3
和 handler1
的差别在于,C
和 D
加入到回调函数的次序是不一样的。由于 handler3
是先加入了 D
后加入 C
的关系,所以和 handler1
的次序不同,因此两个委托对象也不相等。
Equals
实例方法来获得,也可以使用运算符 ==
和 !=
来获得。
说完委托类型的相等性后,我们来说一下委托的结构不一致性。这个词语过于术语化,所以可能不太明白,你可以认为是这么一种东西:
因为委托类型是可以自定义的参数类型和返回值类型的,所以我们完全可以自定义两个签名完全一样但委托类型名称不同的委托类型。
比如上面这两个委托类型,一个叫 Assignment
,另外一个则是叫 Handler
。虽然它们的签名一致(无参数无返回值),但正是因为类型名本身不同,所以它们仍然是无法通用的。
举个例子。假设我有一个 Assignment
委托类型的对象,我尝试把它赋值给 Handler
类型,可以吗?不可以,因为类型不同。
这个称为委托的结构不一致性。因为委托的底层是一个类,所以两个委托类型就对应了两个类。两个不同的类就意味着无法互相转换。你之前也学过面向对象对吧,你肯定知道无法随便把两个类型进行转换,对吧。只有继承关系,或者是自定义了转换关系的运算符才允许转换。
Part 6 为什么委托类型非要从两个完全不一样的基类型派生?
可能你会有这样的问题:为什么委托类型非得用 MulticastDelegate
和 Delegate
两个不同的基类型来作为固定的派生关系?你看别的类型,Enum
也好、Array
也好,它们虽然有的无法自定义继承关系,但是最多也就从这一个类型进行派生。为什么委托要分两个类型?或者换句话说,为什么不能让 MulticastDelegate
类型的代码内容全部一并丢进 Delegate
类型里?这样不就少一个类型了吗?
下面我们来说一下原因。其实原因很简单:做的东西和工作不一样。面向对象有一个基本的实现规范,叫做单一职责原则(Single Responsibility Principle)。单一职责原则说的是:一个类型只能做一件事情,就是它这个类型本身应该做的事情。这句话有点绕。我如果有一个 Person
类型,那么这个 Person
类型里写的代码就一定要跟 Person
它自己的行为有关系。比如说 Person
类型可以派生出 Teacher
子类型,但是 Teacher
可以教书但 Person
类型的对象不一定都会教书。你不能把子类型的工作放父类型里来。Delegate
只是表达委托的基本操作和行为,并不是跟多播委托绑定的概念。虽然我们经常说,委托都是多播的,但这并不代表所有的委托都一定要用多播委托的功能;那么委托类型就得有一个委托的基类型作为服务的提供。所以,多播委托是多播委托,委托是委托。