深入理解C#的协变和逆变及其限制原因

阅读本文需要的一些前置知识:
C#基本语法、C#的泛型使用、C#的运行过程
由于协变和逆变存在一些细节,在阅读时请注意“接口”和“类型”的差异,此外,文中有可能在不同的语境中将“结构体”和“值类型”混用,但表达的同一个意思。

协变和逆变是一个C#的泛型开发中经常会遇到但可能意识不到的问题,往往会遇到一些认为应该可以但结果却发生了编译器报错表示类型不正确的情况,虽然有一些其他的方式绕过一些限制,但理解了协变和逆变可以在遇到错误时清晰地认识到问题的所在,也有助于写出更健壮的代码。

从一个例子开始

从一个在平时开发中可能遇到的问题下始,看下面几行代码。

string str = "";
object obj = str;//通过编译
string[] strArray = new string[1];
object[] objArray = strArray;//通过编译
List<string> strList = new List<string>();
List<object> objList = strList;//编译器报错 Cannot convert source type 'System.Collections.Generic.List<string>' to target type 'System.Collections.Generic.List<object>'

我们知道,C#中所有类型都是从object派生的,因此下面object obj = str;是符合逻辑的,同样object[] objArray = strArray也是符合预期的,但是为什么使用泛型List<object> objList = strList;时却无法通过编译呢?
这就是本篇文章的主题:变体(Variance),上面的object[] objArray = strArray形式被称为协变,此外还有另一种变体形式被称为逆变(一些书籍或文章翻译为抗变)。
变体的主要形式包括协变(Covariance)逆变(Contravariance),后面我会使用What-Why-How的方式逐个说明这两个概念。

协变(Covariance)

