泛型类型协变\逆变详解

泛型类型协变\逆变详解

前言

​ 协变,逆变不是什么太新的概念.在常规的开发过程中,我们其实已经“接触”到过这类似的应用.

常规类型

​ 看下面这段代码

class Program
{
    static void Main(string[] args)
    {
        var p = CreatePerson("光头强");
    }
    static Person CreatePerson(string name)
    {
        var person=new Chinese();
        person.Name = name;
        return person;
    }
}
internal class Person
{
    internal string Name { get; set; }      
}
internal class Chinese : Person
{
    internal string City{get;set;}
}

上面代码中,从行为上”无形中”就使用到了“协变”的概念和c#中的协变概念有些不同,就在方法CreatePerson的地方.方法本身是返回Person类型,但我们使用Chinese这个子类进行了返回,毋庸置疑,子类从意义上是扩展了父类,需要父类的地方,使用子类是很自然的一种思维方式.这其实就是协变的意思.

泛型类型

​ 正因为这种自然的方式,所以,我们可能在泛型的时候,写如下的代码

class Program
{
    static void Main(string[] args)
    {        
        ISpeak<Chinese> pSpeak=new Speak<Chinese>();
        Speak(pSpeak);
    }
    static void Speak(ISpeak<Person> pSpeak)
    {
        pSpeak.SayHi();
    }
}
internal class Person
{
    public string Name { get; set; }

    internal virtual void SayHi()
    {
        Console.WriteLine($"{Name}:I'm a human");
    }
}
internal class Chinese : Person
{
    internal string City{get;set;}
}
public interface ISpeak<T>
{
    void SayHi();
}
internal class Speak<T>:ISpeak<T>
{
    public void SayHi()
    {
        Console.WriteLine("说点啥呢?");
    }
}

如上代码中,我们重点的地方在这个地方

 static void Main(string[] args)
    {        
        ISpeak<Chinese> pSpeak=new Speak<Chinese>();
        Speak(pSpeak);
    }
    static void Speak(ISpeak<Person> pSpeak)
    {
        pSpeak.SayHi();
    }

简而言之,就是我们在一个方法Speak(ISpeak<Person> pSpeak)中,我们使用泛型类型参数作为参数,并且对泛型使用了具体的类型Person类型作为实际的类型参数.调用的时候,我们使用了Chinese类型作为了类型参数,并传回来,如常规类型节中的代码代码中的方式一样,我们使用子类去替代了父类.事与愿违,上面的代码,连编译器都过不了,我们将收到下面的错误提示(不重要的信息我过滤掉了):

错误	CS1503	参数 1: 无法从“ISpeak<kangbianxiebian.Chinese>”转换为“ISpeak<kangbianxiebian.Person>”

错误很清晰,就是类型转换失败了,普通类型工作得好好得代码,在泛型的地方,不好使了.

​ 原因是什么呢?简而言之是泛型类型中,每一个泛型类型实例都会产生一个新的类型,新的类型之间不再有任何关系了.(通过反编译或者反射可以得到泛型类型编译以后的样子,这里就不展开讨论了.)

​ 像这种,我们觉得顺理成章的事情,并没有像我们想象的那样工作,这让人有点不爽.怎么解决呢,先来个简单粗暴的方式:

//原来的方法:
 static void Speak(ISpeak<Person> pSpeak)
 {
     pSpeak.SayHi();
 }
/*
*更改成如下  ↓
*/
//解决问题的方法
 static void Speak<T>(ISpeak<T> pSpeak)
 {
     pSpeak.SayHi();
 }

这种方式,简单粗暴,属于“偷换概念”的方式去解决了上面类型不兼容的形式,ISpeak<T>放宽了参数的范围,而不是解决了类型的兼容变化.

如何解决这个问题,就是我们要这篇文章要讨论的东西→协变逆变

概念

​ 首先,我们从文字上来了解一下这两个.net中比较高级的概念.

协变

概念:假定A可以转换为B,如果X<A>可以转换为X<B>,那么泛型X的类型参数是可协变的.

解析:注意,其中的“可转换”必须是隐式支持的.其中A和B的关系是A是B的子类或者A实现了B.

逆变

概念:假定A可以转换为B,如果X<B>可以转换为X<A>,那么泛型X的类型参数是可逆变的.

解析:注意,其中的“可转换”必须是隐式支持的.其中A和B的关系是A是B的子类或者A实现了B.

概念提炼

​ 协变和逆变在基本类型(即类型A,类型B)条件相同的情况下,对应的泛型类型可以隐式转换成另外一种类型.

疑问

​ 我们熟知面向对象的特性,子类转换成父类,是我们可能够直观接受的形式,但父类转换成子类,比如上面说到的逆变,就让人有点费解了.那我们就来理解一下这种情况.

概念理解

​ 如何理解呢,其实协变和逆变的概念主要是取决于泛型参数类型所在的位置.我们来看一个泛型接口

 public interface ISpeak<T,R>
 {
     R Say(T t);
 }
public class Speaker<T, R> : ISpeak<T, R>
{
    public R Say(T t)
    {
        Console.WriteLine($"{t}功能执行了");
        return default(R);
    }
}

这个接口,我们定义了两个类型参数TR,方法Say接收一个T类型的参数,返回一个R类型.现在我们假设有以下4个类型

//A的父子类
class A{}
class AChild:A{}

//B的父子类
class B{}
class BChild:B{}

接着,我们继续以接口的方法R Say(T t)来继续理解概念.

