泛型的协变与抗变(反变) (转载)
随Visual Studio 2010 CTP亮相的 C#4 和 VB10,虽然在支持语言新特性方面走了相当不一样的两条路:C#着重增加后期绑定和与动态语言相容的若干特性,VB10着重简化语言和提高抽象能力;但是两者都增加了一项功能:泛型类型的协变(covariant)和抗变(contravariant)。许多人对其了解可能仅限于增加的in/out关键字,而对其诸多特性有所不知。下面我们就对此进行一些详细的解释,帮助大家正确使用该特性。
背景知识:协变和抗变
很多人可能不不能很好地理解这些来自于物理和数学的名词。我们无需去了解他们的数学定义,但是至少应该能分清协变和反变。实际上这个词来源于类型和类型之间的绑定。
我们从数组开始理解。数组其实就是一种和具体类型之间发生绑定的类型。数组类型Int32[]就对应于Int32这个原本的类型。任何类型T都有其对应的数组类型T[]。
那么我们的问题就来了,如果两个类型T和U之间存在一种安全的隐式转换,那么对应的数组类型T[]和U[]之间是否也存在这种转换呢?
这就牵扯到了将原本类型上存在的类型转换映射到他们的数组类型上的能力,这种能力就称为“可变性(Variance)”。
在.NET世界中,原始类型唯一允许可变性的类型转换就是由继承关系带来的“子类引用->父类引用”转换。
举个例子,就是String类型继承自Object类型,所以任何String的引用都可以安全地转换为Object引用。我们发现String[]数组类型的引用也继承了这种转换能力,它可以转换成Object[]数组类型的引用,数组这种与原始类型转换方向相同的可变性就称作协变(covariant)。
由于数组不支持反变性,我们无法用数组的例子来解释反变性,所以我们现在就来看看泛型接口和泛型委托的可变性。
假设有这样两个类型:TSub是TParent的子类,显然TSub型引用是可以安全转换为TParent型引用的。
如果一个泛型接口IFoo
而如果一个泛型接口IBar
因此很好理解,如果一个可变性和子类到父类转换的方向一样,就称作协变;而如果和子类到父类的转换方向相反,就叫抗变。
简而言之:协变和抗变是专门针对泛型接口和泛型委托可变性的扩展,协变是指接口的泛型子类向泛型父类方向的类型转换,抗变是指接口的泛型父类向泛型子类方向的类型转换,如果不使用out和in标注协变和抗变,那么这个泛型类型就是不变的。
.NET 4.0引入的泛型协变、反变性
刚才我们讲解概念的时候已经用了泛型接口的协变和反变,但在.NET 4.0之前,无论C#还是VB里都不支持泛型的这种可变性。不过它们都支持委托参数类型的协变和反变。由于委托参数类型的可变性理解起来抽象度较高,所以我们这里不准备讨论。已经完全能够理解这些概念的读者自己想必能够自己去理解委托参数类型的可变性。
在.NET 4.0之前为什么不允许IFoo
interface IFoo
{
void Method1(T param);
T Method2();
}
如果我们允许协变,从IFoo
我们都知道TParent是不能安全转换成TSub的,所以Method1这个方法就会变得不安全。
同样,如果我们允许反变IFoo
有此可见,在没有额外机制的限制下,泛型接口进行协变或反变都是类型不安全的。
.NET 4.0改进了什么呢?
它允许在类型参数的声明时增加一个额外的描述,以确定这个类型参数的使用范围。
我们看到,如果一个类型参数仅仅能用于函数的返回值,那么这个类型参数就对协变相容。而相反,一个类型参数如果仅能用于方法参数,那么这个类型参数就对反变相容。
如下所示:
interface ICo
{
T Method();
}
interface IContra
{
void Method(T param);
}
可以看到C#4和VB10都提供了大同小异的语法,用Out来描述仅能作为返回值的类型参数,用In来描述仅能作为方法参数的类型参数。
一个接口可以带多个类型参数,这些参数可以既有In也有Out,因此我们不能简单地说一个接口支持协变还是反变,只能说一个接口对某个具体的类型参数支持协变或反变。
比如若有IBar
举个例子来说,IBar
在.NET Framework中,许多接口都仅仅将类型参数用于参数或返回值。为了使用方便,在.NET Framework 4.0里这些接口将重新声明为允许协变或反变的版本。
例如IComparable
下面提起几个泛型协变和反变容易忽略的注意事项:
-
仅有泛型接口和泛型委托支持对类型参数的可变性,泛型类或泛型方法是不支持的。
-
值类型不参与协变或反变,IFoo
永远无法变成IFoo -
声明属性时要注意,可读写的属性会将类型同时用于参数和返回值。因此只有只读属性才允许使用out类型参数,只写属性能够使用in参数。
协变和反变的相互作用
这是一个相当有趣的话题,我们先来看一个例子:
interface IFoo
{
}
interface IBar
{
void Test(IFoo foo); //对吗?
}
你能看出上述代码有什么问题吗?
我声明了in T,然后将他用于方法的参数了,一切正常。但出乎你意料的是,这段代码是无法编译通过的!反而是这样的代码通过了编译:
interface IFoo
{
}
interface IBar
{
void Test(IFoo foo);
}
什么?明明是out参数,我们却要将其用于方法的参数才合法?
初看起来的确会有一些惊奇。我们需要费一些周折来理解这个问题。
现在我们考虑IBar
,它应该能够协变成IBar
,因为string是object的子类。因此IBar.Test(IFoo
也就协变成了IBar.Test(IFoo
。
当我们调用这个协变后方法时,将会传入一个IFoo
作为参数。想一想,这个方法是从IBar.Test(IFoo
协变来的,所以参数IFoo
必须能够变成IFoo
才能满足原函数的需要。这里对IFoo
的要求是它能够反变成IFoo
!而不是协变。
也就是说,如果一个接口需要对T协变,那么这个接口所有方法的参数类型必须支持对T的反变。
同理我们也可以看出,如果接口要支持对T反变,那么接口中方法的参数类型都必须支持对T协变才行。
这就是方法参数的协变-反变互换原则。
所以,我们并不能简单地说out参数只能用于返回值,它确实只能直接用于声明返回值类型,但是只要一个支持反变的类型协助,out类型参数就也可以用于参数类型!换句话说,in参数除了直接声明方法参数之外,也仅能借助支持协变的类型才能用于方法参数,仅支持对T反变的类型作为方法参数也是不允许的。
要想深刻理解这一概念,第一次看可能会有点绕,建议有条件的情况下多进行一些实验。
刚才提到了方法参数上协变和反变的相互影响。那么方法的返回值会不会有同样的问题呢?我们看如下代码:
interface IFooCo
{
}
interface IFooContra
{
}
interface IBar
{
IFooCo Test1();
IFooContra Test2();
}
我们看到和刚刚正好相反,如果一个接口需要对T进行协变或反变,那么这个接口所有方法的返回值类型必须支持对T同样方向的协变或反变。这就是方法返回值的协变-反变一致原则。
也就是说,即使in参数也可以用于方法的返回值类型,只要借助一个可以反变的类型作为桥梁即可。
如果对这个过程还不是特别清楚,建议也是写一些代码来进行实验。至此我们发现协变和反变有许多有趣的特性,以至于在代码里in和out都不像他们字面意思那么好理解。当你看到in参数出现在返回值类型,out参数出现在参数类型时,千万别晕倒,用本文的知识即可破解其中奥妙。
总结
经过本文的讲解,大家应该已经初步了解的协变和反变的含义,能够分清协变、反变的过程。我们还讨论了.NET 4.0支持泛型接口、委托的协变和反变的新功能和新语法。最后我们还套了论的协变、反变与函数参数、返回值的相互作用原理,以及由此产生的奇妙写法。