What-什么是协变

  1. 范畴学定义
    假定有两个类型X和Y,并且X的每个实例都能转换为Y类型(类似于C#中的继承/实现,即X是Y的派生类型)。如果对于一个与X和Y相关的类型I<X>和I<Y>,每一个I<X>的实例都能转换为I<Y>类型,那么就说I<>是协变的。
  2. C#举例说明
    定义比较干巴巴,举个实例来看看可以更清晰,还是上面那一段代码:
    string[] strArray = new string[1];
    object[] objArray = strArray;//通过编译
    
    stringobject的派生类,每一个string[]都可以转换为object[],这个string[]转换为object[]的过程就称为协变,也可以说“数组是协变的”。

Why-为什么要使用协变

1. 协变的使用场景

为什么要提出来协变的概念呢?事实上这不仅是一个非常正常的思考逻辑,而且还具有一定的实用意义。
假设这样一个场景:
我们有一个Log函数,需要使用它来格式化一系列对象的输出,要求它能支持所有类型的对象。假设我们使用的是泛型List,那么很自然地,我们的函数可能会写成下面这样:

string Log(List<object> outputList)
{
     string output = "";
     foreach (var obj in outputList)
     {
         output += obj.ToString();
     }

     return output;
}

当我们有一个List想要输出时,就会使用下面这样的代码:

 List<string> strList = new List<string>();
 string output = Log(strList);//Argument type 'System.Collections.Generic.List<string>' is not assignable to parameter type 'System.Collections.Generic.List<object>'

遗憾的是,如果真的这么写,就无法通过编译了,因为List<>这个泛型类并不支持协变。如果我们真的要使用List<>来格式化,就需要为每个不同的类写不同的重载,很明显这是不可行的。(其实可以使用string Log<T>(List<T> outputList)的签名来实现,但这是泛型方法的话题了。)
但同样明显的是,在这个案例中这样的使用方式是类型安全的,那么为什么C#要禁止协变呢?

2. 协变会带来什么问题

从例子中最容易看明白,直接上代码。

string[] strArray = new string[1];
object[] objArray = strArray;//通过编译
objArray[0] = new object();//运行时报错:System.ArrayTypeMismatchException

由于数组是支持协变的,我们构造一个string[]并且将其赋值组object[],然后向数组中插入一个object。此时会发现可以通过编译,但在运行时会抛出System.ArrayTypeMismatchException错误,插入的object类型不匹配。
在构造数组时,我们所获得的是一个string[],如果没有将其转换为object[],插入一个object很明显是不正确的,但经过协变转换后,它却通过了编译。
也就是说,在这种情况下的协变会导致一个运行时错误

3. 什么情况下可以使用协变

对比上面两个例子,我们知道协变有其特定的用处,但在一些情况下并不能保证程序的正确,所以协变只能在一些受限的情况下使用。在第一个例子里,我们需要的是对List<>进行读取并且确认是类型安全的,而第二个则是需要对实便行修改造成了程序错误。因此C#将泛型的协变限制为仅在读取时可用。

How-如何在C#中使用协变-out关键字

1. out标记

那么要怎么实现现读取和修改分开呢,其实只要能保证被转换后的类型只能被读取而不能被修改,那么就可以安全地使用协变了。
于是C#设计者采用了out关键字用于标记协变的方案,当使用这个关键字时,表明这个被修饰的类型仅被用于输出,并且不能被传入。在编译时,编译器会主动检查被标记类型参数的所有调用,确保它不会被作为传入类型使用。
在C#中就有这样的一个接口:IReadOnlyList<>,它的声明如下:

  public interface IReadOnlyList<out T> : IReadOnlyCollection<T>, IEnumerable<T>, IEnumerable
  {
    T this[int index] { get; }
  }

可以看到在它的类型参数前使用了out进行标记。如果再深入查看,会发现它继承的两个接口IReadOnlyCollection<T>IEnumerable<T>的类型参数也都是进了行out标记的。
我们经常使用的List<>就实现了这个接口:

  public class List<T> : 
    IList<T>,
    ICollection<T>,
    IEnumerable<T>,
    IEnumerable,
    IList,
    ICollection,
    IReadOnlyList<T>,//实现接口
    IReadOnlyCollection<T>
    {
        public T this[int index]
        {
            get
            {
                //具体实现
            }
        }
       //...其他成员实现    
    }

因此我们一开始的代码可以这样写:

    string Log(IReadOnlyList<object> outputList)//注意接口类型
    {
        string output = "";
        foreach (var obj in outputList)
        {
            output += obj.ToString();
        }

        return output;
    }

    //调用代码
    List<string> strList = new List<string>();
    string output = Log(strList);

在这里,strList协变成为了IReadOnlyList<object>

2. 协变的一些限制

虽然看起来只需要用out进行标记就可以实现协变,但在实际使用中还存在一些其他需要注意的地方,具体的原因我会放到文章最后详细说明。

  1. 在C#中,只有泛型接口泛型委托是协变的,泛型类和泛型结构体不是
    泛型是否协变举例
    泛型接口IReadOnlyList<object> lrol = new List<string>()
    泛型委托 Func<string> funcStr = () => ""; Func<object> funcObj= funcStr;
    泛型类不是|
    泛型结构体不是|
  2. 泛型的类型参数中类(class)是协变的,结构体(struct)不是。
    泛型是否协变举例
    泛型类参数IReadOnlyList<object> lrol = new List<string>()
    泛型结构体参数不是注意不可用IReadOnlyList<object> lrol = new List<int>()
  3. 必须显式声明协变,即标记out。注意由于第一条的存在,不能对class或者struct使用out关键字。
  class Pair<out T>{}
  //会提示编译错误,Variant type parameters could be declared in interfaces or delegates only(变体类型参数仅能被用于接口或委托声明)

逆变(Contravariance)

What-什么是逆变

  1. 范畴学定义
    假定有两个类型X和Y,并且X的每个实例都能转换为Y类型(类似于C#中的继承/实现,即X是Y的派生类型)。如果对于一个与X和Y相关的类型I<X>和I<Y>,每一个I<Y>的实例都能转换为I<X>类型,那么就说I<>是逆变的。(注意加粗的文本,与协变正好相反。)
  2. C#举例说明
    逆变相比协变要更难理解一些,还是举个实例来看看。
    Action<object> objAction = o => { o.GetType(); };
    Action<string> strAction = objAction;//可以通过编译
    
    stringobject的派生类,在这里objActionAction<object>的实例,在调用时需要传入一个object类型的实参。当我们向objAction中传入一个string对象时,由于stringobject的派生类,那么理所应当是可以被调用的。
    但是如果放到Action<>上来看,则是反过来了——与类型的派生方向相反,Action<object>对象转换成了一个Action<string>
    正因为与派生关系相反,因此叫它逆变

Why-为什么要使用逆变

1. 逆变的使用场景

上面的例子已经足以说明逆变的作用了,如果不允许逆变的存在,那么在Action<string> strAction = objAction;这样的代码就不能存在,这明显是不合理的,因为string派生自objectobject可以使用的成员在string中必然也存在。
前面使用了委托作为案例,下面再举一个接口的例子。
假设我们有一个Graph类及从它派生的CirleTriangle类,然后建立一个对Graph进行绘制的接口IDrawer<T>和对IDrawer<T>的实现Drawer。

    class Graph { }
    class Circle : Graph { }
    class Triangle : Graph { }

    interface IDrawer<T> where T : Graph
    {
        void DrawGraph(T graph);
    }

    class Drawer : IDrawer<Graph>
    {
        public void DrawGraph(Graph graph)
        {
            //具体实现
        }
    }

由于CircleTriangle都派生自Graph,因此IDrawer<Graph>DrawGraph都可以正常将TriangleCircle的实例作为参数,所以凡是需要一个IDrawer<Circle>的地方,都可以使用IDrawer<Graph>来代替,而不需要为每一个派生自Graph的都单独声明一个实现类。如果没有逆变,下面的调用将出现错误,但根据我们的分析,这是安全的。

    IDrawer<Graph> graphDrawer = new Drawer();
    graphDrawer.DrawGraph(new Triangle());//安全调用
    graphDrawer.DrawGraph(new Circle());//安全调用

    IDrawer<Circle> circleDrawer = graphDrawer;//需要逆变,编译时会发生错误,但实际上是类型安全的
    circleDrawer.DrawGraph(new Circle());//安全调用

    IDrawer<Triangle> triangleDrawer = graphDrawer;//需要逆变,编译时会发生错误,但实际上是类型安全的
    triangleDrawer.DrawGraph(new Triangle());//安全调用

2. 逆变可能带来哪些问题

与协变一样,如果不加限制地允许逆变,同样会带来一些问题,看下面这个例子:

    List<object> objList = new List<object>() { new object() };
    List<string> strList = objList;//逆变,编译时会发生错误
    string str = strList[0];//类型不匹配,必然出错

很容易看出,objectList中唯一的对象是一个object,无法作为string,运行时一定会出错。所以在这种情况下,即使在编译时允许了逆变,运行起来也会出错。

3. 什么情况下可以允许逆变

现在我们遇到了和协变一样的问题,在一些情况下是应该允许的(作为调用的实参),但在另一些情况下又不能允许(作为返回的结果),所以限制的方式也呼之欲出:当只作为传入参数时允许逆变

How-如何使用逆变

1. 使用in标记逆变

作为协变的反方向变化,C#同样提供了类似的方式来对泛型类型参数进行标记和限制:in关键字。
以上面的绘制代码为例,由于我们需要允许IDrawer的逆变,那么就需要在IDrawer接口上增加关键字in,如下面这样:

    class Graph { }
    class Circle : Graph { }
    class Triangle : Graph { }

    interface IDrawer<in T> where T : Graph//注意这一行在T前增加了in关键字
    {
        void DrawGraph(T graph);
    }

    class Drawer : IDrawer<Graph>
    {
        public void DrawGraph(Graph graph)
        {
            //具体实现
        }
    }

只需要进行这一个修改就可以允许逆变了。

2. 逆变的限制

逆变的限制与协变基本相同。

  1. 在C#中,只有泛型接口泛型委托是逆变的,泛型类和泛型结构体不是
  2. 泛型的类型参数中类(class,即引用类型)是逆变的,结构体(struct,即值类型)不是。
  3. 必须显式声明逆变,即标记in。

协变和逆变在C#中受限制的原因

在前面两部分中都提到协变和逆变受到了限制,但为什么会有这样的限制?主要原因是公共语言运行时(Common Language Runtime, CLR)的泛型机制造成的。

1. 协变和逆变本质上是隐式类型转换

协变和逆变本质上是隐式类型转换,这个原因导致了泛型类和结构体无法协变和逆变。
理解这一点非常重要,我们知道在非泛型的情况下,两个不同类型想要隐式转换,只有两种情况,一是存在隐式转换的函数,例如intdouble;二是存在继承或实现关系,例如stringobjectIReadOnlyList<>List<>

//存在隐式转换
int a = 1;
double b = a;

//存在继承关系
string str = "";
object o = str;

按这个关系,我们再来考虑泛型类List,使用下面两行代码进行输出,会发现List<object>List<string>并不属于同一个类。

Console.WriteLine($"typeof(List<object>)={typeof(List<object>)}");
Console.WriteLine($"typeof(List<string>)={typeof(List<string>)}");

//输出:
//typeof(List<object>)=System.Collections.Generic.List`1[System.Object]
//typeof(List<string>)=System.Collections.Generic.List`1[System.String]

很明显,List<object>List<string>既不存在继承关系,也不可能存在隐式转换函数,类型本就不一样,自然无法进行转换。
当然,这并不能说明为什么接口和委托可以进行转换,请继续往下看。

2. 泛型的CLR运行机制是类型参数为结构体的泛型接口/委托不能逆变和协变的根本原因

既然协变和逆变的本质是隐式类型转换,那如果一个值类型实现了接口,不就有了继承/实现关系吗?那为什么又会有值类型的泛型接口/委托不能逆变和协变的限制呢?
同样地,对于接口和委托而言,协变和逆变也不存在继承或实现关系,为什么却可以进行类型转换呢?难道它们之间存在什么特殊的隐式转换吗?

我们来看下面这个简单的例子,结构体People实现了接口IWalk,然后对构造一个泛型委托Action<IWalk>并向Action<People>逆变。如果进行编译,编译器会对逆变这一行代码提示错误。

    interface IWalk { }
    struct People : IWalk { }

    /
    
    Action<IWalk> aWalk = (walk) => { };
    Action<People> aPeople = aw;//实现了接口的结构体逆变,无法通过编译

造成这个限制的原因是CLR的运行机制。我们在C#中写的泛型会通过以下几个步骤来运行:

  1. C#静态编译为IL代码

  2. CLR创建开放的泛型类,并在调用处将类型参数传入

  3. CLR根据传入参数成为完整的类型。
    对于值类型,每个不同的类型参数创建不同的类,也就是说Action<int>Action<long>在CLR中会构造两个不同的类;
    对于引用类型,每个类型参数都使用object作为类型,也就是说Action<string>Action<object>在CLR中只会构造一个Action<object>类,并且都使用它,这是因为引用类型本质上只是一个指针。
    三个步骤可以参考下面的分支图

    静态编译
    值类型
    引用类型
    C#泛型
    IL泛型模板+调用时类型参数
    参数类型
    不同的值类型创建不同的泛型类
    所有引用类型都使用object作为类型参数

基于这个运行方式,我们可以发现,对于同一个泛型接口或委托而言,任何一个值类型生成的泛型都不可能和引用类型生成的泛型类型相同;而对于一个引用类型而言,它们在CLR中使用的始终是同一个类,只要编译器静态验证是合法的类型,那么就可以进行类型的转换。
在上面的代码中,对于aWalk而言,它在CLR中使用的是Action<object>这个类,而aPeople则是Action<object>,同样不存在继承关系,而C#也没有提供泛型的自定义隐式转换方式,所以自然也无法进行转型了。

现在让我们再来思考为什么接口和委托可以进行逆变或协变,我们知道在C#中为接口和委托提供了inout关键字进行标记,由于声明该接口或委托可以被用于协变或逆变。事实上在CLR运行时并不会验证这些类是不是匹配的(严格来说还是会验证,当类型出错还是会报错),因为对于CLR而言,它们都object类的泛型,这个报错只是由C#编译器进行判断接口或委托的内容是不是匹配。

3.必须显式使用in或out进行标记的原因

C#既然可以进行语法分析,自然也可以识别一个接口或委托是否支持协变或逆变,为什么不在识别到某个类需要协变或逆变的时候自动加上呢?这是因为自动识别有可能改变原有的设计意图
还是以之前的Graph-Drawer为例,假设我们新增了一个DrawerInvoker类,它传入一个IDrawer<Graph>对象,用于之后调用Drawer。

class Graph { }
class Circle : Graph { }
class Triangle : Graph { }

interface IDrawer<T> where T : Graph//没有标记协变或逆变
{
    void DrawGraph(T graph);
}

class Drawer : IDrawer<Graph>
{
    public void DrawGraph(Graph graph)
    {
        //具体实现
    }
}

class DrawerInvoker 
{ 
    public DrawerInvoker(IDrawer<Graph> drawer) 
    {
        //具体实现
    }
}

//-------------调用1----------
Drawer<Graph> graphDrawer = new Drawer<Graph>();

IDrawer<Circle> circleDrawer = graphDrawer;//需要逆变
circleDrawer.DrawGraph(new Circle());

DrawerInvoker drawerInvoker = new DrawerInvoker(circleDrawer);//需要协变

在上面这个例子中,假设我们同时写了需要逆变和需要协变的代码,编译器就无法确认IDrawer到底需要标记in还是out了——而且很明显,同一个接口的同一个类型参数并不能既协变又逆变。
更可怕的是,如果接口和调用在两个不同的程序集中,这还会影响接口定义处的逆变/协变性!也就是说,程序集外其他人的代码有可能改变程序集内的代码属性,这种情况明显存在巨大的安全风险。

到这里本篇文章的全部内容就都结束了,后面是一个简单的思维导图,希望可以对理解有一些帮助。

关于协变和逆变的思维导图

在这里插入图片描述

参考文献
C#本质论 第四版 C#5.0
.Net CLR Via C#
泛型接口中的变体 (C#)

  • 4
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值