C#中的协变有哪些种类?(或者说,通过示例来解释协变)

11

协变性大致上是指,在使用“简单”类型的复杂类型中,反映继承关系的能力。
例如,我们总是可以将Cat的实例视为Animal的实例。如果ComplexType是协变的,那么ComplexType<Cat>可以被视为ComplexType<Animal>

我想知道:协变性的“类型”有哪些,它们与C#有什么关系(是否支持)?
代码示例会很有帮助。

例如,一种类型是返回类型协变性,Java支持,但C#不支持。

我希望有函数式编程经验的人也能参与讨论!


我认为你混淆了两个不同(但相似)的概念。一个叫做协变性,另一个叫做逆变性。 - Aron
1
@Nik 更正。几乎所有位于System.Generic.Collections命名空间中的.NET接口都是协变的。 - Aron
这里有一篇关于协变性和逆变性的不错的MSDN文章。在这里 - Romano Zumbé
5
你可能想阅读我在设计加入到C# 4中的协变性和逆变性特性时所写的一系列长文。这些文章按时间顺序排列,从最近到最早,请从底部开始阅读。链接为:http://blogs.msdn.com/b/ericlippert/archive/tags/covariance+and+contravariance/default.aspx - Eric Lippert
可能是Covariance and contravariance real world example的重复问题。 - p.s.w.g
显示剩余6条评论
3个回答

10

以下是我的思考:

更新

通过阅读Eric Lippert指出并撰写的许多文章以及建设性的评论,我改进了回答:

  • 更新数组协变性问题
  • 添加“纯”委托协变性
  • 从BCL添加更多示例
  • 添加解释这些概念的文章链接。
  • 新增有关高阶函数参数协变性的整个新部分。

返回类型协变性

Java(>=5)[1]和C++支持[2],但不支持C#(Eric Lippert说明了为什么不支持,以及你可以怎么做):

class B {
    B Clone();
}

class D: B {
    D Clone();
}

接口协变[3] - 受 C# 支持

BCL 将通用的 IEnumerable 接口定义为协变接口:

IEnumerable<out T> {...}

因此,以下示例是有效的:
class Animal {}
class Cat : Animal {}

IEnumerable<Cat> cats = ...
IEnumerable<Animal> animals = cats;

请注意,按照定义,IEnumerable 是“只读”的 - 无法向其中添加元素。
与之相反的是 IList<T> 的定义,可以通过使用 .Add() 进行修改:
public interface IEnumerable<out T> : ...  //covariant - notice the 'out' keyword
public interface IList<T> : ...            //invariant

委托协变通过方法组实现[4] - 在C#中得到支持。

class Animal {}
class Cat : Animal {}

class Prog {
    public delegate Animal AnimalHandler();

    public static Animal GetAnimal(){...}
    public static Cat GetCat(){...}

    AnimalHandler animalHandler = GetAnimal;
    AnimalHandler catHandler = GetCat;        //covariance

}

“纯”委托协变性[5 - 预泛型版本发布的文章]- 在C#中支持。

BCL对于不带参数并返回某个值的委托定义是协变的:

public delegate TResult Func<out TResult>()

这将允许以下操作:
Func<Cat> getCat = () => new Cat();
Func<Animal> getAnimal = getCat; 
数组的协变 - 在C#中支持,但以一种有缺陷的方式[6][7]
string[] strArray = new[] {"aa", "bb"};

object[] objArray = strArray;    //covariance: so far, so good
//objArray really is an "alias" for strArray (or a pointer, if you wish)


//i can haz cat?
object cat == new Cat();         //a real cat would object to being... objectified.

//now assign it
objArray[1] = cat                //crash, boom, bang
                                 //throws ArrayTypeMismatchException

最后 - 令人惊讶且有些令人费解的“委托参数协变性”(是的,这是协变性)- 用于高阶函数。[8] BCL定义的委托接受一个参数且返回空值是逆变的。
public delegate void Action<in T>(T obj)

请耐心等待。让我们定义一下马戏团动物训练师 - 他可以被告知如何训练动物(通过给他一个与该动物配合的Action)。

delegate void Trainer<out T>(Action<T> trainingAction);

我们有了教练的定义,现在让我们找一名教练并让他开始工作。
Trainer<Cat> catTrainer = (catAction) => catAction(new Cat());

Trainer<Animal> animalTrainer = catTrainer;  
// covariant: Animal > Cat => Trainer<Animal> > Trainer<Cat> 

//define a default training method
Action<Animal> trainAnimal = (animal) => 
   { 
   Console.WriteLine("Training " + animal.GetType().Name + " to ignore you... done!"); 
   };

