仍然困惑于协变和逆变以及in/out的含义

66

我在stackoverflow这个主题上读了一些内容,观看了这个这个,但对于协变性/逆变性仍然有点困惑。

来自这里

协变性允许将一个“更大”(不太具体)的类型用作API中原始类型仅用于“输出”位置(例如作为返回值)的替代。逆变性允许在API中使用原始类型仅用于“输入”位置的情况下使用“更小”(更具体)的类型进行替换。

我知道它与类型安全有关。

关于in/out的事情。我可以说当我需要写入时使用in,并且当只读时使用out。而in表示逆变性,out表示协变性。但是从上面的解释...

这里

例如,一个List<Banana>不能被视为List<Fruit>,因为list.Add(new Apple())对List是有效的,但对于List<Banana>则不是。

所以,如果我要使用 in / 写入对象,那么它不应该更大、更通用吗?

我知道这个问题已经被问过了,但仍然很困惑。


2
我也感到困惑,在维基百科上阅读了相关内容后,我觉得我需要先学习实分析1和2,然后是测度论、向量空间,最后才能学习范畴论,以便更好地理解它。或者,如果有人提供了大量的例子,我可能可以将学习时间从5个学期缩短到1个月。 - Hamish Grubijan
1
请不要在这里使用缩写。想法是用英语书写,而不是使用类似于“abt”和“abit”的东西。 - John Saunders
这里有一个很好的解释:https://devedium.com/better-understanding-covariance-and-contravariance-in-c-37c8b7ed36d5 - liuhongbo
7个回答

83

我必须仔细思考如何很好地解释这个问题。解释似乎和理解一样困难。

想象你有一个基类 Fruit。你有两个子类 Apple 和 Banana。

     Fruit
      / \
Banana   Apple

您需要创建两个对象:

Apple a = new Apple();
Banana b = new Banana();

对于这两个对象,您可以将它们强制转换为Fruit对象。

Fruit f = (Fruit)a;
Fruit g = (Fruit)b;

您可以将派生类视为其基类。
但是,您不能将基类视为派生类。
a = (Apple)f; //This is incorrect

让我们将其应用到列表示例中。
假设您创建了两个列表:
List<Fruit> fruitList = new List<Fruit>();
List<Banana> bananaList = new List<Banana>();

您可以这样做...
fruitList.Add(new Apple());

并且

fruitList.Add(new Banana());

因为将它们添加到列表中时,实际上是对它们进行类型转换。你可以这样想...
fruitList.Add((Fruit)new Apple());
fruitList.Add((Fruit)new Banana());

然而,将相同的逻辑应用于反向情况会引起一些问题。
bananaList.Add(new Fruit());

与……相同

bannanaList.Add((Banana)new Fruit());

由于您不能像处理派生类一样处理基类,因此会产生错误。

以防您的问题是为什么会导致错误,我也会解释一下。

这是水果类(Fruit class)

public class Fruit
{
    public Fruit()
    {
        a = 0;
    }
    public int A { get { return a; } set { a = value } }
    private int a;
}

这里是香蕉类

public class Banana: Fruit
{
   public Banana(): Fruit() // This calls the Fruit constructor
   {
       // By calling ^^^ Fruit() the inherited variable a is also = 0; 
       b = 0;
   }
   public int B { get { return b; } set { b = value; } }
   private int b;
}

假设你创建了两个对象

Fruit f = new Fruit();
Banana ba = new Banana();

请记住,香蕉有两个变量 "a" 和 "b",而水果只有一个变量 "a"。所以当你进行以下操作时...

f = (Fruit)b;
f.A = 5;

你需要创建一个完整的Fruit对象。 但如果你这样做...
ba = (Banana)f;
ba.A = 5;
ba.B = 3; //Error!!!: Was "b" ever initialized? Does it exist?

问题在于你没有创建完整的香蕉类。并非所有的数据成员都被声明/初始化。现在我从淋浴中回来并拿了一点零食,这里就变得有点复杂了。事后看来,我应该在涉及复杂内容时放弃比喻。让我们创建两个新类:
public class Base
public class Derived : Base

