10分钟了解C#中的协变和逆变

说十分钟可能有哗众取宠的嫌疑,本人写这个博客,话了半天时间,查阅了很多资料才完成,因此要弄懂协变逆变的本质,可能要多花点时间。

--------------------------------------------------------------------------------

很多文章中对于协变的描述大致如下:  协变是一个细节化程度高的类型赋值给细节化程度低的类型类型。例如一个方法M,返回值是Giraffe(长颈鹿),你可以把M的返回值赋值给Animal(动物)类型,因为Animal类型是细节化程度底的类型,和Giraffe类兼容。那么方法是协变的,因为当你创建了协变接口,需要用到关键字out,返回值从方法中out。

这根本就不是协变的意思。这只是描述了赋值兼容性,两个类型可以兼容。

--------------------------------------------------------------------------------

协变的准确定义是什么呢?

先不考虑类型,我们考虑数学意义上的整数。考虑整数之间的小于关系——≤。这里关系实际上就是一个方法,接受2个数,返回布尔值。

现在我们考虑一下对于整数的投影,投影指的是一个函数,接受一个整数,返回另一个整数。比如 z → z + z 我们可以定义为D(double), z → 0 - z定义为N for (negate), z → z * z,定义为S(square).

问题就出来了,是不是所有的情况下, (x ≤ y) = (D(x) ≤ D(y))?事实上就是,如果x小于等于y,那么x的2倍也小于等于y的2倍。投影D保留了不等号的方向。

对于N,很显然,1 ≤ 2 但是 -1 ≥ -2。即(x ≤ y) = (N(y) ≤ N(x)),投影N反转了不等号的方向。

对于S, -1 ≤ 0, S(0) ≤ S(-1),但是 1 ≤ 2, S(2)≥ S(1)。可见投影S即没保留不等号方向,也没反转不等号方向。

投影D就是协变的,它保留了整数上的次序关系。投影N是逆变的,它反转了整数上的次序关系,投影S两者都不是,所以是不变的。

所以这里很清楚,整数自身不是变体,小于关系也不是变体。投影才是协变或者逆变——接受一个整数生成一个新整数。 

--------------------------------------------------------------------------------

现在再来看类型,替换整数上的小于关系,我们在引用类型上也有一个小于关系。如果引用类型X的值可以存储在类型Y上,那么称一个引用类型X小于或等于引用类型Y。

再考虑对于类型的投影,假设一个投影是把T变成IEnumerable<T>,即,如果接受一个参数Giraffe类型,返回一个新类型 IEnumerable<Giraffe>。那么这个投影在C# 4.0中是协变吗?是的,它保留了次序的方向。Giraffe可以赋值给Animal,因此Giraffes序列也可以赋值给IEnumerable<Giraffe>。

精炼的说,对于一个投影,如果A可以赋值给B,经过投影后的值A'可以赋值给B',那么就可以说这个投影是一个协变。 我们可以认为 接受类型T,生成出类型IEnumerable<T>看作是一个投影,并称这个投影为"IEnumerable<T>”。因此根据上下文,当我们说IEnumerable<T>是协变的,意思就是对于类型T生成类型IEnumerable<T>的投影是一个协变的投影。由于IEnumerable<T>只有一个类型参数,因此很明确我们说的参数就是T。

因此我们可以定义协变,逆变和不变。如果一个泛型类型I<T>,根据类型参数得出的结构,保留了赋值的兼容方向,那么这个泛型类型是协变的。即如果一个泛型类型I<T>,对于类型A和B,如果A能赋值给B,而且I<A>也能赋值给I<B>,即保留了赋值的兼容方向,那么说I<T>这个泛型类就是协变的。相反,逆变就是反转了赋值的兼容方向。不变就是既不是协变也不是逆变。简单准确的说,接受T,生成 I<T>的这样的一个投影就是协变/逆变/不变的投影。

--------------------------------------------------------------------------------

在 C# 中,协变和逆变允许数组类型、委托类型和泛型类型参数进行隐式引用转换。 协变保留分配兼容性,逆变与之相反。

数组是支持协变的,string兼容于object,string数组化后依然兼容于object。但是协变可能会导致类型不安全,如下例子: object[] array = new String[10]; // 这里会报错,array[0]已经先分配给string了,无法再接受整型。 // array[0] = 10;

委托类型也支持协变。如下代码,string兼容于object,返回值为string的fun兼容于返回值为object的委托。

public delegate object mydelege();

static string fun2()        

{    

  return "";    

}

static void Main(string[] args)

