一文带你了解C#中的协变与逆变
作者:微小冷
协变
协变概念令人费解,多半是取名或者翻译的锅,其实是很容易理解的。
比如大街上有一只狗,我说大家快看,这有一只动物!这个非常自然,虽然动物并不严格等于狗,但不会有人觉得我说的不对,把狗变成动物就是协变,C#也支持这个:
// C#6顶级语句 Dog dog= new Dog(); Animal animal= dog; interface Animal {} class Dog : Animal {}
那么接下来,大街上有一群狗,我说有一群动物,按理说也是对的,但看样子C#不这么认为
List<Dog> dogLst = new List<Dog>(); List<Animal> aniLst = dogLst; //飙红飙红飙红了 interface Animal {} class Dog : Animal {}
原因其实很容易理解,毕竟在上述的代码中,写了Dog:Animal,即声明了狗是动物的子类,但是并没有写List<Animal> : List<Dog>,换言之,从来没有声明过一群狗是一群动物的子类。
但是,如果不用List,而用其父类IEnumerable,写成下面这样,就又不报错了。
List<Dog> dogLst = new List<Dog>(); IEnumerable<Animal> aniLst = dogLst;
换言之,C#承认List<Dog>是IEnumerable<Animal>的子类,个中差别,只需一览源码,就会知晓:
public interface IEnumerable<out T> : IEnumerable public class List<T> : ..., IEnumerable<T>, ...
IEnumerable无非比List多了一个out参数,有了这个参数,就拥有了协变的功能,从而当U是T的子类时,可以支持IEnumerable<U>到IEnumerable<T>的转换。
在官方文档中,指明了具有out关键字的泛型接口包括IEnumerable<T>, IEnumerator<T>, IQueryable<T>和IGrouping<TKey,TElement>。
协变接口的实现
协变和逆变目前只能在泛型接口和委托中使用,下面新建一个泛型接口,并使用关键字out。由于使用.Net6.0的顶级语句,所以接口和类的声明放在后面。
IOut<string> outStr = new Out(); IOut<object> outObj = outStr; Console.WriteLine(outObj.getName()); interface IOut<out T> { T getName(); } class Out : IOut<string> { public string getName() { return GetType().Name; } }
编译运行,最后输出Out,即outObj尽管在声明的时候用的是IOut<object>,但在IOut的out修饰符的作用下,成功让IOut<object>变成了IOut<string>的父类,得以顺利调用Out中的方法。
那么接下来,如果想让getName更加完备一些,例如要求实现getName(T name)这样的功能,那么经out修饰的协变接口就无能为力了,像下面这样的写法果然被无情地飙红了
interface IOut<out T> { void getName(T name); }
逆变
VS作为宇宙顶级IDE,协变逆变十分拎得清,上述代码在飙红的同时,直接给出如下错误
变型无效: 类型参数“T”必须是在“IOut.getName(T)”上有效的 逆变式。“T”为 协变。
换言之,如果想让泛型接口可以输入泛型参数,那么需要用到逆变,具体写法如下,其中修饰符in表示逆变
IIn<object> inObj = new In(); IIn<string> inStr = inObj; inStr.getName("in"); interface IIn<in T> { void getName(T name); } class In : IIn<object> { public void getName(object name) { Console.WriteLine(name); } }
逆变和协变最大的不同,并非in和out这两个修饰符的字数,而是整个替换逻辑发生了变化,上述代码中,实际上是作为子类的string调用了通过父类object作为参数定义的函数。
里氏替换原则
在具体实现了协变与逆变之后,总觉得那里怪怪的,最怪的其实还是下面这行代码的错误
//错错错错错错错错错错错错错错错错错错错错错错错错错错 interface IOut<out T> { void getName(T name); }
而且可以想象,与之相对应的下面的逆变代码也是不对的
//错错错错错错错错错错错错错错错错错错错错错错错错错错 interface IOut<in T> { T getName(); }
接下来复盘一下产生这种现象的原因,为了破除命名带来的困扰,接下来考虑泛型接口I<T>,其中有一个函数T test(T t)。现有两个特定的继承自泛型接口I<A>和I<B>的类,假设I<A>要调用I<B>中的方法,那么其流程如下
1.A I<A>.test(A t),即输入一个A类型的参数
2.将这个A类型的参数t,传入到B I<B>.test(B t)。由于I<B>要求输入B类型的参数,所以要求A可以转换为B类型。
3.B I<B>.test(B t)计算完毕,返回一个B类型的参数
4.这个B类型的参数又被返回给最初的调用者A I<A>.test,而这时I<A>的函数最终将返回一个A类型的参数,换言之,在这个步骤,要求B可以转换为A。
A能转为B,然后还得B能转为A,同时A和B还不相等,这显然是不可能的。
所以逆变和协变分别实现了第2步和第4步。
如果I<A>想要调用I<B>test(B t)中的函数,那么A类型必须可以转成B类型。正如string可以转为object一样,此即逆变,用in修饰,其作用场合为子类调用父类中的方法。
如果I<A>想要调用B I<B>test(),那么作为返回值的B类型必须可以转化为A类型,此即协变,用out修饰,正是父类调用子类的方法。
协变和逆变的统一之处在于,二者都严格遵循这子类可以转变为父类的规则,此即里氏替换。这是1987年,芭芭拉·利斯科夫提出的,她也是2008年图灵奖得主。
在协变逆变的过程中,对里氏替换的遵循主要表现在当子类方法重载父类方法时
- 方法的输入参数要更加宽松,此即逆变(IOut<object>调用IOut<string>,object比string更宽松)
- 方法的返回值要更加严格,此即协变(IOut<string>调用IOut<object>,string比object更严格)
以上就是一文带你了解C#中的协变与逆变的详细内容,更多关于C# 协变 逆变的资料请关注脚本之家其它相关文章!