他们可以做任何你想要的事情。
现在让我们定义两个函数。
public Base DoSomething(int variable)
{
    return (Base)DoSomethingElse(variable);
}  
public Derived DoSomethingElse(int variable)
{
    // Do stuff 
}

这有点像“out”如何工作,你应该总是能够像使用基类一样使用派生类,让我们将其应用到接口中。

interface MyInterface<T>
{
    T MyFunction(int variable);
}

out/in的关键区别在于,当Generic作为返回类型或方法参数时,即为前者情况。

让我们定义一个实现该接口的类:

public class Thing<T>: MyInterface<T> { }

然后我们创建两个对象:
MyInterface<Base> base = new Thing<Base>;
MyInterface<Derived> derived = new Thing<Derived>;

如果你要做这件事:
base = derived;

如果您遇到“无法隐式转换”的错误,您有两个选择:1)显式转换它们或2)告诉编译器隐式转换它们。

base = (MyInterface<Base>)derived; // #1

或者
interface MyInterface<out T>  // #2
{
    T MyFunction(int variable);
}

第二种情况是当您的界面类似于以下内容时:
interface MyInterface<T>
{
    int MyFunction(T variable); // T is now a parameter
}

将其再次与两个功能相关联。
public int DoSomething(Base variable)
{
    // Do stuff
}  
public int DoSomethingElse(Derived variable)
{
    return DoSomething((Base)variable);
}

希望你能看到情况已经反转,但本质上是相同类型的转换。 再次使用相同的类。
public class Base
public class Derived : Base
public class Thing<T>: MyInterface<T> { }

相同的对象

MyInterface<Base> base = new Thing<Base>;
MyInterface<Derived> derived = new Thing<Derived>;

如果您尝试将它们设置为相等

base = derived;

如果编译器再次报错,您有与之前相同的选项。

base = (MyInterface<Base>)derived;

或者

interface MyInterface<in T> //changed
{
    int MyFunction(T variable); // T is still a parameter
}

通常情况下,当泛型只用作接口方法的返回类型时,请使用“out”;当它将用作方法参数时,请使用“in”。在使用委托时也适用相同的规则。

虽然有一些奇怪的例外,但我不会在这里担心它们。

提前对任何粗心的错误表示抱歉 =)


我对Java不是很熟悉,但是List<Banana> bananaList = new Banana();应该改为List<Banana> bananaList = new List<Banana>();,对吗? - Tyler
5
在那种情况下,你实际上可以说 a = (Apple)f。对于那篇没有触及原帖问题的错误帖子扣1分。还有就是减少使用 aba.a - Blindy
3
我不太擅长C#,但这不是C#吗? - Wes Field
1
我认为 f = (Fruit)b; 应该改为 f = (Fruit)ba; - James Wilkins
我认为这个答案可以从一些漂亮的大胆标题中受益,以分隔各个部分。在我跟着做的时候,直到解释为什么你不能使用基本类型作为派生类型,我就迷失了,因为我已经知道为什么了,所以试图跳过那部分。 - Marie
在第一个解决方案中,该方法返回T类型并接受int值,因此使用了“out”关键字,因为重要的类型必须是T,并且它对我的代码起作用。 但是第二个解决方案接受T类型并返回int值,所以重要的是接受所使用的类型,使用了“in”关键字。但它在我的代码中无法正常运行,我不理解这一部分。显式转换可以工作,但“in”关键字不起作用。 - Çağlar Can Sarıkaya

65

在C# 4.0中,协变和逆变都指的是可以使用派生类而非基类的能力。in/out关键字是编译器提示,用于指示类型参数是否用于输入和输出。

协变

C# 4.0中的协变通过out关键字得到支持,意味着可以使用out类型参数的派生类来代替泛型类型。因此,

IEnumerable<Fruit> fruit = new List<Apple>();

