协变和逆变的区别

170
我很难理解协变和逆变之间的区别。
6个回答

285
问题是“协变和逆变的区别是什么?”
协变和逆变是将一个集合的成员与另一个集合的成员关联起来的映射函数的属性。更具体地说,一个映射可以相对于该集合上的关系是协变或逆变。
考虑以下两个C#类型集合的子集。首先:
{ Animal, 
  Tiger, 
  Fruit, 
  Banana }.

第二,这是一组明显相关的内容:
{ IEnumerable<Animal>, 
  IEnumerable<Tiger>, 
  IEnumerable<Fruit>, 
  IEnumerable<Banana> }

第一个集合中存在从第一个集合到第二个集合的映射操作。也就是说,对于第一个集合中的每个T,其在第二个集合中相应的类型是IEnumerable<T>。或者简写为T → IE<T>。请注意,这是一个“细箭头”。

到目前为止,你明白了吗?

现在让我们考虑一个关系。第一个集合中的类型对之间存在一种赋值兼容关系。类型为Tiger的值可以赋值给类型为Animal的变量,因此这些类型被称为“赋值兼容”。让我们用更短的形式来写“类型为X的值可以赋值给类型为Y的变量”:X ⇒ Y。请注意,这是一个“粗箭头”。

因此,在我们的第一个子集中,这里是所有赋值兼容关系:

Tiger  ⇒ Tiger
Tiger  ⇒ Animal
Animal ⇒ Animal
Banana ⇒ Banana
Banana ⇒ Fruit
Fruit  ⇒ Fruit

在支持某些接口协变赋值兼容性的 C# 4 中,第二组类型中的一些类型之间存在赋值兼容关系。
IE<Tiger>  ⇒ IE<Tiger>
IE<Tiger>  ⇒ IE<Animal>
IE<Animal> ⇒ IE<Animal>
IE<Banana> ⇒ IE<Banana>
IE<Banana> ⇒ IE<Fruit>
IE<Fruit>  ⇒ IE<Fruit>

请注意,映射 T → IE<T> 保留了赋值兼容性的存在和方向。也就是说,如果 X ⇒ Y,那么 IE<X> ⇒ IE<Y> 也是正确的。

如果我们在一个粗箭头的两侧都有东西,那么我们可以用相应细箭头右侧的内容替换两侧的东西。

对于特定关系具有这种属性的映射称为“协变映射”。这应该是有道理的:一系列老虎可以用在需要一系列动物的地方,但反过来不成立。并不是所有的动物序列都可以用在需要一系列老虎的地方。

这就是协变性。现在考虑所有类型的子集:

{ IComparable<Tiger>, 
  IComparable<Animal>, 
  IComparable<Fruit>, 
  IComparable<Banana> }

现在我们有了从第一个集合到第三个集合的映射 T → IC<T>

在C# 4中:

IC<Tiger>  ⇒ IC<Tiger>
IC<Animal> ⇒ IC<Tiger>     Backwards!
IC<Animal> ⇒ IC<Animal>
IC<Banana> ⇒ IC<Banana>
IC<Fruit>  ⇒ IC<Banana>     Backwards!
IC<Fruit>  ⇒ IC<Fruit>

也就是说,映射 T → IC<T> 保留了赋值兼容性的存在但颠倒了方向。也就是说,如果 X ⇒ Y,那么 IC<X> ⇐ IC<Y>
保留但颠倒关系的映射称为逆变映射。
同样地,在C# 4中,这应该是明确无误的。可以比较两个动物的设备也可以比较两只老虎,但是可以比较两只老虎的设备不能必然比较任何两只动物。
因此,这就是C# 4中协变和逆变之间的区别。协变保留了可赋值性的方向。逆变则颠倒了它。

