也来凑泛型协变/反变的热闹

本文深入浅出地讲解了协变和反变的概念,并通过具体的示例解释了何时使用它们,以及它们如何帮助实现泛型之间的类型转换。

前言

今天看了两篇讲协变/反变的文章,写得很好也很有意思。不过我猜应该有不少人可能还是很难理解这个新概念——每一次推出新的概念的时候,都会或多或少造成我们的困惑:这是个什么东西?为什么要出这么复杂的东西?我们什么时候应该用这种东西,什么时候不该用?

有这样的困惑没关系,我想绝大多数人都经历过这个过程。我在这里呢,也说说从我的角度是如何看这个新鲜事物的,也许对理解这个东西有帮助。不过先声明一下,我没有装过,更没有用过.NET 4.0,因此我写的内容基本是自己推导出来的,如果有什么不正确的地方,也希望大家能够指出。

先给出刚才提到的两篇文章,因为也许有人是通过搜索引擎过来的:

http://www.cnblogs.com/Hush/archive/2008/11/22/1339140.html

http://www.cnblogs.com/Ninputer/archive/2008/11/22/generic_covariant.html

其中第一篇文章的内容相对简单一点,也好理解一点,后者更加理论化一些。
本来呢,我看完Ninputer的文章产生了两个想法:
1、好!
2、有点太理论化了,恐怕难以理解。
基于第二点想法,我就产生了要从更容易理解的角度去描述这个问题的想法,结果一刷屏幕,已经出来了第二篇了——有人捷足先登了。这让我郁闷了一下会儿,不过后来还是发现了一些有趣的事情大家都没有提到,于是又重燃了我码一堆文字的热情。

 


什么是协变和反变?

其实前面的文章里面已经有很清晰和正规的定义,我这里不打算再写得更详细了。相反,我想把问题简化,因此我会给出一个较简单但不太准确的说法:

interface  IFoo < in  TIn,  out  TOut >   //  TIn 就是反变,TOut就是协变
{
    TOut Output();
    
void  Intput(TIn value);
}

 

这个定义估计足够简单明了了,那么他们是干什么用的呢?

提示:理解协变和反变,需要从泛型之间的类型转换入手,而不要把注意力定格在泛型中某个函数的类型参数T上面。比如:IEnumerable<object> objs = new List<string>();

 

 

为什么要有协变和反变?

这个问题嘛,跟继承和派生的概念是有一定关联的。比方说:

ContractedBlock.gif ExpandedBlockStart.gif 对派生和继承了解的就别看了,浪费时间!
class Animal // 父类
{
   
// 返回某一种子类对象
   public static Animal CreateOne(string type)
   {
      
// 
   }
}
class Human:Animal // 子类
{
   
// 
}
class Dog:Animal // 另一个子类
{
   
// 
}
Animal foo 
= new Human(); // 这样是可以的
Human boo = Animal.CreateOne("dog"); // 这样是不可以的,因为你不能断定“动物”就一定是“人”,它还可能是“狗”。

这个和协变好像还有点远,哦,对,还跟泛型有关系:我们在泛类型上是否也可以像刚才那样使用呢?我们看一个例子:

IList < string >  source  =   new  List < string > ;
IList
< object >  target  =  source;   //  可以这么干吗?

我们先撇开“能不能”不说,至少我们是很期望能够这么干的,比如说:

public   void  RemoveNull(IList < object >  objList)
{
  
//  把中间员素值为null的元素删掉
}

IList
< string >  stringList  =  GetItFromSomewhere();
RemoveNull(stringList); 
//  嗯,看着很诱人的样子!
//  如果这样做是被允许的话,那么我们就不用再写一个针对
//  IList<string>版本的RemoveNull函数了

那到底能不能呢?这个问题很有趣,在Ninputer的文章里面提到了另外一个类似的例子:

如果两个类型T和U之间存在一种安全的隐式转换,那么对应的数组类型T[]和U[]之间是否也存在这种转换呢?
……
举个例子,就是String类型继承自Object类型,所以任何String的引用都可以安全地转换为Object引用。我们发现String[]数组类型的引用也继承了这种转换能力,它可以转换成Object[]数组类型的引用,…… 

我这里只是节选了其中一部分,是因为有一部分的描述是不准确的。为什么说是不准确的呢?我们来看这么一段例子:

