一文带你了解C#中的协变与逆变

协变

协变概念令人费解,多半是取名或者翻译的锅,其实是很容易理解的。

比如大街上有一只狗,我说大家快看,这有一只动物!这个非常自然,虽然动物并不严格等于狗,但不会有人觉得我说的不对,把狗变成动物就是协变,C#也支持这个:

// C#6顶级语句
Dog dog= new Dog();
Animal animal= dog;

interface Animal
{}

class Dog : Animal
{}

那么接下来,大街上有一群狗,我说有一群动物,按理说也是对的,但看样子C#不这么认为

List dogLst = new List();
List aniLst = dogLst;       //飙红飙红飙红了
interface Animal {}
class Dog : Animal {}

原因其实很容易理解,毕竟在上述的代码中,写了Dog:Animal,即声明了狗是动物的子类,但是并没有写List : List,换言之,从来没有声明过一群狗是一群动物的子类。

但是,如果不用List,而用其父类IEnumerable,写成下面这样,就又不报错了。

List dogLst = new List();
IEnumerable aniLst = dogLst;

换言之,C#承认List是IEnumerable的子类,个中差别,只需一览源码,就会知晓:

public interface IEnumerable : IEnumerable
public class List : ..., IEnumerable, ...

IEnumerable无非比List多了一个out参数,有了这个参数,就拥有了协变的功能,从而当U是T的子类时,可以支持IEnumerable到IEnumerable的转换。

在官方文档中,指明了具有out关键字的泛型接口包括IEnumerable, IEnumerator, IQueryable和IGrouping

协变接口的实现

协变和逆变目前只能在泛型接口和委托中使用,下面新建一个泛型接口,并使用关键字out。由于使用.Net6.0的顶级语句,所以接口和类的声明放在后面。

IOut outStr = new Out();
IOut outObj = outStr;
Console.WriteLine(outObj.getName());

interface IOut
{
    T getName();
}

class Out : IOut
{
    public string getName()
    {
        return GetType().Name;
    }
}


编译运行,最后输出Out,即outObj尽管在声明的时候用的是IOut,但在IOut的out修饰符的作用下,成功让IOut变成了IOut的父类,得以顺利调用Out中的方法。

那么接下来,如果想让getName更加完备一些,例如要求实现getName(T name)这样的功能,那么经out修饰的协变接口就无能为力了,像下面这样的写法果然被无情地飙红了

interface IOut
{
    void getName(T name);
}

逆变

VS作为宇宙顶级IDE,协变逆变十分拎得清,上述代码在飙红的同时,直接给出如下错误

变型无效: 类型参数“T”必须是在“IOut.getName(T)”上有效的 逆变式。“T”为 协变。

换言之,如果想让泛型接口可以输入泛型参数,那么需要用到逆变,具体写法如下,其中修饰符in表示逆变

IIn inObj = new In();
IIn inStr = inObj;
inStr.getName("in");

interface IIn
{
    void getName(T name);
}

class In : IIn
{
    public void getName(object name)
    {
        Console.WriteLine(name);
    }
}


逆变和协变最大的不同,并非in和out这两个修饰符的字数,而是整个替换逻辑发生了变化,上述代码中,实际上是作为子类的string调用了通过父类object作为参数定义的函数。

里氏替换原则

在具体实现了协变与逆变之后,总觉得那里怪怪的,最怪的其实还是下面这行代码的错误

//错错错错错错错错错错错错错错错错错错错错错错错错错错
interface IOut
{
    void getName(T name);
}

而且可以想象,与之相对应的下面的逆变代码也是不对的

//错错错错错错错错错错错错错错错错错错错错错错错错错错
interface IOut
{
    T getName();
}

接下来复盘一下产生这种现象的原因,为了破除命名带来的困扰,接下来考虑泛型接口I,其中有一个函数T test(T t)。现有两个特定的继承自泛型接口I和I的类,假设I要调用I中的方法,那么其流程如下

1.A I.test(A t),即输入一个A类型的参数

2.将这个A类型的参数t,传入到B I.test(B t)。由于I要求输入B类型的参数,所以要求A可以转换为B类型。

3.B I.test(B t)计算完毕,返回一个B类型的参数

4.这个B类型的参数又被返回给最初的调用者A I.test,而这时I的函数最终将返回一个A类型的参数,换言之,在这个步骤,要求B可以转换为A。

A能转为B,然后还得B能转为A,同时A和B还不相等,这显然是不可能的。

所以逆变和协变分别实现了第2步和第4步。

如果I想要调用Itest(B t)中的函数,那么A类型必须可以转成B类型。正如string可以转为object一样,此即逆变,用in修饰,其作用场合为子类调用父类中的方法。

如果I想要调用B Itest(),那么作为返回值的B类型必须可以转化为A类型,此即协变,用out修饰,正是父类调用子类的方法。

协变和逆变的统一之处在于,二者都严格遵循这子类可以转变为父类的规则,此即里氏替换。这是1987年,芭芭拉·利斯科夫提出的,她也是2008年图灵奖得主。

在协变逆变的过程中,对里氏替换的遵循主要表现在当子类方法重载父类方法时

  • 方法的输入参数要更加宽松,此即逆变(IOut调用IOut,object比string更宽松)
  • 方法的返回值要更加严格,此即协变(IOut调用IOut,string比object更严格)

    以上就是一文带你了解C#中的协变与逆变的详细内容,更多关于C# 协变 逆变的资料请关注脚本之家其它相关文章!

    你可能感兴趣的:(一文带你了解C#中的协变与逆变)