您的位置:首页 > 编程语言 > C#

C# 中的协变和逆变

2013-12-15 00:45 253 查看
作为一个从接触 Unity 3D 才开始学习 C# 的人,我一直只了解一些最基本、最简单的语言特性。最近看了《C# in Depth》这本书,发现这里面东西还真不少,即使除去和 Windows 编程相关的内容,只是兼容 Unity 的部分就够好好消化一阵子的。其中,令我非常头大的一组概念,就是协变性和逆变性(统称可变性)。

一、可变性的概念

C# 一开始就支持数组的协变,听说是为了和 Java 竞争于是就把 Java 的这个不怎么样的特性给实现了。看如下代码,

object[] myArray = new string[] {
"abc",
"def",
"ghi",
// ...
};


我 new 了一个 string 类型的数组,却把它作为一个 object 类型数组的初始化式。这可以编译通过,也就是说,使用 object 数组的地方,都可以传入 string 数组。推广一下,就是使用基类数组的地方,都可以传入派生类数组。这样看来,协变的概念没什么深奥的,所谓协变性指的就是,在一个使用一般类型的地方,可以传入一个特殊类型的对象。那么,最基本的协变就是面向对象中的多态——使用基类型对象的地方,都可以传入派生类对象。

但是,上面这个数组的协变性还是很特别的。首先,在 C# 的类型系统中,派生类型的数组并不继承自基类型的数组(别的语言也是如此吧)。所以,它的确是一种新的协变性。其次,如果你做一个如下操作,你就会收到运行时错误,告诉你数组类型不匹配:

myArray[0] = 3;


也就是说,CLR 还是知道 myArray 到底是什么类型的,并且不许改变。这显得有些别扭,不过至少我通过它知道协变是什么了。于是逆变就是反过来的概念——在一个使用特殊类型的地方,可以传入一般类型的对象。

二、委托中的可变性

在 C# 1 中,如果我们定义一个委托类型,那么用于它的方法将必须在参数表和返回值方面严格匹配。但是 C# 2 里事情改变了,它支持对参数的逆变性和对返回值的协变性。假定有类型 Base 和 Derived,其中后者派生自前者,那么下面代码是合法的。

delegate Base VariantDelegate(Derived d);

public Derived MyFunc(Base b) {
// ...
}

// ...
VariantDelegate d = new VariantDelegate(MyFunc);


委托 VariantDelegate 要求一个 Base 类型的返回值,但是我们可以给它一个 Derived 类型的返回值,即这返回值是协变的。类似的,这委托的参数是逆变的。为什么这样是合理的呢?

考虑使用这委托的地方,它最终会调用这个委托的实例,传入参数,处理返回值。由于使用者必须给这个委托实例传一个 Derived 类型(或者它的子类型)的参数,那么,如果这委托所调用的方法 MyFunc 本身需要一个 Base 类型的参数,不会有任何问题。因为所有的 Derived 对象可以被当做是 Base 类型的对象来使用。具体的说,如果 Base 是 object,而 Derived 是 string,那么本例中,委托的使用者会给委托一个 string 类型的参数,而 MyFunc 会把这 string 当做是 object 处理,当然是安全的。另一方面,对于这委托的返回值,使用者当它是个 Base 来处理,那么,MyFunc 实际返回的是 Derived 也就没有任何问题。这其实就是消费代码必须把传入对象当做是一个更加泛化(一般)的对象来处理。因此,如果让委托参数支持协变,返回值支持逆变,那么一定会死得很难看。

三、泛型中的可变性

一直到 C# 3,泛型类型、接口、委托的参数都是不可变的。基于和上述类似的逻辑,C# 4 终于决定在泛型接口和委托中支持类型参数的可变性。如果你想使用可变性,必须在类型参数前用 in 或者 out 修饰符来显式指定。和前面类似,如果一个类型参数仅用作接口方法或者委托中的(普通)参数,那么它可以被指定为逆变的(使用 in 来修饰);如果它只作为返回值,那么它可以被指定为协变的(使用 out 来修饰)。最常见的例子是下面两个委托:

delegate void Action<T>(T t);
delegate TResult Func<TResult>();


在 C# 4 中的定义变成了

delegate void Action<in T>(T t);
delegate TResult Func<out TResult>();


如果理解了上面介绍的关于(非泛型)委托参数和返回值的可变性,那么对这样的泛型委托也可以很容易的理解。但是,如果情况复杂了怎么办呢?考虑下面这个情况:

delegate void Action2<T>(Action<T> action);


这个委托如果要受可变性的恩泽,应该在 T 前面加什么修饰符呢?答案是 out,而书上的解释是模糊的:“作为一个便捷的规则,可以认为内嵌的逆变性反转了之前的可变性。”这话谁看得懂呢?还是来分析一下好了。

这个委托 Action2 的参数是 Action<T> 类型。基于委托参数的逆变性,我们可以传递一个比 Action<T> 更特殊(如果不是 Action<T> 本身)的类型。那么怎样的类型是比 Action<T> 特殊呢?由于委托参数的逆变性,Action<T> 需要一个比 T 特殊(如果不是 T 本身的话)的参数,也就是说,使用 Action<object> 的地方可以传一个 Action<string>。所以,我们可以将其理解为,Action<string> 是比 Action<object> 更泛化的类型。那好了,Action<object> 就是比 Action<string> 更窄化。这样,前面说需要 Action<T> 参数的地方,可以传一个 Action<T 的派生类>。也就是说,在 Action2<T> 中,要求类型 T 的时候可以传入 T 的派生类,所以 T 是协变的,应加 out 修饰符。从这个例子,也可以明白前面引用的《C# in depth》上的解释:object 本身是比 string 更泛化的类型,但逆变性使得 Action<string> 成了比 Action<object> 更泛化的类型。

值得注意的一点,就是 out 的意思。out 除了表示类型参数的协变性之外,还有一个作用,就是作为函数参数的修饰符,表示输出参数。如果泛型接口或者委托的类型参数用于输出参数,那么它本身是不可变的,也就不能用 out 来修饰了。为什么呢?我们假定 CLR 支持这种可变性(从语义上来看当然应该是协变性),看看会发生什么。考虑如下委托:

delegate void WrongDelegate<out T>(out T t); // Won't really compile


它有一个输出参数,是 T 类型的。我们标记它是协变的,那么需要 WrongDelegate<string> 的地方可以传入 WrongDelegate<object>。也就是说它可以从下面这样的方法实例化:

void MyFuncWithOutParam(out object o){
// Something will be assigned to o here
}


可是,使用 WrongDelegate<string> 的地方会传一个 string 类型的变量给它作为输出参数,而 MyFuncWithOutParam 认为传进来的东西是 object,不定会赋什么样的对象给它,后果不堪设想。至于如果 T 是逆变的会发生什么问题,我还没有想清楚,很可能是因为,输出参数是不能做类型转换的(比如编译器会告诉你out object不能转换为out string或者反过来)。但为什么输出参数不能做类型转换呢?

无论如何,输出参数和返回值还是很不一样的。输出参数毕竟还是参数。对 CLR 来说,输出参数是带有特别属性的引用参数,我理解和引用参数的区别并不大。而对于引用参数,人们可能更容易理解它为什么不能是可变的。

注意:这里说的可变性,是英文 Variance,协变和逆变分别是 Covariance 和 Contravariance,要和 mutability 区分。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: