说十分钟可能有哗众取宠的嫌疑,本人写这个博客,花了半天时间,查阅了很多资料才完成,因此要弄懂协变逆变的本质,可能要多花点时间。
很多文章中对于协变的描述大致如下:
协变是一个细节化程度高的类型赋值给细节化程度低的类型类型。例如一个方法M,返回值是Giraffe(长颈鹿),你可以把M的返回值赋值给Animal(动物)类型,因为Animal类型是细节化程度底的类型,和Giraffe类兼容。那么方法是协变的,因为当你创建了协变接口,需要用到关键字out,返回值从方法中out。
这根本就不是协变的意思。这只是描述了赋值兼容性,两个类型可以兼容。
协变的准确定义是什么呢?
先不考虑类型,我们考虑数学意义上的整数。考虑整数之间的小于关系——≤。这里关系实际上就是一个方法,接受2个数,返回布尔值。
现在我们考虑一下对于整数的投影,投影指的是一个函数,接受一个整数,返回另一个整数。比如 z → z + z 我们可以定义为D(double), z → 0 - z定义为N for (negate), z → z * z,定义为S(square).
问题就出来了,是不是所有的情况下, (x ≤ y) = (D(x) ≤ D(y))?事实上就是,如果x小于等于y,那么x的2倍也小于等于y的2倍。投影D保留了不等号的方向。
对于N,很显然,1 ≤ 2 但是 -1 > -2。即(x ≤ y) = (N(y) ≤ N(x)),投影N反转了不等号的方向。
对于S, -1 ≤ 0, S(0) ≤ S(-1),但是 1 ≤ 2, S(2)≥ S(1)。可见投影S即没保留不等号方向,也没反转不等号方向。
投影D就是协变的,它保留了整数上的次序关系。投影N是逆变的,它反转了整数上的次序关系,投影S两者都不是,所以是不变的。
所以这里很清楚,整数自身不是变体,小于关系也不是变体。投影才是协变或者逆变——接受一个整数生成一个新整数。
现在再来看类型,替换整数上的小于关系,我们在引用类型上也有一个小于关系。如果引用类型X的值可以存储在类型Y上,那么称一个引用类型X小于或等于引用类型Y。
再考虑对于类型的投影,假设一个投影是把T变成IEnumerable<T>,即,如果接受一个参数Giraffe类型,返回一个新类型 IEnumerable<Giraffe>。那么这个投影在C# 4.0中是协变吗?是的,它保留了次序的方向。Giraffe可以赋值给Animal,因此Giraffes序列也可以赋值给IEnumerable<Giraffe>。
精炼的说,对于一个投影,如果A可以赋值给B,经过投影后的值A'可以赋值给B',那么就可以说这个投影是一个协变。
我们可以认为接受类型T,生成出类型IEnumerable<T>看作是一个投影,并称这个投影为"IEnumerable<T>”。因此根据上下文,当我们说IEnumerable<T>是协变的,意思就是对于类型T生成类型IEnumerable<T>的投影是一个协变的投影。由于IEnumerable<T>只有一个类型参数,因此很明确我们说的参数就是T。
因此我们可以定义协变,逆变和不变。如果一个泛型类型I<T>,根据类型参数得出的结构,保留了赋值的兼容方向,那么这个泛型类型是协变的。即如果一个泛型类型I<T>,对于类型A和B,如果A能赋值给B,而且I<A>也能赋值给I<B>,即保留了赋值的兼容方向,那么说I<T>这个泛型类就是协变的。相反,逆变就是反转了赋值的兼容方向。不变就是既不是协变也不是逆变。简单准确的说,接受T,生成 I<T>的这样的一个投影就是协变/逆变/不变的投影。
在 C# 中,协变和逆变允许数组类型、委托类型和泛型类型参数进行隐式引用转换。 协变保留分配兼容性,逆变与之相反。
数组是支持协变的,string兼容于object,string数组化后依然兼容于object。但是协变可能会导致类型不安全,如下例子:
1
2
|
object
[] array =
new
String[10];
// 这里会报错,array[0]已经先分配给string了,无法再接受整型。 // array[0] = 10;
|
委托类型也支持协变。如下代码,string兼容于object,返回值为string的fun兼容于返回值为object的委托。
1
2
3
4
5
6
7
8
|
public
delegate
object
mydelege();
static
string
fun2()
{
return
""
;
}
static
void
Main(
string
[] args)
{
mydelege md1 = fun2;
//string兼容于object,返回值为string的fun兼容于返回值为object的委托
}
|
对于泛型接口,在Framework4.0之前,不支持协变。例如: IEnumerable<object> b = new List<string>(); 无法编译过。string兼容于object,但是List<string>无法兼容IEnumerable<object>,属于不变。 但在Framework4.0之后,上述例子就能编译过了,也就是说IEnumerable<object>这个泛型接口支持协变了。
Framework4.0后,支持协变的接口有很多。IEnumerable<T>、IEnumerator<T>、IQueryable<T> 和 IGrouping<TKey, TElement>
委托类型支持逆变。如下代码:
儿子继承父亲
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
class
Father{ }
class
Son : Father{ }
class
Program{
public
delegate
void
mydelege1(Father f);
public
delegate
void
mydelege2(Son s);
static
void
fun1(Father s)
{
}
static
void
fun2(Son s)
{
}
static
void
Main(
string
[] args)
{
Father f =
new
Father();
Son s =
new
Son();
f = s;
//ok,儿子可以赋值给父亲
mydelege1 md1 = fun2;
//error,输入参数不支持协变,儿子类型的方法不能赋值给父亲类型的委托
mydelege2 md2 = fun1;
//ok,父亲类型的方法可以赋值给儿子类型的委托,逆变了。
}
}
|
对于一个委托mydelege1(Father f),定义的输入参数类型是Father。
可以看到son是可以赋值给father的,而经过委托定义这样一个投影,发现son类型为参数的方法不能赋值给father类型为参数的委托。即经过这样的委托投影,son类型方法无法赋值给father类型委托了。
而相反的,father类型为参数的方法却可以赋值给son类型为参数的委托。即经过这样的委托投影,father类型的方法可以赋值给son类型的委托了。这就逆天了,因此也就是逆变了。
简单的来看,即投影前Son可以赋值给Fahter,投影(转成委托类型)后Father可以赋值给Son,是一种逆变。
为什么转成委托后,Son类型的方法不能赋值给Father类型的委托了,很简单,这个方法要接受的是Son的方法要处理的自然是Son类型的值,而Father为参数的委托可能接受Daughter类型的参数,(假设Daughter和Son并列的继承了Father)。
因此Son方法就无法处理了。因此不允许这样操作。 换成代码来说,假设这样的代码合法了:
1
|
mydelege1 md1 = fun2;
//假设是合法的,
|
那么 md1(new Daughter())的代码要处理的时候,肯定无法处理了。
所以,不允许存在这样的协变,而只允许逆变。通过上面的一些例子,可以看出对于委托,协变只存在与返回值中,逆变只存在与输入值中。
再来看泛型委托中的协变和逆变 我们可以看微软自定义的泛型委托,Func和Action这两个。(Func必须有返回类型,Action返回void) 在Framework3.5的时候,Func,Action是不支持协变也不支持逆变。如下代码:
1
2
3
4
5
6
7
8
9
10
11
|
class
Father{ }
class
Son : Father{ }
class
Program{
static
void
Main(
string
[] args)
{
Func<Father> fatherfun = () =>
new
Father();
Func<Son> sonfun = () =>
new
Son();
fatherfun = sonfun;
//无法将类型"System.Func<ConsoleApplication2.Son>";隐式转换为"System.Func<ConsoleApplication2.Father>
sonfun = fatherfun;
//无法将类型"System.Func<ConsoleApplication2.Father>"隐式转换为"System.Func<ConsoleApplication2.Son>"
}
}
|
上面的代码编译不过。因此很是不方便,所以在Framework4.0后,允许其可以协变和逆变。在Framework4.0中,如下
1
2
3
4
5
6
7
|
static
void
Main(
string
[] args)
{
Func<Father> fatherfun = () =>
new
Father();
Func<Son> sonfun = () =>
new
Son();
fatherfun = sonfun;
//ok协变成功,Son可以赋值给Father,Sonfun也可以赋值给Fatherfun了;
sonfun = fatherfun;
//无法将类型"System.Func<ConsoleApplication2.Father>"隐式转换为"System.Func<ConsoleApplication2.Son>"。存在一个显式转换(是否缺少强制转换?)
}
|
对于逆变,如下代码,同样的,在Framework3.5时代,仍然是不可协变逆变。
1
2
3
4
5
6
7
8
9
10
|
class
Father{ }
class
Son : Father{ }
class
Program{
static
void
Main(
string
[] args)
{
Action<Father> fatherfun;
Action<Son> sonfun;
fatherfun = sonfun;
//无法将类型"System.Action<ConsoleApplication2.Son>"隐式转换为"System.Action<ConsoleApplication2.Father>
sonfun = fatherfun;
//无法将类型"System.Action<ConsoleApplication2.Father>"隐式转换为"System.Action<ConsoleApplication2.Son>"
}
}
|
在Framework4.0后,允许其可以逆变。
1
2
3
4
5
6
7
|
static
void
Main(
string
[] args)
{
Action<Father> fatherfun;
Action<Son> sonfun;
fatherfun = sonfun;
//无法将类型"System.Action<ConsoleApplication2.Son>"隐式转换为"System.Action<ConsoleApplication2.Father>"。存在一个显式转换(是否缺少强制转换?)
sonfun = fatherfun;
//ok 逆变成功
}
|
以上是微软提供的泛型委托,事实上自己也可以定义自己的泛型委托,也可以知道这个委托是否支持协变或者逆变。在C#中,通过对参数标注in和out来标注此参数是逆变类型参数还是协变类型参数。
微软MSDN中的定义。
协变类型参数用 out 关键字(在 Visual Basic 中为 Out 关键字,在 MSIL 汇编程序中为 +)标记。 可以将协变类型参数用作属于接口的方法的返回值,或用作委托的返回类型。 但不能将协变类型参数用作接口方法的泛型类型约束。 逆变类型参数用 in 关键字(在 Visual Basic 中为 In 关键字,在 MSIL 汇编程序中为 -)标记。 可以将逆变类型参数用作属于接口的方法的参数类型,或用作委托的参数类型。 也可以将逆变类型参数用作接口方法的泛型类型约束。事实上Action和Func的定义在Framework4.0中如下:
1
2
|
public
delegate
void
Action<
in
T>( T obj )
public
delegate
TResult Func<
in
T,
out
TResult>(T arg )
|
从定义可以看出,协变out主要用在返回值上,逆变in用在输入参数。如果把out由于输入参数,编译不会通过。原因前面也已经解释过了。这里在说明一下:假设我们把out用于输入参数,并假设编译能够通过,那么这样做的目的是使该参数能够协变。因此假设如下代码能成功编译。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
class
Father
{ }
class
Son : Father
{ }
class
Daught : Father
{ }
class
Program
{
public
delegate
void
myAction<
out
T>(T t);
//该委托和Action<in T>类似,就差了一个in,一个out,假设此段代码编译通过。
static
void
f_father(Father f) { }
static
void
f_son(Son f) { }
static
void
Main(
string
[] args)
{
myAction<Father> fatheract = f_father;myAction<Son> sonact = f_son;
fatheract = sonact;
//假设上面的委托能编译成功,那么就是支持协变。因此这段代码也能成立。
fatheract(
new
Daught())
//这段代码就无法准确运行了。
//如果这段代码成立了,
//那么fatheract(new Daught())运行的时候,发现
//f_son里面接受了Daughter类型,无法运行。
}
}
|
同样的,如果对于输出类型用in来修饰,即允许其可以逆变,也会出现类似的类型问题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
class
Father
{ }
class
Son : Father
{ }
class
Daught : Father
{ }
class
Program
{
public
delegate
T myFunc<
in
T>();
//假设这个代码可以编译成功。即假设是支持逆变
static
void
Main(
string
[] args)
{
myFunc<Father> fatherfunc = ()=>
new
Father();
myFunc<Son> sonfunc = () =>
new
Son(); ;
sonfunc = fatherfunc;
//假设上面的代码编译成功,支持了逆变,即支持了fatherfunc赋值给sonfunc那么下面的代码就会引起异常。
Son ason= sonfunc();
//ason被迫接受father类型了,导致异常
}
}
|
所以,in对应逆变,只能用于输入参数,out对应协变,只能用于输出参数。否则会出现问题。
因此,在泛型委托中用允许输入类型协变会引起类型问题,因此只允许逆变。
基于上面的叙说,很多人会有疑问为何还要显示的标注in或者out,编译器完全可以推断泛型参数是协变还是逆变。事实上,编译器确实可以自动推断,但C#团队认为你需要明确的定义了一个契约,并且遵守这个契约。比如,如果编译器替你决定了某个泛型类型参数是逆变的,但是,你却在接口上加了个成员,并使用了out标记。这到后来可能会导致一些类型的错误,因此,一开始编译器就要求你显示的声明泛型类型参数。如果你不按照你定义的规则那样,协变或者逆变,编译器会报错,提示你违法了你定义的契约。
所以,协变逆变只不过是为了使泛型委托转型成为可能,简化了编程。即使不清楚内部的原理,也能很好利用协变逆变的特性来编程。
-------------------------------------------------------------------
参考
http://msdn.microsoft.com/zh-cn/library/ee207183.aspx
http://blogs.msdn.com/b/ericlippert/archive/2009/11/30/what-s-the-difference-between-covariance-and-assignment-compatibility.aspx
http://geekswithblogs.net/abhijeetp/archive/2010/01/10/covariance-and-contravariance-in-c-4.0.aspx
《CLR.via.Csharp.3rd.Edition》