//work it!
animalTrainer(trainAnimal);

输出证明这个有效:
“训练猫不理你……完成!”
为了理解这个,我们需要一个笑话。
“一位语言学教授有一天正在给他的学生上课。'在英语中,'他说,'双重否定构成肯定。但是,'他指出,'没有一种语言可以使双倍的肯定形成否定。'”
教室后面传来一个声音,“没错。”
这和协方差有什么关系呢?
让我试着用餐巾纸胡乱演示一下。
Action<T>是逆变的,即它会“翻转”类型之间的关系:
A < B => Action<A> > Action<B> (1)

将上面的AB替换为Action<A>Action<B>,得到:

Action<A> < Action<B> => Action<Action<A>> > Action<Action<B>>  

or (flip both relationships)

Action<A> > Action<B> => Action<Action<A>> < Action<Action<B>> (2)     

把(1)和(2)结合起来,我们得到:
,-------------(1)--------------.
 A < B => Action<A> > Action<B> => Action<Action<A>> < Action<Action<B>> (4)
         `-------------------------------(2)----------------------------'

但是我们的 Trainer<T> 委托实际上是一个 Action<Action<T>>:

Trainer<T> == Action<Action<T>> (3)

因此,我们可以将(4)重写为:
A < B => ... => Trainer<A> < Trainer<B> 

- 按照定义,Trainer是协变的。 - 简而言之,应用Action两次会得到反-反-协变,即类型之间的关系被翻转了两次(见(4)),因此我们回到了协变。

3
关于数组协变性的说明:如果您添加第三行objArray[1] = new object();,虽然在编译时是合法的,但在运行时会抛出异常。从这个意义上讲,与其他示例不同,数组协变性是有问题的。详情请参阅此链接:http://blogs.msdn.com/b/ericlippert/archive/2007/10/17/covariance-and-contravariance-in-c-part-two-array-covariance.aspx - Chris Sinclair
2
好的列表。我会做一个小改变,注明从C# 4开始有两种委托协变。你所说的那种我通常称为方法组协变。委托协变允许delegate R D<R>(); ... D<Banana> db = ()=>new Banana(); D<Fruit> df = db; - Eric Lippert

3

这最好通过更通用的结构类型来解释。考虑以下内容:

  1. 元组类型:(T1,T2),包含类型T1和T2的一对(或更普遍的n元组);
  2. 函数类型:T1 -> T2,具有参数类型T1和结果T2的函数;
  3. 可变类型:Mut(T),持有T的可变变量。

元组在其组件类型上是协变的,即 (T1,T2) < (U1,U2) 当且仅当 T1 < U1 且 T2 < U2(其中“<”表示子类型)。

函数在结果方面是协变的,在参数方面是逆变的,即 (T1 -> T2) < (U1 -> U2) 当且仅当 U1 < T1 且 T2 < U2。

可变类型是不变的,即仅当 T = U 时 Mut(T) < Mut(U)。

所有这些规则都是最常见的正确子类型化规则。

现在,像主流语言中所熟知的对象或接口类型可以被解释为一种奇特的元组形式,其中包含其方法作为函数,以及其他东西。例如,接口

interface C<T, U, V> {
  T f(U, U)
  Int g(U)
  Mut(V) x
}

基本上代表着该类型。
C(T, U, V) = ((U, U) -> T, U -> Int, Mut(V))

f、g 和 x 分别对应元组的第一、第二和第三个组成部分。

根据上述规则可知,如果 T < T' 且 U' < U 且 V = V',则 C(T, U, V) < C(T', U', V')。这意味着通用类型 C 在 T 上是协变的,在 U 上是逆变的,在 V 上是不变的。

另一个例子:

interface D<T> {
  Int f(T)
  T g(Int)
}

D(T) = (T -> Int, Int -> T)

在这里,只有当 T < T' and T' < T 时,D(T) < D(T')。通常情况下,只有当T = T'时才会出现这种情况,因此D实际上在T中是不变的。

还有第四种情况,有时被称为“双变性”,即同时具有协变和逆变。例如:

interface E<T> { Int f(Int) }

在T中是双变的,因为它实际上并未被使用。

1
Java采用“使用点变异”概念来处理泛型类型:所需的变异在每个使用点上指定。这就是为什么Java程序员需要熟悉所谓的PECS规则。是的,它很笨拙,并已经受到了大量批评。

1
有趣。这个SO问题和链接的文章描述了使用点变异(Java)和声明点变异(C#)之间的区别。 - Cristian Diaconescu
谢谢链接,Cristi,这是最好的SO答案之一! - Marko Topolnik

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接