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 首发于博客园。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容