第 77 讲:C# 2 之委托逆变参数和协变返回
我们经常使用委托来“代理”一个函数。我们使用 new 委托(方法名)
的方式来告诉运行时,我这个方法我将在以后会用到,并且执行,但不是现在。可某些时候,问题总是离谱的。
C# 2 对委托类型有三个扩展语法规则,其中两个“方法组转换”和“匿名函数”已经说过了,下面要说的是第三种语法规则,它包含下面两种情况:
协变返回(Covariance on Return)
逆变参数(Contravariance on Parameter)
它们均针对的是委托类型赋值方的方法组。
本节内容微微有点难,如果看不懂也无妨。不过我在网上搜了很多资料,貌似对这个特性的介绍并不多。所以我这里尽量对这个语法特性的机制说得清楚一些。
Part 1 参数的逆变性
1-1 理解参数的逆变性
考虑一种情况。假设我有一个 MammalHandler
类型的委托,是这样的:
那么,这个委托就可以接受一个参数类型是 Mammal
类型的实例,并不返回任何数值的方法。这个 mammal 意思是“哺乳动物”。我们假设这个委托类型表达一个意思是,处理一个哺乳动物的基本数据信息。
现在,我有如下的三种数据类型:Animal
、Mammal
和 Cat
,分别表示和对应“动物”、“哺乳动物”和“猫咪”。
那么请看下面的代码:
Mammal
参数类型都对不上了,而且 Animal
派生了 Mammal
类型出来。那么,这样的写法是正确的吗?你肯定会说是错误的,因为类型完全就不兼容嘛。我怎么可能会传入一个比它“大”的类型呢?再怎么说也应该是传入比它“小”的兼容类型吧。可事实是,从 C# 2 开始,OnAnimalHandling
可以得到允许,而 OnCatHandling
反而不允许。这不是挺奇怪吗?为啥会有这样的现象?
想想看,我要求的是在委托类型里接受和兼容的数据类型是 Mammal
,而我添加到回调函数列表的这个 OnCatHandling
却仅允许接受一个 Cat
类型的实例。你觉得,这科学吗?显然不科学。我们兼容和允许接受的类型是是一个哺乳动物,这意味着什么长颈鹿啊、什么猪猪啊、什么狗狗之类的哺乳动物也都应该从哺乳动物类型 Mammal
这里派生下来,这是良好的设计。而正是因为这种设计原则,所以我完全可能在调用 handler
委托实例的时候传入一个不是 Cat
类型的实例进去。如果我们允许 OnCatHandling
这样的方法成立的话:
那么假设前文的 handler += OnCatHandling
成立的话,就会出现完全不正常、不稳定、不安全的类型错误:你传入了 Giraffe
类型实例完全跟 Cat
就不相关,这肯定是不安全的类型转换模式和处理机制。因此,在 C# 这个编程语言的语法设计里,并未允许 Cat
类型作为传参的方法可以赋值给委托实例,因为类型并不兼容。
从这种视角来看,由于 Cat
是 Mammal
的其中一种派生类型,那么按照这种语言设计规则来看,这种不允许的情况应当推广到任何派生类型上去。因此,C# 规定,委托实例的回调函数的参数必须是委托类型签名的参数的相同类型,或是它的基类型。而在早期 C# 的原生语法里,“或是它的基类型”这一部分内容是不允许的。C# 2 里则允许的是这一部分情况。
好了。可能你还是觉得一头雾水,那么我们再来说一下,允许这种机制到底是为了干嘛。
1-2 逆变参数的真正用途
下面我用窗体程序的相关知识点给大家介绍一下为啥这个机制得已存在并且好用的原因。如果你没有接触过窗体程序的话,那么你可以先跳过,等以后你学了一些相关的处理机制后,我们再回头看也是可以的。
在窗体程序之中,我们经常会遇到处理按下按键、鼠标点击等等和键鼠这些硬件交互操作的过程,它们在 C# 里往往都用事件来表示。要知道,事件就是委托字段的封装,因此我们这里就当成委托实例的交互来理解就行。
考虑一种情况,我要想让键盘按键和鼠标点击都执行同一个方法操作,这可能吗?显然不可能。因为鼠标点击后,肯定交互数据是跟鼠标有关的;而在键盘操作后,肯定交互数据是跟键盘有关的。假设我们给鼠标点击期间的交互数据封装为 MouseEventArgs
类型来包裹这些信息,而给键盘操作期间的交互数据封装成 KeyboardEventArgs
类型来包裹这些信息,而它们均从 EventArgs
类型派生:
那么,我封装了两个委托类型来分别表示和处理鼠标操作和键盘操作的过程:
MouseClick
来表达我现在鼠标按下后要执行什么方法;而 KeyboardPress
现在,有了 C# 2 提供的这个机制,我们就可以“一劳永逸”了:
我们全让事件处理的时候,都只回调 OnEventOccurred
这个方法。而 OnEventOccurred
方法此时允许的参数类型只需要是委托实例对应参数类型的基类型即可,而恰好我们直接写上 EventArgs
就行;所以这么做相当方便。
1-3 为什么叫逆变参数?
可问题来了。为啥这种机制叫逆变呢?这参数的类型兼容被“放大”了,这难道不是协变吗?呃……这个命名其实是看的你实际传入的参数类型和实际类型的对比关系。你想想看,我允许 Mammal
为类型参数传入,而你传入的确实是 Animal
类型,甚至比 Mammal
类型还要“大”。但是,你在调用的时候,这个 Animal
会被缩小范围到 Mammal
类型去调用和使用,你真正在开始 Invoke
委托实例的时候,你只能传入 Mammal
类型的实例进去,而并不能传入 Animal
类型的实例进去。所以,真正意义上来看,是允许了基类型参数赋值到了派生类型参数的委托类型上去。这就是一个反向的类型兼容的机制,所以才叫它逆变参数。
1-4 不只是派生类,还可以是派生的接口
如题,这种机制除了可以允许派生类型的逆变参数以外,你也可以用到接口上。换句话说,假如 Animal
从 IAnimal
接口派生(即实现了 IAnimal
接口),你甚至可以允许把一个传入 IAnimal
接口的方法给传入赋值到 MammalHandler
委托实例上去:
Part 2 返回值的协变性
2-1 理解返回值的协变性
我们还是使用前面给出的那些数据类型来举例。不过这次我们为了说明返回值是协变的,我们需要重新创建一个新的委托类型。
假设我们有一个委托类型 MammalCreator
,返回一个 Mamal
类型,表达的意思是“创造一个哺乳动物”,它的类型声明是这样的:
Mammal
是的,这样的写法确实没有问题,因为不带参数,返回值的类型也是合适的。现在我这么去改造一下方法:
其实也没改造很多地方,也就第 3 行的返回值类型从 Mammal
改成了更为具体的 Cat
类型。这是允许的吗?是的,这是允许的;但反过来你改成 Animal
这些就不行了。这是为什么呢?
这个比前文的参数逆变性要好说一些。仔细思考一下,我给的回调函数,它的返回值类型如果比委托类型原本给的返回值类型的范围还要大的话,会有什么样的结果?是不是就不安全了啊?我明明出的是 Mammal
类型,可你居然给我出了一个 Animal
类型出来。委托可都没允许我们这么返回,你居然这么大张旗鼓地返回一个更大的类型,那自然 C# 肯定不让你这么做。没明白?那我换个说法。委托给的是 Mammal
类型作为返回值,可你返回了一个超出 Mammal
类型的实例(比如 Animal
这样的类型),自然是不允许的。因为你只凭借这个回调函数自身来看,由于你的返回值是 Animal
类型,而委托类型必须要求你返回 Mammal
类型,而你又根本无法保证和约束你这个方法一定返回的是 Mammal
的实例,所以编译器自然不让你这么干。
那么,推广到比较广泛的层面来说,委托实例的回调函数的返回值必须是委托类型签名的返回值的相同类型,或是它的派生类型。
2-2 协变返回的用途
这有啥好说的,都可以认定为具体类型了,那么自然我们更喜欢和更习惯去处理具体类型。而范围更“大”的数据类型我们则根本不能确认它的类型,进而不容易去处理一些事情。于是乎,有了这种机制,我们就可以让回调函数是具体的类型,这样更容易处理一些。
2-3 为什么叫协变返回?
emmm,虽然光看这段结论确实也是有点分不清,不过我们还是可以使用前面完全一样的思路去理解。
协变返回之所以是协变性,是因为这种机制允许我们的回调函数的返回值类型是一个派生类型,而它相对于委托类型自身带有的返回值类型来说,是协变的,毕竟我们定义的回调函数的返回值更“小”,而委托签名的返回值类型则更“大”一些。
2-4 当然,它也可以用于接口
是的,它也可以用在接口类型上。比如:
如果委托类型的签名的返回值是 IAnimal
接口类型的话,那么你给的回调函数的返回值就可以是所有实现了 IAnimal
接口的类型。
Part 3 匿名函数的参数和返回值的可变性不在考虑范畴
很遗憾的是,我们前文举例说明的时候,都没有使用匿名函数机制,是因为我忘了用吗?实际上并不是。匿名函数的参数和返回值的可变性并未考虑在内。换句话说,即使我们知道前文的这样的代码是成立的:
但,我们更换为匿名函数语法,却并不行:
是的,你打开 Visual Studio,编写代码后,编译器并不予以通过,并且会告诉你,类型不兼容的错误信息。因此,这种委托可变性的机制仅用于原生语法。