【问题标题】:Covariance and Contravariance with Func in generics泛型中 Func 的协变和逆变
【发布时间】:2018-01-09 22:18:13
【问题描述】:

我需要有关泛型和委托的差异的更多信息。以下代码 sn -p 无法编译:

错误 CS1961 无效方差:类型参数“TIn”必须是 在“Test.F(Func)”上协变有效。 'TIn' 是 逆变的。

public interface Test<in TIn, out TOut>
{
    TOut F (Func<TIn, TOut> transform);
}

.netFunc定义如下:

public delegate TResult Func<in T, out TResult> (T arg);

为什么编译器抱怨 TIn 是逆变的,而 TOut 是协变的,而 Func 期望完全相同的方差?

编辑

对我来说主要的限制是我希望我的 Test 接口具有 TOut 作为协变,以便像这样使用它:

public Test<SomeClass, ISomeInterface> GetSomething ()
{
    return new TestClass<SomeClass, AnotherClass> ();
}

鉴于public class AnotherClass : ISomeInterface

【问题讨论】:

  • 该接口唯一有效的差异声明是Test&lt;out TIn, TOut&gt;

标签: c# generics variance


【解决方案1】:

我需要有关泛型和委托的差异的更多信息。

我就此功能撰写了一系列广泛的博客文章。尽管其中一些已经过时——因为它是在设计完成之前编写的——但那里有很多很好的信息。特别是如果您需要正式定义什么是方差有效性,您应该仔细阅读以下内容:

https://blogs.msdn.microsoft.com/ericlippert/2009/12/03/exact-rules-for-variance-validity/

有关相关主题,请参阅我的 MSDN 和 WordPress 博客上的其他文章。


为什么编译器抱怨 TIn 是逆变的,而 TOut 是协变的,而 Func 期望完全相同的方差?

让我们稍微重写一下你的代码,看看:

public delegate R F<in T, out R> (T arg);
public interface I<in A, out B>{
  B M(F<A, B> f);
}

编译器必须证明这是安全,但事实并非如此。

我们可以通过假设它是不安全的,然后发现它如何被滥用来说明它是不安全的。

假设我们有一个具有明显关系的动物层次结构,例如,哺乳动物是动物,长颈鹿是哺乳动物,等等。让我们假设您的方差注释是合法的。我们应该可以说:

class C : I<Mammal, Mammal>
{
  public Mammal M(F<Mammal, Mammal> f) {
    return f(new Giraffe());
  }
}

我希望你同意这是一个完全有效的实现。现在我们可以这样做了:

I<Tiger, Animal> i = new C();

C 实现了I&lt;Mammal, Mammal&gt;,我们说过第一个可以更具体,第二个可以更通用,所以我们做到了。

现在我们可以这样做了:

Func<Tiger, Animal> f = (Tiger t) => new Lizard();

对于这个委托,这是一个完全合法的 lambda,它与以下签名相匹配:

i.M(f);

然后会发生什么? C.M 期待一个接收长颈鹿并返回哺乳动物的函数,但它被赋予了一个接收老虎并返回蜥蜴的函数,所以有人会过得很糟糕。

显然不能允许这种情况发生,但沿途的每一步都是合法的。我们必须得出结论,方差本身不能被证明是安全的,事实上,事实并非如此。编译器拒绝这个是正确的。

获得正确的方差不仅仅是匹配输入和输出注释。 您必须以不允许此类缺陷存在的方式这样做。

这就解释了为什么这是非法的。为了解释如何它是非法的,编译器必须检查B M(F&lt;A, B&gt; f);的以下内容是否正确:

  • B有效协变。既然它被宣布为“out”,那就是。
  • F&lt;A, B&gt; 逆变有效。它不是。泛型委托的“有效逆变”定义的相关部分是:如果第 i 个类型参数被声明为逆变,那么 Ti 必须是协变有效。 好的。第一个类型参数T,被声明为逆变的。因此,第一个 类型参数A 必须是有效协变。但它不是协变有效的,因为它被声明为逆变的。这就是你得到的错误。同样,B 也不好,因为它必须是逆变有效的,但 B 是协变的。编译器在这里找到第一个问题后不会继续查找其他错误;我考虑过但拒绝了它,因为它是一个过于复杂的错误消息。

我还注意到,即使委托不是变体,您也会仍然遇到这个问题;在我的反例中,我们没有使用 F 在其类型参数中变体这一事实。如果我们尝试会报告类似的错误

public delegate R F<T, R> (T arg);

改为。