由于苹果是一种水果,因此可以安全地使用List<Apple>作为IEnumerable<Fruit>

逆变性

逆变性是in关键字,通常用于委托的输入类型。原则相同,它意味着委托可以接受更多派生类。

public delegate void Func<in T>(T param);

这意味着如果我们有一个 Func<Fruit>,它可以转换为 Func<Apple>
Func<Fruit> fruitFunc = (fruit)=>{};
Func<Apple> appleFunc = fruitFunc;

为什么它们被称为协变/逆变,如果它们基本上是相同的东西?

因为尽管原则相同,从派生类型到基类型的安全转换,但当用于输入类型时,我们可以将一个不那么派生的类型 (Func<Fruit>) 安全地转换为一个更派生的类型 (Func<Apple>),这是有道理的,因为任何接受 Fruit 的函数也可以接受 Apple


1
你描述的委托应该是 public delegate void Func<in T>(T param);,对吧? ;) - Jeff Mercado
13
最好称之为“Action”。在那里使用“Func”会令人感到困惑 :) - nawfal

38

让我分享一下我的看法。

免责声明:忽略空赋值,我使用它们来使代码相对较短,并且它们足以让我们看到编译器想要告诉我们的内容。

让我们从类的层次结构开始:

class Animal { }

class Mammal : Animal { }

class Dog : Mammal { }

现在定义一些接口,以说明 inout 泛型修饰符实际上是做什么的:

interface IInvariant<T>
{
    T Get(); // ok, an invariant type can be both put into and returned
    void Set(T t); // ok, an invariant type can be both put into and returned
}

interface IContravariant<in T>
{
    //T Get(); // compilation error, cannot return a contravariant type
    void Set(T t); // ok, a contravariant type can only be **put into** our class (hence "in")
}

interface ICovariant<out T>
{
    T Get(); // ok, a covariant type can only be **returned** from our class (hence "out")
    //void Set(T t); // compilation error, cannot put a covariant type into our class
}

好的,那么如果它们限制我们,为什么要使用带有inout修饰符的接口呢?让我们看一下:


不变性

让我们从不变性(没有in,没有out修饰符)开始

不变性实验

考虑IInvariant<Mammal>

  • IInvariant<Mammal>.Get() - 返回一个哺乳动物
  • IInvariant<Mammal>.Set(Mammal) - 接受一个哺乳动物

如果我们尝试:IInvariant<Mammal> invariantMammal = (IInvariant<Animal>)null

  • 调用IInvariant<Mammal>.Get()的人期望得到一个哺乳动物,但是IInvariant<Animal>.Get()返回一个动物。并非每个动物都是哺乳动物,因此它们是不兼容的
  • 调用IInvariant<Mammal>.Set(Mammal)的人期望可以传递一个哺乳动物。由于IInvariant<Animal>.Set(Animal)接受任何动物(包括哺乳动物),因此它是兼容的
  • 结论:这样的赋值是不兼容的

如果我们尝试:IInvariant<Mammal> invariantMammal = (IInvariant<Dog>)null

  • 调用IInvariant<Mammal>.Get()的人期望得到一个哺乳动物,IInvariant<Dog>.Get()返回一个,每只狗都是哺乳动物,因此它们是兼容的
  • 调用IInvariant<Mammal>.Set(Mammal)的人期望可以传递一个哺乳动物。由于IInvariant<Dog>.Set(Dog)仅接受(而不是每只哺乳动物都是狗),因此它是不兼容的
  • 结论:这样的赋值是不兼容的

让我们检查一下我们是否正确

IInvariant<Animal> invariantAnimal1 = (IInvariant<Animal>)null; // ok
IInvariant<Animal> invariantAnimal2 = (IInvariant<Mammal>)null; // compilation error
IInvariant<Animal> invariantAnimal3 = (IInvariant<Dog>)null; // compilation error

