前言
今天看了两篇讲协变/反变的文章,写得很好也很有意思。不过我猜应该有不少人可能还是很难理解这个新概念——每一次推出新的概念的时候,都会或多或少造成我们的困惑:这是个什么东西?为什么要出这么复杂的东西?我们什么时候应该用这种东西,什么时候不该用?
有这样的困惑没关系,我想绝大多数人都经历过这个过程。我在这里呢,也说说从我的角度是如何看这个新鲜事物的,也许对理解这个东西有帮助。不过先声明一下,我没有装过,更没有用过.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>();
为什么要有协变和反变?
这个问题嘛,跟继承和派生的概念是有一定关联的。比方说:

对派生和继承了解的就别看了,浪费时间!
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; // 能运行通过吗?不能!
由于编译是可以通过的,所以上面我节选的那一段话是正确的。但由于实际上运行是不通过的,原因也很显而易见。

关于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[]数组,然后一个个元素复制过去。上面这么直接写,也许很难理解为什么要这么调用,好像没有什么道理。但是如果我们考虑下面的情况:

Code
List<string> foo = GetItFromSomeWhere();
string.Format("{0}{1}{2}{3}", foo.ToArray());
这也许就会比较常见了,因为我可没有办法控制GetItFromSomeWhere返回的是什么,比如说强制对方一定返回一个object[]数组,这样可能导致某些地方的性能开销增大。于是当我要用Format的时候就会遇到讨厌的转换问题。
而泛型就关不了这么多了,干脆就不允许便已通过,请参考接下来我给出的IArray例子,这个接口是否可以认为是近似于一个数组的定义呢?
刚才的object数组的例子,能够给我们带来很多的思考:
一、为什么数组就可以编译通过,那么泛型呢?

泛型和数组的对比
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);
}
我们考虑有如下的代码:

Code
class Foo<TIn, TOut> : IFoo<TIn, TOut>
{
public TOut Output(){
}
public void Intput(TIn value){
}
}
// 反变
IFoo<object, string> source = new Foo<object, string>();
source.Input("ABC"); // 这样显然是可以的,因为string->object是可以的
IFoo<string, string> contra = source; // 显然这样也是可以的:
// 既然能接受object对象,显然也可以接受string对象
// 反例:
IFoo<object, string> foo = new Foo<string, string>();
// 这样显然是不可能的,Input(string value)当然不能传入object参数。
// 协变
object result = source.Output(); // 返回的字符串用object变量来承载,
// 显然是可以的。
IFoo<object, object> co = source; // 同理,这样也是可以的。
// 反例:
IFoo<object, string> boo = new Foo<object, object>();
// 这样显然是不行的,object Output()的返回值类型是object,
// 除非强制转换,不可能赋值到string类型的变量上。
通过这段代码,应该能理解,如果我们需要在泛型之间能够达成安全的隐式类型转换,是会有一定的前提条件限制的。在应用这些限制之前,泛型之间的隐式转换是不可能的事情,即使类型参数T之间是有继承关系的。而协变/反变就是为了完成泛型间类型转换而提供的,用于明确限制转换方向、给编译器验证条件的语法工具。
什么时候使用协变/反变?
我的经验是,先看看框架里面是怎么用的,用多了之后再总结,别轻易在自己设计的泛型中使用。原因很简单:刚学会新特性的时候,很容易把它当作金锤子四处滥用,最后可能反而会增加了整个程序的复杂度。
后记
大家考虑一下,前面举的例子IList<T>能通过协变/反变来达到目的吗?答案在Ninputer的文章中。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· .NET周刊【3月第1期 2025-03-02】
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器