string [] source  =  {  " A " " B " " C "  };
object [] target  =  source;  //  编译会出错吗?不会!
target[ 1 =   1 //  能运行通过吗?不能!

由于编译是可以通过的,所以上面我节选的那一段话是正确的。但由于实际上运行是不通过的,原因也很显而易见。

ContractedBlock.gif ExpandedBlockStart.gif 关于Ninputer的原话,以及不那么显而易见的“显而易见”的解释可以看这里

既然Ninputer点到了,我也多解释一下。没说原话不对,只是后面说是协变不准确(不是不正确)。我不反对这么说,不过我觉得是有条件的。因为这么转换实际上违反了强类型的类型安全:隐式类型转换下,应该是不会发生类型相关的运行时错误的。
比如说:

object [] a = new   object [ 1 ];
a[
0 =   1 //  int -> object 隐式转换,不应该有运行时错误,事实上也没有。
a[ 0 =   " a " //  string -> object 隐式转换,不应该有运行时错误,事实上也没有。

这样肯定没问题对吧?
但是如果是:

string [] problem  =   new   string [ 1 ];
test(problem); 
//  隐式转换或者强制转换,没关系,问题不在这里
void  test( object [] a)
{
  a[
0 =   1 //  int -> object 隐式转换导致运行时错误!
}

这却有可能发生运行时类型错误,实际上有点不太对劲,这才是我要表达的内容。

而我认为之所以是允许编译通过,很可能是有一些迫不得已的原因。举string.Format的例子可能不太恰当,但也可以说是有这个可能性的。我进一步解释一下:

string.Format(string format, params object[] objs) 这个函数不仅仅可以这么调用:

string .Format( " {0}{1}{2}{3} " 0 1 2 3 );

还可以这么调用:

object [] vals  =   new   object []{ 0 1 2 3 };
string .Format( " {0}{1}{2}{3} " , vals);

那么,当然也有可能这么调用:

string [] vals  =   new   string []{ 0 1 2 3 };
string .Format( " {0}{1}{2}{3} " , vals);

如果迫于完全尊从强类型原则,不允许string[]类型能够转换成object[]类型,上述的调用就会很麻烦,必须先创建一个object[]数组,然后一个个元素复制过去。上面这么直接写,也许很难理解为什么要这么调用,好像没有什么道理。但是如果我们考虑下面的情况:

ContractedBlock.gif ExpandedBlockStart.gif Code
List<string> foo = GetItFromSomeWhere();
string.Format("{0}{1}{2}{3}", foo.ToArray());


这也许就会比较常见了,因为我可没有办法控制GetItFromSomeWhere返回的是什么,比如说强制对方一定返回一个object[]数组,这样可能导致某些地方的性能开销增大。于是当我要用Format的时候就会遇到讨厌的转换问题。

而泛型就关不了这么多了,干脆就不允许便已通过,请参考接下来我给出的IArray例子,这个接口是否可以认为是近似于一个数组的定义呢?

 

刚才的object数组的例子,能够给我们带来很多的思考:

一、为什么数组就可以编译通过,那么泛型呢?

ContractedBlock.gif ExpandedBlockStart.gif 泛型和数组的对比
interface IArray<T>
{
  T 
this[int index]{get;set;}
}

IArray
<string> source = GetFromSomewhere();
IArray
<object> target = source; // 编译不通过!
object[] boo = new string[0]; // 竟然可以……气死人了

泛型的类型转换是不能编译通过的!可为什么不行呢?其实前面数组的那个例子已经给出了答案:运行的时候如果我们试图对某个元素做赋值操作,是有可能出现运行时错误的。实际上数组本身也没有解决这个问题,只是忽略了这个问题。忽略这个问题的原因也很简单,比如说我们看看string.Format(string format, params object[] objs)这个函数,如果不忽略又怎么提供这种方法呢?可以说数组允许这种情况的转换,其实是一种不得不作出的妥协,而并不是真正的协变(按照泛型的协变/反变规则,其实是不允许这么做的)。


二、如果我们想要编译及运行通过,应该怎么去做?

前面曾经举了一个例子,说明我们是那么期望这种泛型之间的转换,协变和反变就是为了解决这一问题的。让我们回顾最开始的那个例子:

interface  IFoo < in  TIn,  out  TOut >   //  TIn 就是反变,TOut就是协变
{
    TOut Output();
    
void  Intput(TIn value);
}

我们考虑有如下的代码:

ContractedBlock.gif ExpandedBlockStart.gif Code
class Foo<TIn, TOut> : IFoo<TIn, TOut>
{
    
public TOut Output(){}
    
public void Intput(TIn value){}
}

// 反变
IFoo<objectstring> source = new Foo<objectstring>();
source.Input(
"ABC"); // 这样显然是可以的,因为string->object是可以的
IFoo<stringstring> contra = source; // 显然这样也是可以的:
// 既然能接受object对象,显然也可以接受string对象

// 反例:
IFoo<objectstring> foo = new Foo<stringstring>();
// 这样显然是不可能的,Input(string value)当然不能传入object参数。

// 协变
object result = source.Output(); // 返回的字符串用object变量来承载,
// 显然是可以的。
IFoo<objectobject> co = source; // 同理,这样也是可以的。

// 反例:
IFoo<objectstring> boo = new Foo<objectobject>();
// 这样显然是不行的,object Output()的返回值类型是object,
// 除非强制转换,不可能赋值到string类型的变量上。

通过这段代码,应该能理解,如果我们需要在泛型之间能够达成安全的隐式类型转换,是会有一定的前提条件限制的。在应用这些限制之前,泛型之间的隐式转换是不可能的事情,即使类型参数T之间是有继承关系的。而协变/反变就是为了完成泛型间类型转换而提供的,用于明确限制转换方向、给编译器验证条件的语法工具。

 

什么时候使用协变/反变?

我的经验是,先看看框架里面是怎么用的,用多了之后再总结,别轻易在自己设计的泛型中使用。原因很简单:刚学会新特性的时候,很容易把它当作金锤子四处滥用,最后可能反而会增加了整个程序的复杂度。 

 

后记

大家考虑一下,前面举的例子IList<T>能通过协变/反变来达到目的吗?答案在Ninputer的文章中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值