IInvariant<Mammal> invariantMammal1 = (IInvariant<Animal>)null; // compilation error
IInvariant<Mammal> invariantMammal2 = (IInvariant<Mammal>)null; // ok
IInvariant<Mammal> invariantMammal3 = (IInvariant<Dog>)null; // compilation error

IInvariant<Dog> invariantDog1 = (IInvariant<Animal>)null; // compilation error
IInvariant<Dog> invariantDog2 = (IInvariant<Mammal>)null; // compilation error
IInvariant<Dog> invariantDog3 = (IInvariant<Dog>)null; // ok

这一点很重要:值得注意的是,根据泛型类型参数在类层次结构中的高低,泛型类型本身有不同的原因不兼容

好的,那么让我们来看看如何利用它。


协变性 (out)

当您使用out泛型修饰符时(请参见上文),您就拥有了协变性。

如果我们的类型看起来像这样:ICovariant<Mammal>,它声明了两件事:

  • 我的一些方法返回一个Mammal(因此有out泛型修饰符)- 这很无聊
  • 我的所有方法都不接受Mammal - 这很有趣,因为这是out泛型修饰符所强加的实际限制

我们如何从out修饰符的限制中获益?回顾上面的“不变实验”的结果。现在尝试看看当我们进行协变性实验时会发生什么?

协变性实验

如果我们尝试:ICovariant<Mammal> covariantMammal = (ICovariant<Animal>)null,会怎样呢?

  • 调用ICovariant<Mammal>.Get()的人期望得到一个Mammal,但是ICovariant<Animal>.Get()返回一个Animal。并非所有的Animal都是Mammal,因此它们不兼容
  • ICovariant.Set(Mammal) - 这不再是一个问题,感谢out修饰符的限制!
  • 结论:这种赋值是不兼容的

如果我们尝试:ICovariant<Mammal> covariantMammal = (ICovariant<Dog>)null,会怎样呢?

  • 调用ICovariant<Mammal>.Get()的人期望得到一个Mammal,ICovariant<Dog>.Get()返回一个Dog,每个Dog都是Mammal,因此它们兼容
  • ICovariant.Set(Mammal) - 这不再是一个问题,感谢out修饰符的限制!
  • 结论:这种赋值是兼容的

让我们通过代码来确认一下:

ICovariant<Animal> covariantAnimal1 = (ICovariant<Animal>)null; // ok
ICovariant<Animal> covariantAnimal2 = (ICovariant<Mammal>)null; // ok!!!
ICovariant<Animal> covariantAnimal3 = (ICovariant<Dog>)null; // ok!!!

ICovariant<Mammal> covariantMammal1 = (ICovariant<Animal>)null; // compilation error
ICovariant<Mammal> covariantMammal2 = (ICovariant<Mammal>)null; // ok
ICovariant<Mammal> covariantMammal3 = (ICovariant<Dog>)null; // ok!!!

ICovariant<Dog> covariantDog1 = (ICovariant<Animal>)null; // compilation error
ICovariant<Dog> covariantDog2 = (ICovariant<Mammal>)null; // compilation error
ICovariant<Dog> covariantDog3 = (ICovariant<Dog>)null; // ok

逆变性 (in)

当您使用 in 泛型修饰符时,就会出现逆变性(请参见上文)。

如果我们的类型看起来像这样:IContravariant<Mammal>,它声明了两件事:

  • 我的某些方法接受 Mammal(因此具有 in 泛型修饰符)- 这很无聊
  • 我的任何方法都不返回 Mammal - 尽管如此,这仍然很有趣,因为这是 in 泛型修饰符 强加的实际限制

逆变性实验

如果我们尝试:IContravariant<Mammal> contravariantMammal = (IContravariant<Animal>)null

  • IContravariant<Mammal>.Get() - 由于 in 修饰符的限制,这不再是问题!
  • 调用 IContravariant<Mammal>.Set(Mammal) 的人期望可以传递 Mammal。由于 IContravariant<Animal>.Set(Animal) 接受任何 Animal(包括 Mammal),因此它是兼容的
  • 结论:这样的赋值是兼容的

