C# 中的协变和逆变
作为一个从接触 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 区分。
旧文搬运,2013-12-15 首发于博客园。