{   

      mydelege md1 = fun2; //string兼容于object,返回值为string的fun兼容于返回值为object的委托

}

对于泛型接口,在Framework4.0之前,不支持协变。例如: IEnumerable<object> b = new List<string>(); 无法编译过。string兼容于object,但是List<string>无法兼容IEnumerable<object>,属于不变。 但在Framework4.0之后,上述例子就能编译过了,也就是说IEnumerable<object>这个泛型接口支持协变了。 Framework4.0后,支持协变的接口有很多。IEnumerable<T>、IEnumerator<T>、IQueryable<T> 和 IGrouping<TKey, TElement>

委托类型支持逆变。如下代码:

儿子继承父亲

class Father     { }

class Son : Father     { }

class Program   

{        

  public delegate void mydelege1(Father f);

      public delegate void mydelege2(Son s);

      static void fun1(Father s)

      {         }

      static void fun2(Son s)

      {         }

      static void Main(string[] args)       

     {          

    Father f = new Father();  

           Son s = new Son();

            f = s;//ok,儿子可以赋值给父亲

            mydelege1 md1 = fun2;//error,输入参数不支持协变,儿子类型的方法不能赋值给父亲类型的委托

            mydelege2 md2 = fun1;//ok,父亲类型的方法可以赋值给儿子类型的委托,逆变了。        

     }  

对于一个委托mydelege1(Father f),定义的输入参数类型是Father。 可以看到son是可以赋值给father的,而经过委托定义这样一个投影,发现son类型为参数的方法不能赋值给father类型为参数的委托。即经过这样的委托投影,son类型方法无法赋值给father类型委托了。 而相反的,father类型为参数的方法却可以赋值给son类型为参数的委托。即经过这样的委托投影,father类型的方法可以赋值给son类型的委托了。这就逆天了,因此也就是逆变了。 简单的来看,即投影前Son可以赋值给Fahter,投影(转成委托类型)后Father可以赋值给Son,是一种逆变。

为什么转成委托后,Son类型的方法不能赋值给Father类型的委托了,很简单,这个方法要接受的是Son的方法要处理的自然是Son类型的值,而Father为参数的委托可能接受Daughter类型的参数,(假设Daughter和Son并列的继承了Father)。 因此Son方法就无法处理了。因此不允许这样操作。 换成代码来说,假设这样的代码合法了: mydelege1 md1 = fun2;//假设是合法的, 那么 md1(new Daughter())的代码要处理的时候,肯定无法处理了。 所以,不允许存在这样的协变,而只允许逆变。通过上面的一些例子,可以看出对于委托,协变只存在与返回值中,逆变只存在与输入值中。

--------------------------------------------------------------------------------

再来看泛型委托中的协变和逆变 我们可以看微软自定义的泛型委托,Func和Action这两个。(Func必须有返回类型,Action返回void) 在Framework3.5的时候,Func,Action是不支持协变也不支持逆变。如下代码:

class Father     { }

class Son : Father     { }

class Program

{        

  static void Main(string[] args)

     {            

    Func<Father> fatherfun = () => new Father();  

        Func<Son> sonfun = () => new Son();   

        fatherfun = sonfun;//无法将类型“System.Func<ConsoleApplication2.Son>”隐式转换为“System.Func<ConsoleApplication2.Father>

        sonfun = fatherfun;//无法将类型“System.Func<ConsoleApplication2.Father>”隐式转换为“System.Func<ConsoleApplication2.Son>”     

   }    

}

上面的代码编译不过。因此很是不方便,所以在Framework4.0后,允许其可以协变和逆变。在Framework4.0中,如下

 static void Main(string[] args)

 {

       Func<Father> fatherfun = () => new Father();

       Func<Son> sonfun = () => new Son();  

       fatherfun = sonfun;//ok协变成功,Son可以赋值给Father,Sonfun也可以赋值给Fatherfun了;

       sonfun = fatherfun;//无法将类型“System.Func<ConsoleApplication2.Father>”隐式转换为“System.Func<ConsoleApplication2.Son>”。存在一个显式转换(是否缺少强制转换?)

  }

对于逆变,如下代码,同样的,在Framework3.5时代,仍然是不可协变逆变。    

class Father     { }

class Son : Father     { }     

class Program    

{        

  static void Main(string[] args)       

   {         

      Action<Father> fatherfun;       

      Action<Son> sonfun;

          fatherfun = sonfun;//无法将类型“System.Action<ConsoleApplication2.Son>”隐式转换为“System.Action<ConsoleApplication2.Father>   

          sonfun = fatherfun;//无法将类型“System.Action<ConsoleApplication2.Father>”隐式转换为“System.Action<ConsoleApplication2.Son>”   

      }   

 }

在Framework4.0后,允许其可以逆变。  

static void Main(string[] args)     

{         

    Action<Father> fatherfun;         

    Action<Son> sonfun;

   fatherfun = sonfun;//无法将类型“System.Action<ConsoleApplication2.Son>”隐式转换为“System.Action<ConsoleApplication2.Father>”。存在一个显式转换(是否缺少强制转换?)   

    sonfun = fatherfun;//ok 逆变成功 

 }

以上是微软提供的泛型委托,事实上自己也可以定义自己的泛型委托,也可以知道这个委托是否支持协变或者逆变。在C#中,通过对参数标注in和out来标注此参数是逆变类型参数还是协变类型参数。  

--------------------------------------------------------------------------------

微软MSDN中的定义。

协变类型参数用 out 关键字(在 Visual Basic 中为 Out 关键字,在 MSIL 汇编程序中为 +)标记。 可以将协变类型参数用作属于接口的方法的返回值,或用作委托的返回类型。 但不能将协变类型参数用作接口方法的泛型类型约束。 逆变类型参数用 in 关键字(在 Visual Basic 中为 In 关键字,在 MSIL 汇编程序中为 -)标记。 可以将逆变类型参数用作属于接口的方法的参数类型,或用作委托的参数类型。 也可以将逆变类型参数用作接口方法的泛型类型约束。事实上Action和Func的定义在Framework4.0中如下:

public delegate void Action<in T>( T obj )

public delegate TResult Func<in T, out TResult>( T arg )

从定义可以看出,协变out主要用在返回值上,逆变in用在输入参数。如果把out由于输入参数,编译不会通过。原因前面也已经解释过了。这里在说明一下:假设我们把out用于输入参数,并假设编译能够通过,那么这样做的目的是使该参数能够协变。因此假设如下代码能成功编译。

class Father     { }  

class Son : Father     { }     

class Daught : Father     { }  

class Program   

{   

     public delegate void myAction<out T>(T t);//该委托和Action<in T>类似,就差了一个in,一个out,假设此段代码编译通过。

     static void f_father(Father f)   

      { }         

    static void f_son(Son f)         { }     

      static void Main(string[] args)     

     {  

           myAction<Father> fatheract = f_father;    

           myAction<Son> sonact = f_son;     

           fatheract = sonact;//假设上面的委托能编译成功,那么就是支持协变。因此这段代码也能成立。

           fatheract(new Daught())//这段代码就无法准确运行了。              

        //如果这段代码成立了,      

       //那么fatheract(new Daught())运行的时候,发现     

        //f_son里面接受了Daughter类型,无法运行。         

    }    

}

同样的,如果对于输出类型用in来修饰,即允许其可以逆变,也会出现类似的类型问题

class Father     { }  

class Son : Father     { }  

class Daught : Father     { }     

class Program    

{     

    public delegate T myFunc<in T>();//假设这个代码可以编译成功。即假设是支持逆变

    static void Main(string[] args)  

   {   

        myFunc<Father> fatherfunc = ()=> new Father();       

        myFunc<Son> sonfunc = () => new Son(); ;          

        sonfunc = fatherfunc;//假设上面的代码编译成功,支持了逆变,即支持了fatherfunc赋值给sonfunc那么下面的代码就会引起异常。

        Son ason=  sonfunc();//ason被迫接受father类型了,导致异常       

   }    

}

所以,in对应逆变,只能用于输出参数,out对应协变,只能用于输出参数。否则会出现问题。 因此,在泛型委托中用允许输入类型协变会引起类型问题,因此只允许逆变。

基于上面的叙说,很多人会有疑问为何还要显示的标注in或者out,编译器完全可以推断泛型参数是协变还是逆变。事实上,编译器确实可以自动推断,但C#团队认为你需要明确的定义了一个契约,并且遵守这个契约。比如,如果编译器替你决定了某个泛型类型参数是逆变的,但是,你却在接口上加了个成员,并使用了out标记。这到后来可能会导致一些类型的错误,因此,一开始编译器就要求你显示的声明泛型类型参数。如果你不按照你定义的规则那样,协变或者逆变,编译器会报错,提示你违法了你定义的契约。