如果我们尝试:IContravariant<Mammal> contravariantMammal = (IContravariant<Dog>)null

  • IContravariant<Mammal>.Get() - 由于 in 修饰符的限制,这不再是问题!
  • 调用 IContravariant<Mammal>.Set(Mammal) 的人期望可以传递 Mammal。由于 IContravariant<Dog>.Set(Dog) 仅接受 Dogs(而不是每个 Mammal 都是 Dog),因此它是不兼容的
  • 结论:这样的赋值是不兼容的

让我们通过代码进行确认:

IContravariant<Animal> contravariantAnimal1 = (IContravariant<Animal>)null; // ok
IContravariant<Animal> contravariantAnimal2 = (IContravariant<Mammal>)null; // compilation error
IContravariant<Animal> contravariantAnimal3 = (IContravariant<Dog>)null; // compilation error

IContravariant<Mammal> contravariantMammal1 = (IContravariant<Animal>)null; // ok!!!
IContravariant<Mammal> contravariantMammal2 = (IContravariant<Mammal>)null; // ok
IContravariant<Mammal> contravariantMammal3 = (IContravariant<Dog>)null; // compilation error

IContravariant<Dog> contravariantDog1 = (IContravariant<Animal>)null; // ok!!!
IContravariant<Dog> contravariantDog2 = (IContravariant<Mammal>)null; // ok!!!
IContravariant<Dog> contravariantDog3 = (IContravariant<Dog>)null; // ok

顺便说一句,这感觉有点违反直觉,不是吗?

// obvious
Animal animal = (Dog)null; // ok
Dog dog = (Animal)null; // compilation error, not every Animal is a Dog

// but this looks like the other way around
IContravariant<Animal> contravariantAnimal = (IContravariant<Dog>) null; // compilation error
IContravariant<Dog> contravariantDog = (IContravariant<Animal>) null; // ok

为什么不两个都用呢?

我们能同时使用inout泛型修饰符吗?——很明显不能

为什么呢?回想一下,inout修饰符有哪些限制。如果我们想要让我们的泛型类型参数既是协变的又是逆变的,我们基本上会说:

  • 我们接口的所有方法均不返回T
  • 我们接口的所有方法均不接收T

这将使我们的泛型接口实际上非泛型化

如何记忆?

你可以使用我的技巧:)

  1. “covariant”比“contravaraint”短,这与它们的修饰符长度相反(分别是“out”和“in”)
  2. contravaraint有点违反直觉(见上面的例子)

3
我总是在抽象语法中理解代码范例时遇到问题,往往会迷失其中。因此,您的解释是一种新鲜的方法,也是我第一个完全理解和掌握的方法,非常完美!谢谢!您应该写书! - bradbury
很好的解释! - MultiValidation
1
一个很好的例子是“如何逐步解释复杂概念?”@andrzej,你一定是一个很棒的讲故事者 :) - Parag Meshram
哈哈,很妙的技巧 :) - Çağlar Can Sarıkaya
这是一个非常好的回答。它值得更多的赞同。它结构良好,能够逐步引导你理解。谢谢 :) - Varvara Kalinina

7

协变很容易理解,它是自然而然的。而逆变更加令人困惑。

请仔细查看MSDN上的示例。可以看到SortedList期望一个IComparer,但他们传入了ShapeAreaComparer : IComparer。Shape是“较大”的类型(它在被调用者的签名中,而不是调用者的签名中),但逆变允许“较小”的类型——Circle替代ShapeAreaComparer中通常需要Shape的任何位置。

希望这能帮到你。


5
在乔恩的话中:
协变性允许在 API 中替代原始类型只用于“输出”位置(例如作为返回值)的“更大”(不太具体)类型。而逆变性则允许在 API 中替代原始类型只用于“输入”位置的“更小”(更具体)类型。
起初,我觉得他的解释有些混乱,但一旦强调了“被替代”,并结合 C# 编程指南中的示例,这种说法就变得容易理解了。
// Covariance.   
IEnumerable<string> strings = new List<string>();  
// An object that is instantiated with a more derived type argument   
// is assigned to an object instantiated with a less derived type argument.   