​ 先看返回值R类型上,如果要求我们返回一个类型A,那么我们可以返回一个类型AChild,这个是没问题的.再看参数T类型上,如果要求我们传入一个类型B,那么我们是可以传入一个类型BChild的,这个也是没问题的.这个是我们能够在面向对象特性的基础上完全理解的事情.

​ 基于上述代码,我们来实例化我们的ISpeak<T,R>的具体类型

var father=new Speaker<A,B>();
var child=new Speaker<AChild,BChild>();
//重点地方:能通过编译吗?你可以照着试试
father=child;

根据前言中的描述,我们可以大胆的推测到编译错误,原因之前也交代过了

错误	CS0029	Speaker<AChild, BChild>”隐式转换为“Speaker<A, B>”

从方法R Say(T t)的具体实现来看,我们的代码应该是“理所当然”的,子类传进来以后,是完全可以“工作”的,我们的要求,是否并不过分.

所以,这也是C#(.NET)关注到的内容,于是乎,他们通过两个关键字,来让我们上面所想变得可行,我们将泛型做一个微调

//注意前后变换地方
多in,out两个关键字
public interface ISpeak<in T,out R>
{
    R Say(T t);
}

就这么简单就ok了吗?

no!

what!!!!!!! 还需要很复杂?那我不看了!!!

留步!

还需要很小一步,这一步就是理解协变逆变的关键

我们的泛型类型参数应该调整以下顺序,如下代码

//原来的代码是
var father=new Speaker<A,B>();
var child=new Speaker<AChild,BChild>();
...

//修改后 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
//A和AChild的位置调换了,这里这么理解
ISpeak<AChild, B> father=new Speaker<AChild, B>();
ISpeak<A,BChild> child=new Speaker<A,BChild>();
father=child;

从最后的赋值语句:father=child;我们可以代换以下,形式上就等于:ISpeak<AChild, B>=ISpeak<A,BChild>,从参数位置上看,我们再次代换,得到

AChild=AB=Bchild,后者是我们滚瓜烂熟的方式,前者,我们就费解了,上下文中,A是AChild的父类,把父类给了子类,这是不是反了呢?

是!的确反了,这个地方就是我们喊了好几遍的--逆变!

(duang~~!我又蒙圈了,面向对象思想再次塌掉了,这可怎么得了?)

更重要的来了,我们要理解这个变化方式,就得从接口的方法看起,再次把接口代码贴上来

public interface ISpeak<in T,out R>
{
    R Say(T t);
}

声明中,out关键字修饰的R类型是我们Say方法的返回值,in关键字修饰的T是方法的参数,我们分别来看R,和T

  • R类型:作为返回值,我们可以返回任何可以隐式转换为R指定的类型的类型,也就是说,需要R类型或者R更“下层”的类型.比如上面的B=Bchild
  • T类型:作为参数,我们要兼容这个类型,就得更加“开放”这个类型的广度,如,类型指定为A的时候,我们需要A以及比A更“上层”的类型才能兼容.所以这个地方我们是将AChild=A的方式.

至此,协变,逆变的概念,我们就是解析清楚了.在此概念上,产生了以下我们要讲到的约定.

约定

​ 此出的约定主要是针对于协变和逆变的情况,和泛型约束是两个不相干的概念.

out关键字:修饰的类型,只能够用于返回值(或者说非方法的参数类型).只对返回值有效,方法参数中out类型参数是不具备协变性质的.

in关键字:修饰的类型,只能够作为方法的参数(输入类型的参数).只对输入参数有效

使用范围

​ 协变(out)、逆变(in)只能出现在泛型接口泛型委托之中.你也可以理解为,协变和逆变是和“方法”息息相关的.

意义

​ 扩大泛型类型的使用范围,或者说使泛型类型本身也符合面向对象的类型关系.通常在做高可复用的抽象的时候,我们可以为代码带来更高的复用性.

举个例子

​ .net framwork框架中, System.Collections.Generic命名空间下,有个IEnumerable的定义,正因为有了这个,我们可以这样使用

 interface IAnimalCatcher
 {
     IEnumerable<Animal> GetAnimals();
 }
class Animal{}
class  Bear:Animal,IAnimalCatcher
{
    private Bear[] bears = new Bear[20];
    public IEnumerable<Animal> GetAnimals()
    {
        return bears.ToList<Bear>();
    }
}

再者,我们来看System.Collections.Generic命名空间下的IList,它没有做协变的拓展,同样的代码如下

interface IAnimalCatcher
{
    IList<Animal> GetAnimals();
}
class Animal{}
class  Bear:Animal,IAnimalCatcher
{
    private Bear[] bears = new Bear[20];
    public IList<Animal> GetAnimals()
    {
        return bears.ToList<Bear>();
    }
}

就会得到我们熟悉的错误,这种情况,我们就只能够分开设计我们的接口,复用性就不如当前了,类型之间的继承关系也就打散了.

错误	CS0266	无法将类型“System.Collections.Generic.List<Bear>”隐式转换为“System.Collections.Generic.IList<Animal>”。存在一个显式转换(是否缺少强制转换?)

建议

​ 只要是这类泛型类型或者泛型委托,只要不是确认不需要进行扩展,就加上协变,逆变标记,此变化几乎是没有副作用的.

结尾

​ 文中仅仅是个人理解的阐述,难免有错误和主观的地方,如遇到这些情况,还请在评论区指出,以免误导了他人

posted @ 2021-02-22 22:44  OhMyBug  阅读(430)  评论(0)    收藏  举报