5
对于像我这样的人来说,最好添加一些示例,展示什么不是协变的、什么不是逆变的以及什么既不是协变也不是逆变的。 - bjan
3
很相似,不同之处在于C#使用“明确位置协变”而Java使用“调用位置协变”。因此,事物变化的方式是相同的,但开发人员说“我需要这个变量”时的位置不同。顺便说一下,在这两种语言中,这个功能部分是由同一个人设计的! - Eric Lippert
2
@AshishNegi:将箭头读作“可用作”。 “一个可以比较动物的东西可以用作可以比较老虎的东西。” 现在有意义了吗? - Eric Lippert
2
@AshishNegi:不,那不对。IEnumerable是协变的,因为T只出现在IEnumerable方法的返回值中。而IComparable是逆变的,因为T只出现在IComparable方法的形式参数中。 - Eric Lippert
5
@AshishNegi:你需要思考支撑这些关系的逻辑原因。为什么我们可以安全地将 IEnumerable<Tiger> 转换为 IEnumerable<Animal>?因为没有办法向 IEnumerable<Animal> 中输入一只长颈鹿。为什么我们可以将 IComparable<Animal> 转换为 IComparable<Tiger>?因为没有办法从 IComparable<Animal> 中取出一只长颈鹿。明白了吗? - Eric Lippert
显示剩余5条评论

124

最好通过示例来解释——这是我记住它们的方式。

协变性

经典示例:IEnumerable<out T>Func<out T>

您可以将 IEnumerable<string> 转换为 IEnumerable<object>,或将 Func<string> 转换为 Func<object>。值仅从这些对象中输出。

它之所以有效,是因为如果你只从 API 中取出值,并且它将返回某个特定的东西(如 string),那么你可以将返回的值视为更一般的类型(如 object)。

逆变性

经典示例:IComparer<in T>Action<in T>

您可以将 IComparer<object> 转换为 IComparer<string>,或将 Action<object> 转换为 Action<string>;值只能传递到这些对象中。

这次它有效,是因为如果该 API 需要一些通用的东西(如 object),则可以给它一些更具体的东西(如 string)。

更一般地说

如果您有一个接口 IFoo<T>,它可以在 T 上是协变的(即,在接口中只用于输出位置(例如返回类型)时将其声明为 IFoo<out T>)。如果 T 仅在输入位置(例如参数类型)中使用,则可以在 T 上逆变(即 IFoo<in T>)。

这可能会变得有点复杂,因为“输出位置”并不像听起来那样简单——类型为 Action<T> 的参数仍然只在输出位置使用 T——Action<T> 的逆变性使其相反,如果你知道我的意思的话。在这种情况下,“输出”是指值可以从方法实现中向调用方的代码传递,就像返回值一样。通常情况下不需要考虑这种问题:)


1
对于像我这样的人来说,最好添加一些示例,展示什么不是协变的、什么不是逆变的以及什么既不是协变也不是逆变的。 - bjan
1
@Jon Skeet 不错的例子,我只是不理解“类型为Action<T>的参数仍然只在输出位置使用T”。 Action<T>返回类型是void,它怎么能将T用作输出呢?或者这就是它的意思,因为它不返回任何东西,所以你可以看到它永远不会违反规则? - Alexander Derck
2
给我未来的自己,如果你再次回到这个优秀答案来重新学习区别,这是你想要的那一行:"[协变性]之所以有效,是因为如果你只从API中取值,并且它将返回特定的内容(比如字符串),你可以将该返回值视为更通用的类型(比如对象)"。 - Matt Klein
1
所有这些中最令人困惑的部分是,无论是协变还是逆变,如果忽略方向(输入或输出),你都会得到更具体到更通用的转换!我的意思是:对于协变,“你可以将返回值视为更一般的类型(如对象)”,而对于逆变,“API期望一些通用的东西(如对象),你可以给它一些更具体的东西(如字符串)”。对我来说,这些听起来有点相同! - XMight
@AlexanderDerck:不确定为什么之前没有回复你;我同意这很不清楚,会尝试澄清一下。 - Jon Skeet
这个比被接受的答案更好。 - undefined

17

我希望我的文章能够给你一个与语言无关的视角。

在我们内部的培训中,我使用了一本精妙的书籍《Smalltalk, Objects and Design (Chamond Liu)》,并重新构思了以下例子。

“一致性”是什么意思?这个概念是指设计具有高度可替换类型的类型安全类型层次结构。如果您使用静态类型语言,则实现此一致性的关键是基于子类型的一致性(我们将在此高层面上讨论里氏替换原则(LSP))。