 作者:cnn237111

协变(Covariance)与逆变(Contravariance)。

对于协变与逆变,大家肯定不会感到陌生,但是我相信有很多人不能很清晰地说出他们之间的区别。我希望通过这篇文章能够让读者更加深刻的认识协变与逆变。但是也不排除另一种可能,那就是读者这篇文章你对这两个概念更加模糊。文章一些内容仅代表个人观点,如有不妥,还望指正。

目录        一、两个概念:强类型与弱类型         二、委托中的协变与逆变的使用         三、接口中的协变与逆变的使用         四、从Func<T,TResult>看协变与逆变的本质         五、逆变实现了“算法”的重用

  一、两个概念:强类型与弱类型

  为了后面叙述方便,我现在这里自定义两个概念:强类型和弱类型。在本篇文章中,强类型和弱类型指的是两个具有直接或者间接继承关系的两个类。如果一个类是另一个类的直接或者间接基类,那么它为弱类型,直接或者间接子类为强类型。后续的介绍中会用到的两个类Foo和Bar先定义在这里。Bar继承自Foo。Foo是弱类型,而Bar则是强类型。

1: publicclass Foo 2: { 3: //Others Members...4: } 5: publicclass Bar:Foo 6: { 7: //Others Members...8: }

  有了强类型和弱类型的概念,我们就可以这样的定义协变和逆变:如果类型TBar是基于强类型Bar的类型(比如类型参数为Bar的泛型类型,或者是参数/返回值类型为Bar的委托),而类型TFoo是基于弱类型Foo的类型,协变就是将TBar类型的实例赋值给TFoo类型的变量,而逆变则是将TFoo类型的实例赋值给TBar类型的变量。

  二、委托中的协变与逆变的使用

  协变和逆变主要体现在两个地方:接口和委托,先来看看在委托中如何使用协变和逆变。现在我们定义了如下一个表示无参函数的泛型委托Function<T>,类型参数为函数返回值的类型。泛型参数之前添加了一个out关键字表示T是一个协变变体。那么在使用过程中,基于强类型的委托Fucntion<Bar>实例就可以赋值给基于弱类型的委托Fucntion<Foo>变量。

1: publicdelegate T Function<out T>(); 2: class Program 3: { 4: staticvoid Main() 5: { 6: Function<Bar> funcBar =new Function<Bar>(GetInstance); 7: Function<Foo> funcFoo = funcBar; 8: Foo foo = funcFoo(); 9: } 10: static Bar GetInstance() 11: { 12: returnnew Bar(); 13: } 14: }

  接下来介绍逆变委托的用法。下面定义了一个名称为Operate的泛型委托,接受一个具有泛型参数类型的参数。在定义泛型参数前添加了in关键字,表示T是一个基于逆变的变体。由于使用了逆变,我们就可以将基于弱类型的委托Operate<Foo>实例就可以赋值给基于强类型的委托Operate<Bar>变量。

1: publicdelegatevoid Operate<in T>(T instance); 2: class Program 3: { 4: staticvoid Main() 5: { 6: Operate<Foo> opFoo =new Operate<Foo>(DoSth); 7: Operate<Bar> opBar = opFoo; 8: opBar(new Bar()); 9: } 10: staticvoid DoSth(Foo foo) 11: { 12: //Others...13: } 14: }

 

  三、接口中的协变与逆变的使用

  接下来我们同样通过一个简单的例子来说明在接口中如何使用协变和逆变。下面定义了一个继承自 IEnumerable<T>接口的IGroup<out T>集合类型,和上面一样,泛型参数T之前的out关键字表明这是一个协变。既然是协变,我们就可以将一个基于强类型的委托IGroup<Bar>实例就可以赋值给基于弱类型的委托IGroup<Foo>变量。

1: publicinterface IGroup<out T> : IEnumerable<T>2: { } 3: publicclass Group<T> : List<T>, IGroup<T>4: { } 5: publicdelegatevoid Operate<in T>(T instance); 6: class Program 7: { 8: staticvoid Main() 9: { 10: IGroup<Bar> groupOfBar =new Group<Bar>(); 11: IGroup<Foo> groupOfFoo = groupOfBar; 12: //Others...13: } 14: }

  下面是一个逆变接口的例子。首先定义了一个IPaintable的接口,里面定义了一个可读写的Color属性,便是实现该接口的类型的对象具有自己的颜色,并可以改变颜色。类型Car实现了该接口。接口IBrush<in T>定义了一把刷子,泛型类型需要实现IPaintable接口,in关键字表明这是一个逆变。方法Paint用于将指定的对象粉刷成相应的颜色,表示被粉刷的对象的类型为泛型参数类型。Brush<T>实现了该接口。由于IBrush<in T>定义成逆变,我们就可以将基于弱类型的委托IBrush<IPaintable>实例就可以赋值给基于强类型的委托IBrush<Car>变量。

1: publicinterface IPaintable 2: { 3: Color Color { get; set; } 4: } 5: publicclass Car : IPaintable 6: { 7: public Color Color { get; set; } 8: } 9: 10: publicinterface IBrush<in T>where T : IPaintable 11: { 12: void Paint(T objectToPaint, Color color); 13: } 14: publicclass Brush<T> : IBrush<T>where T : IPaintable 15: { 16: publicvoid Paint(T objectToPaint, Color color) 17: { 18: objectToPaint.Color = color; 19: } 20: } 21: 22: class Program 23: { 24: staticvoid Main() 25: { 26: IBrush<IPaintable> brush =new Brush<IPaintable>(); 27: IBrush<Car> carBrush = brush; 28: Car car =new Car(); 29: carBrush.Paint(car, Color.Red); 30: Console.WriteLine(car.Color.Name); 31: } 32: }

 

  四、从Func<T,TResult>看协变与逆变的本质

  接下来我们来谈谈协变和逆变的本质区别是什么。在这里我们以我们非常熟悉的一个委托Func<T, TResult>作为例子,下面给出了该委托的定义。我们可以看到Func<T, TResult>定义的两个泛型参数分别属于逆变和协变。具体来说输入参数类型为逆变,返回值类型为协变。

1: publicdelegate TResult Func<in T, out TResult>(T arg);

  再重申以下这句话“输入参数类型为逆变,返回值类型为协变”。然后,你再想想为什么逆变用in关键字,而协变用out关键字。这两个不是偶然,实际上我们可以将协变/逆变与输出/输入匹配起来。

  我们再从另一个角度来理解协变与逆变。我们知道接口代表一种契约,当一个类型实现一个接口的时候就相当于签署了这份契约,所以必须是实现接口中所有的成员。实际上类型继承也属于一种契约关系,基类定义契约,子类“签署”该契约。对于类型系统来说,接口实现和类型继承本质上是一致的。契约是弱类型,签署这份契约的是强类型。

  将契约的观点应用在委托上面,委托实际上定义了一个方法的签名(参数列表和返回值),那么参数和返回值的类型就是契约,现在的关键是谁去履行这份契约。所有参数是外界传入的,所以基于参数的契约履行者来源于外部,也就是被赋值变量的类型,所以被赋值变量类型是强类型。而对于代理本身来说,参数是一种输入,也就是一种采用in关键字表示的逆变。

  而对于委托的返回值,这是给外部服务的,是委托自身对外界的一种承诺,所以它自己是契约的履行着,因此它自己应该是强类型。相应地,对于代理本身来说,返回值是一种输出,也就是一种采用out关键字定义的协变。

  也正式因为这个原因,对于一个委托,你不能将参数类型定义成成协变,也不能将返回类型定义成逆变。下面两中变体定义方式都是不能通过编译的。

   1: delegate TResult Fucntion<out T, TResult>(T arg);
   2: delegate TResult Fucntion<T, in TResult>(T arg);

  说到这里,我想有人要问一个问题,既然输入表示逆变,输出表示协变,委托的输出参数应该定义成协变了?非也,实际上输出参数在这里既输出输出,也输出输入(毕竟调用的时候需要指定一个对应类型的对象)。也正是为此,输出参数的类型及不能定义成协变,也不能定义成逆变。所以下面两种变体的定义也是不能通过编译的。

1: delegatevoid Action<in T>(out T arg); 2: delegatevoid Action<out T>(out T arg);

  虽然这里指介绍了关于委托的协变与逆变,上面提到的契约和输入/输出的关系也同样适用于基于接口的协变与逆变。你自己可以采用这样的方式去分析上面一部分我们定义的IGroup<Foo>和IBrush<in T>。

  五、逆变实现了“算法”的重用

  实际上关系协变和逆变体现出来的编程思想,还有一种我比较推崇的说法,那就是:协变是继承的体现,而逆变体现的则是多态(可以参考idior的文章《Covariance and Contravariance》)。实际上这与上面分析的契约关系本质上是一致的。

  关于逆变,在这里请容我再啰嗦一句:逆变背后蕴藏的编程思想体现出了对算法的重用——我们为基类定义了一套操作,可以自动应用于所有子类的对象。

posted @ 2013-11-03 20:07  dennys  阅读(537)  评论(1编辑  收藏  举报