【讨论】:

    【解决方案2】:

    Variance 是关于能够用比最初声明的更多或更少派生类型替换类型参数。例如,IEnumerable&lt;T&gt;T 是协变的,这意味着如果您从对 IEnumerable&lt;U&gt; 对象的引用开始,您可以将该引用分配给类型为 IEnumerable&lt;V&gt; 的变量,其中 V 可从 @987654326 分配@(例如 U 继承 V)。这是可行的,因为任何尝试使用IEnumerable&lt;V&gt; 的代码都希望只接收V 的值,并且由于V 可以从U 分配,因此只接收U 的值也是有效的。

    对于像T 这样的协变参数,您必须分配给目标类型与T 相同的类型,或者可以从T 分配。对于逆变参数,它必须走另一条路。目标类型必须与类型参数相同或可分配给类型参数。

    那么,您尝试编写的代码在这方面是如何工作的?

    当您声明Test&lt;in TIn, out TOut&gt; 时,您承诺将该接口Test&lt;TIn, TOut&gt; 的实例分配给具有Test&lt;U, V&gt; 类型的任何目标是有效的,其中U 可以分配给TIn 和@ 987654342@ 可以分配给V(当然,它们是相同的)。

    同时,让我们考虑一下您的transform 代表的期望。 Func&lt;T, TResult&gt; 类型差异要求如果您想将该值分配给其他东西,它也符合差异规则。也就是说,目标Func&lt;U, V&gt; 必须具有可从T 分配的U,以及可从V 分配的TResult。这可确保您的委托目标方法(期望接收 U 的值)将获得其中之一,并且该方法返回的类型为 V 的值可以被接收它的代码接受。

    重要的是,您的接口方法F() 是接收的方法! 接口声明承诺TOut 将仅用作接口成员的输出。但是通过使用transform 委托,F() 方法将接收一个TOut 的值,使该方法输入。同样,F() 方法允许将 TIn 的值传递给 transform 委托,使其成为接口实现的输出,即使您已承诺 TIn仅用作输入。

    换句话说,每一层调用都会反转变化的感觉。接口中的成员必须只使用协变类型参数作为输出,只使用逆变参数作为输入。但是,当这些参数在传递给接口成员或从接口成员返回的委托类型中使用时,它们在意义上变得相反,并且必须遵守这方面的差异。

    一个具体的例子:

    假设我们有一个你的接口的实现,Test&lt;object, string&gt;。如果编译器允许您的声明,您将被允许将该实现的值Test&lt;object, string&gt; 分配给具有Test&lt;string, object&gt; 类型的变量。也就是说,最初的实现承诺允许任何具有object 类型的东西作为输入,并且只返回具有string 类型的值。声明为 Test&lt;string, object&gt; 的代码使用它是安全的,因为它会将 string 对象传递给需要 objects 值的实现(stringobject),并且它将接收具有类型的值object 来自返回 string 值的实现(同样,stringobject,因此也很安全)。

    但是您的接口实现需要代码传递Func&lt;object, string&gt; 类型的委托。如果您被允许将(如上所述)您的接口实现视为Test&lt;string, object&gt;,那么使用您的重铸实现的代码将能够将Func&lt;string, object&gt; 的委托传递给方法F()。实现中的方法F() 允许将object 类型的任何值传递给委托,但该委托的类型为Func&lt;string, object&gt;,只期望将具有string 类型的值传递给它。如果 F() 传递了其他东西,例如只是一个普通的旧new object(),委托实例将无法使用它。它期待一个string

    所以,事实上,编译器正在做它应该做的事情:它阻止你编写非类型安全的代码。正如声明的那样,如果您被允许以不同的方式使用该接口,您实际上将能够编写在编译时允许但在运行时可能会中断的代码。这与泛型的全部观点完全相反:能够在编译时确定代码是类型安全的!

    现在,如何解决这个难题。不幸的是,您的问题中没有足够的上下文来了解正确的方法是什么。您可能只需要放弃方差。通常,实际上不需要使类型变体。在某些情况下这很方便,但不是必需的。如果是这种情况,那么就不要让接口的参数变体。

    或者,您可能确实想要变化,并认为以不同的方式使用界面是安全的。这更难解决,因为您的基本假设不正确,您需要以其他方式实现代码。如果您可以反转Func&lt;T, TResult&gt; 中的参数,则代码将编译。 IE。制作方法F(Func&lt;TOut, TIn&gt; transform)。但是您的问题中没有任何内容表明在您的场景中这实际上是可能的。

    同样,如果没有更多上下文,就不可能说出“其他方式”对您有用。但是,希望现在您以您现在编写代码的方式了解代码中的危险,您可以重新审视导致您使用这种非类型安全接口声明的设计决策,并且可以提出一些可行的方法。如果您对此有疑问,请发布一个新问题,提供更多详细信息,说明您为什么认为这是安全的、您将如何使用界面、您考虑过哪些替代方案以及为什么这些都不适合您.

    【讨论】:

      【解决方案3】:

      TIn = 该类知道如何读取它,并且允许实现将其视为比实际派生更少的类型。您可能会向它传递一个比预期派生更多的实例,但这并不重要,因为派生类可以做基类可以做的所有事情。

      TOut = 实现知道要产生一个,并且允许实现产生比调用者期望的更派生的类型。同样,这并不重要——调用者可以毫无问题地将派生程度更高的类分配给派生程度较低的变量。

      但是——

      如果您将Func&lt;TIn, TOut&gt; 传递给类,并且您希望该类能够调用它,那么该类将必须能够产生一个TIn阅读TOut。与上述相反。

      为什么不能呢?好吧,我已经提到该类可以将TIn 视为较少派生的东西。如果它尝试使用派生较少的参数调用函数,它将不起作用(如果函数期望能够调用 string.Length 但类传递给它一个 object 怎么办?)。此外,如果它试图将函数的结果读取为更衍生的东西,那也会失败。

      您可以通过消除方差来消除问题——去掉inout关键字——这将使类无法替换更少/更多派生类型(这称为“不变性”),但是将允许您读取和写入类型。

      【讨论】:

        【解决方案4】:

        从接口定义中删除 inout -关键字:

        public interface Test<TIn, TOut>{
            TOut F (Func<TIn, TOut> transform);
        }
        

        【讨论】:

        • 我希望我的测试接口也与 TOut 协变。
        【解决方案5】:

        去掉in和out关键字:

        public interface Test<TIn, TOut>
        {
            TOut F (Func<TIn, TOut> transform);
        }
        

        你可以在这里阅读它们的含义:

        https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/in-generic-modifier

        如果类型仅用作方法参数的类型而不用作方法返回类型,则可以在泛型接口或委托中声明类型为逆变

        https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/out-generic-modifier

        类型参数仅用作接口方法的返回类型,不用作方法参数的类型。

        【讨论】:

          猜你喜欢
          • 2013-11-29
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2011-02-02
          相关资源
          最近更新 更多