本文共 6260 字,大约阅读时间需要 20 分钟。
个人感觉协变(Covariance)与逆变(Contravariance)是 C# 4 中最难理解的一个特性了,因为 C# 4 用了一个非常直观的语法(in
和out
关键字),在很多情况下,这似乎很简单,in
用于输入的参数,out
用于输出的返回值,但事实上不完全如此,比如Method(Action<T> action)
(会让人抓狂,一会再说)。这也是困扰了我相当久的问题,所以今天打算分享一下我自己的理解。
我们先引入一些记号,假设 T 和 U 是两个类型,那它们之间会有几种关系:
T < UT > UT = UT 和 U 无关
Animal
和 Cat
两个类型, Cat
是 Animal
的子类,那我们就记为 Animal > Cat
或 Cat < Animal
,可以理解为, Animal
表示所有的动物,而 Cat
只表示"猫", Animal
可以表示的范围比 Cat
更广,所以 Animal > Cat
。现在假设我们分别在 T 和 U 上应用一个操作,我们用 f 函数来表示这个操作,即应用了 f 以后,T 和 U 对应地变成 f(T) 和 f(U)。 协变:T < U,应用 f 操作后,f(T) < f(U)
逆变:T < U,应用 f 操作后,f(T) > f(U)
Cat
,U 替换成 Animal
,用大小关系来表示,即 Cat < Animal
,然后把 f 操作替换为"数组化",也就是说,应用了数组化操作后, Cat
就成变 Cat[]
, Animal
就变成 Animal[]
。 数组上的协变:Cat < Animal,所以 Cat[] < Animal[]
Cat
是 Animal
的子类,即 Cat < Animal
,那下面的语法是合法的: Cat cat = ...;Animal animal = cat;
也就是说,如果两个类型 T 和 U 满足 T < U,那么下面的代码是合法的:
T obj = ...;U u = obj;
前面我们说了,C# 从 1.0 开始就支持数组上的协变,也就是说,如果Cat < Animal
,那么Cat[] < Animal[]
就可以成立,那是不是意味着,我可以将一个Cat
数组赋值给一个Animal
数组呢?答案是确定的:
// 定义 Cat 数组
Cat[] cats = new[] { new Cat { Name = "Kitty" } };
// 将 Cat 数组赋值给 Animal 数组
Animal[] animals = cats;
Animal animal = new Person()
这样的代码是没办法通过编译的( Person
类型和 Animal
不兼容),C# 语言在设计上就在尽可能地避免类型不安全的发生,但可惜的是,数组上的协变不是类型安全的协变,我们可以通过一个例子来看: // 定义 Cat 数组
Cat[] cats = new[] { new Cat { Name = "Kitty" } };
// 将 Cat 数组赋值给 Animal 数组
Animal[] animals = cats;
// 修改 Animal 数组的第一个元素
animals[0] = new Tiger { Name = "Tiger Lei" };
Action<>
上的协变就不被也永远不会被支持,试想一下,如果支持 Action<>
操作的协变,那会是怎样?按前面说的,已知 Cat < Animal
,若支持 Action<>
操作上的协变,则有 Action<Cat> < Action<Animal>
,那就意味着下面的代码是合法的: Actionmiao = cat => cat.Miao(); Action action = miao; action(new Tiger());
Action<>
上的协变,则上面的代码可以通过编译,但很明显,最后一行在执行时会抛出一个运行时的异常,因为 Tiger
虽然长得有那么点像猫,但人家可不会 Miao
的叫啊。所以,C# 是不会允许这种情况发生的,所以上面的代码在实际中会编译错误(不管是哪个版本的编译器)。 Action<>
上支持类型安全的协变,那可以支持类型安全的逆变吗?我们可以来试一下,已知 Cat < Animal
,假设支持 Action<>
操作的逆变,则有 Action<Cat> > Action<Animal>
,那就意味着下面的代码是合法的: ActionsayHello = { it => Console.WriteLine(it.Name); };Action catSayHello = sayHello; catSayHello(new Cat());
sayHello
这个委托永远都只会调用 Animal
上的属性和方法,而我们永远都只会向 catSayHello
传入 Cat
或 Cat
的子类。 sayHello
既然可以处理 Animal
,那一定可以处理 Cat
,所以,上面的代码是类型安全的,也就是说, Action<>
上的逆变是类型安全的。 Action<>
上的逆变是类型安全的,但在 C# 4.0 之前,你没有办法在代码中使用这种逆变性,所以大家可能会发牢骚,把 Action<Animal>
赋给 Action<Cat>
明明是类型安全的,会什么编译器不让我通过!不过幸运的是,C# 4.0 开始,类型安全的协变和逆变都得到了支持,但要注意的是,我们在 C# 4.0 中谈到的对协变和逆变的支持,都是在"类型安全"的前提下,类型不安全的协变和逆变是不支持的,并且,我们谈的都是对泛型参数的协变性和逆变性的支持。 例如:
public interface IEnumerator{ T Current { get; }}public interface IEnumerable { IEnumerator GetEnumerator();}public delegate TResult Func ();
IEnumerator<T>
、 IEnumerable<T>
和 Func<T>
中的T,都是处于"输出"的位置,所以 T
是可以支持类型安全的协变的,我们可以试一下,已知 Cat < Animal
,若支持 IEnumerable<>
操作上的协变,则 IEnumerable<Cat> < IEnumerable<Animal>
,那按照"小的"可以赋值给"大的"的原则: IEnumerablecats = ...; IEnumerable animals = cats;
// 接下来随便对 animals 怎么操作,都是类型安全的,强制类型转换除外
Func<T>
,已知 Cat < Animal
,那 Func<Cat> < Func<Animal>
,因此: FuncfindCat = () => new Cat(); Func findAnimal = findCat;
Animal animal = findAnimal();
// 接下来不管怎么对 animal 操作,都是类型安全的,强制类型转换除外
例如:
public interface IComparer{ int Compare(T x, T y);}public delegate void Action (T obj);
IComparer<T>
和Action<T>
中的T
都是处于输入的位置,所以它们的逆变性都是类型安全的。我们可以试一下,已知Cat < Animal
,若支持IComparer<>
操作上的逆变,则有IComparer<Cat> > IComparer<Animal>
,也就意味着下面的代码是合法的:
ICompareranimalComparer = ...;IComparer catComparer = animalComparer;catComparer(new Cat(), new Cat());
animalComparer
可以处理任意的动物 (Animal
),而我们只可能向catComparer
传入Cat
或Cat
的子类,既然animalComparer
可以处理任意的动物,那当然就可以处理任意的猫了,所以上面的代码是类型安全的。Action<>
前面已举过例子,不再重复。
因此,我们可以得到一个大致的结论,如果泛型参数处于输出的位置,那它就可以支持类型安全的协变,若泛型参数处于输入的位置,就可以支持类型安全的逆变(不完全正确,后面再细说),这也就是为什么 C# 用out
来表示对应的泛型参数支持协变,而用in
来表示对应的泛型参数支持逆变。out
和in
显然比协变和逆变这样的术语来得通俗易懂,所以这也是 C# 设计团队的聪明之处。
如果说上面的内容都很好理解,那接下来的这个例子也许就会让人抓狂了。
前面说过,当泛型参数处于"输入"的位置时,它的逆变是类型安全的,这时只要在相应的泛型参数前加个in
关键字,就可以让它支持逆变,C# 编译器就不会为难我们,但C# 编译器真的这么仁慈吗?
public interface IFoo{ }public interface IBar { void Method(IFoo foo);}
在IBar<T>
中,T 是处于输入的位置,所以上面的代码理应会在 C# 4.0 的编译器下编译通过,但事实上,我们会得到一个编译错误。好吧,和前一节讲的不一样,这是为什么?要怎么做才能让它编译过过?
为简化问题,我们用X
来代指 IFoo<T>
,用X'
来代指 IFoo<T'>
('
没有特殊含义,如果不觉得太多字母迷乱双眼的话,也可以用Y
啊A
啊什么的),即:
X = IFooX' = IFoo public interface IFoo { }
// 下面的问号有三种可能性,
// in, out 或什么都不加(不可变)
// 接下来我们会推导出一个合适的结果
public interface IBar { void Method(X foo);}
IBar
来说,泛型参数 X
处于输入的位置,这和上一节中提到的 IComparer<X>
的情形是一样的,所以对于 X
来说,是可以支持类型安全的逆变的(注意,是 X
,不是 T
)。根据 X
的逆变性: 如果 IBar<X> < IBar<X'>,则必有 X > X';
如果 IBar<X> > IBar<X'>,则必有 X < X';
[1] 因为 IFoo<T> 中的 T 是逆变的(根据 IFoo<in T> 接口定义),因此,
[2] 若 IFoo<T> > IFoo<T'>,则必有 T < T'
[3] 若 IBar<X> < IBar<X'>,
[4] 因为 IBar<X> 上的 X 支持类型安全的逆变,因此,
[5] 必有 X > X',即 IFoo<T> > IFoo<T'>
[6] 根据 [2] 中的结论, [7] 必有 T < T'
如果 IBar<X> < IBar<X'>,
必有 T < T'
IBar<X> < IBar<X'>
成立, T < T'
必须成立,也就意味着,如果把 T
作为 IBar
的泛型参数,那 T
只能支持类型安全的协变,而我们一开始的代码中, IBar<T>
中的 T
被标记为 in
(要求支持逆变),当然编译器就不答应了,如果我们把它改成 out
(要求支持协变),那编译器就没有意见了,因为根据前面的推导,这样是类型安全的。 public interface IFoo{ }public interface IBar { void Method(IFoo foo);}
T
直接作为输入参数时,那它就可以支持逆变,但如果 T
上被套了另一个操作,比如 IFoo<T>
,那可变性就会被扭转。所以,上面代码中的 in
和 out
互调位置后也可以编译通过。不过这对于方法返回值则不会有这种”扭转“。 IList<T>
接口的 T
即是不可变的,因为无法同时保证它的协变和逆变都是类型安全的。
原文链接
转载地址:http://zolzz.baihongyu.com/