文章目录
协变
协变概念令人费解,多半是取名或者翻译的锅,其实是很容易理解的。
比如大街上有一只狗,我说大家快看,这有一只动物!这个非常自然,虽然动物并不严格等于狗,但不会有人觉得我说的不对,把狗变成动物就是协变,C#也支持这个:
cs
// C#6顶级语句
Dog dog= new Dog();
Animal animal= dog;
interface Animal
{}
class Dog : Animal
{}
那么接下来,大街上有一群狗,我说有一群动物,按理说也是对的,但看样子C#不这么认为
cs
List<Dog> dogLst = new List<Dog>();
List<Animal> aniLst = dogLst; //飙红飙红飙红了
interface Animal {}
class Dog : Animal {}
原因其实很容易理解,毕竟在上述的代码中,写了Dog:Animal
,即声明了狗是动物的子类,但是并没有写List<Animal> : List<Dog>
,换言之,从来没有声明过一群狗是一群动物的子类。
但是,如果不用List
,而用其父类IEnumerable
,写成下面这样,就又不报错了。
cs
List<Dog> dogLst = new List<Dog>();
IEnumerable<Animal> aniLst = dogLst;
换言之,C#承认List<Dog>
是IEnumerable<Animal>
的子类,个中差别,只需一览源码,就会知晓:
cs
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的顶级语句,所以接口和类的声明放在后面。
cs
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
修饰的协变接口就无能为力了,像下面这样的写法果然被无情地飙红了
cs
interface IOut<out T>
{
void getName(T name);
}
逆变
VS作为宇宙顶级IDE,协变逆变十分拎得清,上述代码在飙红的同时,直接给出如下错误
变型无效: 类型参数"T"必须是在"IOut.getName(T)"上有效的 逆变式。"T"为 协变。
换言之,如果想让泛型接口可以输入泛型参数,那么需要用到逆变,具体写法如下,其中修饰符in
表示逆变
cs
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
作为参数定义的函数。
里氏替换原则
在具体实现了协变与逆变之后,总觉得那里怪怪的,最怪的其实还是下面这行代码的错误
cs
错错错错错错错错错错错错错错错错错错错错错错错错错错
interface IOut<out T>
{
void getName(T name);
}
而且可以想象,与之相对应的下面的逆变代码也是不对的
cs
错错错错错错错错错错错错错错错错错错错错错错错错错错
interface IOut<in T>
{
T getName();
}
接下来复盘一下产生这种现象的原因,为了破除命名带来的困扰,接下来考虑泛型接口I<T>
,其中有一个函数T test(T t)
。现有两个特定的继承自泛型接口I<A>
和I<B>
的类,假设I<A>
要调用I<B>
中的方法,那么其流程如下
A I<A>.test(A t)
,即输入一个A类型的参数- 将这个
A
类型的参数t
,传入到B I<B>.test(B t)
。由于I<B>
要求输入B
类型的参数,所以要求A
可以转换为B
类型。 B I<B>.test(B t)
计算完毕,返回一个B
类型的参数- 这个
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
更严格)