// Assignment compatibility is preserved.   
IEnumerable<object> objects = strings;

// Contravariance.             
// Assume that the following method is in the class:   
// static void SetObject(object o) { }   
Action<object> actObject = SetObject;  
// An object that is instantiated with a less derived type argument   
// is assigned to an object instantiated with a more derived type argument.   

// Assignment compatibility is reversed.   
Action<string> actString = actObject;    

转换委托帮助我理解它:
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();

嗯,我不太明白这个代理示例如何完整地说明协变性和逆变性的整个概念。我已经有一段时间没有使用C#了,可能我错了,但在我看来,一个更好的例子是当一个期望Converter<Dog, Poodle>作为参数的函数接收到类似Converter<Object, PoodleSubClass>的东西作为参数时。 - Varvara Kalinina

5
在开始讨论主题前,让我们快速复习一下:
基类引用可以持有派生类对象,但反之不行。
协变性: 协变性允许您传递一个派生类型对象,而期望一个基类型对象。 协变性可应用于委托、泛型、数组、接口等。
逆变性: 逆变性应用于参数。它允许将具有基类参数的方法分配给期望派生类参数的委托。
请看下面的简单示例:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace CovarianceContravarianceDemo
{
    //base class
    class A
    {

    }

    //derived class
    class B : A
    {

    }
    class Program
    {
        static A Method1(A a)
        {
            Console.WriteLine("Method1");
            return new A();
        }

        static A Method2(B b)
        {
            Console.WriteLine("Method2");
            return new A();
        }

        static B Method3(B b)
        {
            Console.WriteLine("Method3");
            return new B();
        }

        public delegate A MyDelegate(B b);
        static void Main(string[] args)
        {
            MyDelegate myDel = null;
            myDel = Method2;// normal assignment as per parameter and return type

            //Covariance,  delegate expects a return type of base class
            //but we can still assign Method3 that returns derived type and 
            //Thus, covariance allows you to assign a method to the delegate that has a less derived return type.
            myDel = Method3;
            A a = myDel(new B());//this will return a more derived type object which can be assigned to base class reference

            //Contravariane is applied to parameters. 
            //Contravariance allows a method with the parameter of a base class to be assigned to a delegate that expects the parameter of a derived class.
            myDel = Method1;
            myDel(new B()); //Contravariance, 

        }
    }
}

最好的解释。 - Oleksiy Mumzhu

0

让我们从协变和逆变示例中使用的类层次结构开始:

public class Weapon { }
public class Sword : Weapon { }
public class TwoHandedSword : Sword { }

协变性意味着您可以将子类型的实例作为其超类型返回(输出)。以下是一个示例:

[Fact]
public void Covariance_tests()

 Assert.IsType<Sword>(Covariance());
 Assert.Throws<InvalidCastException>(() => BreakCovariance());
}
// We can return a Sword into a Weapon
private Weapon Covariance()
 => new Sword();
// We cannot return a Sword into a TwoHandedSword
private TwoHandedSword BreakCovariance()
 => (TwoHandedSword)new Sword();

如前面的例子所示,打破协变性的一种方法是将超类型作为子类型返回。 另一方面,逆变性意味着您可以将子类型的实例输入其超类型。 基本上是相同的概念,但用于输入,就像这样:
[Fact]
public void Contravariance_tests()
{
 // We can pass a Sword as a Weapon
 Contravariance(new Sword());
 // We cannot pass a Weapon as a Sword
 BreakContravariance(new Weapon()); // Compilation error 
}
private void Contravariance(Weapon weapon) { }
private void BreakContravariance(Sword weapon) { }

相同的多态规则适用,正如我们可以从前面的代码中看到的那样。我们可以使用子类型作为超类型。

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