实践示例(伪代码/在 C# 中无效):

  • 协变(Covariance):假设鸟会“一致地”下蛋,并使用静态类型:如果 Bird 类型会下蛋,那么 Bird 的子类型是否会下更专门的蛋?例如,Duck 类型会下鸭蛋,因此就具有了一致性。为何是一致的呢?因为在此类表达式中:Egg anEgg = aBird.Lay();引用 aBird 可以被合法地替换为 Bird 或 Duck 实例。我们说返回类型对于定义 Lay() 方法的类型是协变的。子类型的覆盖方法可以返回更专门的类型。 =>“它们提供更多。”

  • 逆变(Contravariance):假设钢琴是演奏家可以演奏的乐器,并使用静态类型:如果一个演奏家可以演奏钢琴,那么他能演奏大钢琴吗?难道不应该是技艺精湛的演奏家演奏大钢琴吗?(请注意;这里有一个转折!)这是不一致的!因为在此类表达式中:aPiano.Play(aPianist); aPiano 不能被合法地替换为 Piano 或 GrandPiano 实例!大钢琴只能由技艺精湛的演奏家演奏,普通的演奏家太笼统了!大钢琴必须能够被更一般的类型演奏,才能保持一致。我们说参数类型对于定义 Play() 方法的类型是逆变的。子类型的覆盖方法可以接受更一般的类型。=>“它们需要更少。”

回到 C#:
因为 C# 是一种静态类型的语言,一个类型的接口中需要协变或者逆变的“位置”(比如参数和返回值)必须明确地标注出来,以保证该类型在使用/开发时的一致性,从而使LSP能够正常工作。在动态类型语言中,LSP的一致性通常不是问题。换句话说,如果您只在类型中使用了 dynamic 类型,完全可以完全摆脱 .Net 接口和委托中的协变和逆变“标记”。但是,在 C# 中这不是最好的解决方案(您不应该在公共接口中使用 dynamic)。

回到理论:
所描述的符合性(协变返回类型/逆变参数类型)是理论上的理想情况(由 Emerald 和 POOL-1 语言支持)。一些面向对象语言(例如 Eiffel)决定应用另一种类型的一致性,尤其是协变参数类型,因为它更好地描述了现实情况,而不是理论上的理想情况。 在静态类型的语言中,通常需要通过设计模式(如“双重分派”和“访问者”)来实现所需的一致性。其他语言提供所谓的“多分配”或者多方法(这基本上是在运行时选择函数重载,例如使用 CLOS),或者通过使用动态类型来实现所需的效果。

你说A subtype's override may return a more specialized type,但这完全不正确。如果Bird定义了public abstract BirdEgg Lay();,那么Duck : Bird 必须 实现public override BirdEgg Lay(){}。所以你断言BirdEgg anEgg = aBird.Lay();有任何形式的差异是完全不正确的。作为解释点的前提,整个观点现在已经消失了。你能否改而说协变存在于实现中,其中一个DuckEgg被隐式转换为BirdEgg的输出/返回类型?无论哪种方式,请澄清我的困惑。 - Suamere
1
简而言之:你是对的!抱歉造成困惑。在C#中,DuckEgg Lay()不是Egg Lay()的有效重写,这是关键所在。C#不支持协变返回类型,但Java和C++都支持。我使用类似于C#的语法描述了理论上的理想情况。在C#中,您需要让Bird和Duck实现一个公共接口,在其中定义Lay具有协变返回(即out-specification)类型,然后问题就解决了! - Nico
1
作为对Matt-Klein在@Jon-Skeet的回答中的评论的类比,"给我的未来自己": 对我来说最好的收获是"他们提供更多"(具体)和"他们需要更少"(具体)。"需要更少,提供更多"是一个很好的记忆口诀!这类似于一份工作,我希望需要更少的具体说明(一般请求),但能够提供更具体的东西(实际的工作成果)。无论哪种方式,子类型的顺序(LSP)都不会被打破。 - karfus
@karfus:谢谢,但我记得我是从另一个来源改编了“要求更少,提供更多”的想法。可能是我上面提到的刘的书......或者甚至是.NET Rock的讲话。顺便说一下,在Java中,人们将助记符缩减为“PECS”,它直接关系到声明差异的语法方式,PECS代表“生产者extends,消费者super”。 - Nico

9

协变和逆变是非常符合逻辑的事情。语言类型系统强制我们支持现实生活中的逻辑。通过例子很容易理解。

协变

例如,你想买一朵花,在你所在的城市有两家花店:玫瑰店和雏菊店。

如果你问别人“花店在哪里?”,然后有人告诉你玫瑰店在哪里,这样可以吗?可以,因为玫瑰是花,如果你想买一朵花,你可以买一朵玫瑰花。同样的道理也适用于有人回答你让你去雏菊店的地址。

这就是协变的例子:如果A产生泛型值(从函数返回),则允许将A<C>强制转换为A<B>,其中CB的子类。协变是关于生产者的,这就是为什么C#使用关键字out来表示协变。

类型:

class Flower {  }
class Rose: Flower { }
class Daisy: Flower { }

interface FlowerShop<out T> where T: Flower {
    T getFlower();
}

class RoseShop: FlowerShop<Rose> {
    public Rose getFlower() {
        return new Rose();
    }
}

class DaisyShop: FlowerShop<Daisy> {
    public Daisy getFlower() {
        return new Daisy();
    }
}

问题是“花店在哪里?”,答案是“玫瑰店就在那里”。
static FlowerShop<Flower> tellMeShopAddress() {
    return new RoseShop();
}

逆变性

例如,你想送花给你的女朋友,而你的女朋友喜欢所有的花。你会把她视为喜欢玫瑰花的人,还是喜欢雏菊花的人?两者都可以,因为如果她喜欢所有的花,她会喜欢玫瑰和雏菊。

这是逆变性的一个例子:如果 A 消耗泛型值,那么可以将 A<B> 强制转换为 A<C>,其中 CB 的子类。逆变性关注消费者,因此 C# 使用关键字 in 表示逆变性。

类型:

interface PrettyGirl<in TFavoriteFlower> where TFavoriteFlower: Flower {
    void takeGift(TFavoriteFlower flower);
}

class AnyFlowerLover: PrettyGirl<Flower> {
    public void takeGift(Flower flower) {
        Console.WriteLine("I like all flowers!");
    }
}

你认为你的女友喜欢任何一种花,但是你想把玫瑰花送给她:

PrettyGirl<Rose> girlfriend = new AnyFlowerLover();
girlfriend.takeGift(new Rose());

链接


8
转换器委托帮助我理解它们之间的区别。
delegate TOutput Converter<in TInput, out TOutput>(TInput input);

TOutput代表一个方法返回协变类型,这种类型更加具体。

TInput代表一个方法接受逆变类型,这种类型比较抽象。

public class Dog { public string Name { get; set; } }
public class Poodle : Dog { public void DoBackflip(){ System.Console.WriteLine("2nd smartest breed - woof!"); } }

public static Poodle ConvertDogToPoodle(Dog dog)
{
    return new Poodle() { Name = dog.Name };
}

List<Dog> dogs = new List<Dog>() { new Dog { Name = "Truffles" }, new Dog { Name = "Fuzzball" } };
List<Poodle> poodles = dogs.ConvertAll(new Converter<Dog, Poodle>(ConvertDogToPoodle));
poodles[0].DoBackflip();

-1
考虑一个组织中有两个职位。Alice 是椅子的计数员,而 Bob 是同样椅子的库管。
逆变性。现在我们不能称 Bob 为家具的库管,因为他不会把桌子放到他的库房里,他只存储椅子。但是我们可以称他为紫色椅子的库管,因为紫色的椅子也是椅子。这是 IBookkeeper<in T>,我们允许分配给更具体的类型,而不是更少的类型。 in 表示数据流入对象。
协变性。相反,我们可以称 Alice 为家具的计数员,因为这不会影响她的角色。但是我们不能称她为红色椅子的计数员,因为我们期望她不计算非红色的椅子,但她却计算了它们。这是 ICounter<out T>,允许隐式转换为不太具体的类型,而不是更具体的类型。 out 表示数据从对象流出。
而不变性是当我们两者都不能做到时。

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