关于c#:与泛型中的Func的协方差和逆变

Covariance and Contravariance with Func in generics

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

Error CS1961 Invalid variance: The type parameter 'TIn' must be
covariantly valid on 'Test.F(Func)'. 'TIn' is
contravariant.

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

.net Func定义如下:

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

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

编辑

对我来说主要的限制是我希望我的Test界面将TOut作为协变,以便使用它:

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

鉴于public class AnotherClass : ISomeInterface


I need more information about variance in generics and delegates.

我写了一系列有关此功能的博客文章。虽然其中一些已经过时 - 因为它是在设计最终确定之前编写的 - 那里有很多好的信息。特别是如果您需要对方差有效性的正式定义,您应该仔细阅读:

Exact rules for variance validity

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

Why the compiler complains about TIn being contravariant and TOut - covariant while the Func expects exactly the same variance?

让我们稍微重写您的代码并查看:

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

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

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

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

1
2
3
4
5
6
class C : I<Mammal, Mammal>
{
  public Mammal M(F<Mammal, Mammal> f) {
    return f(new Giraffe());
  }
}

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

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

C实现I,我们已经说过第一个可以更具体,第二个可以更通用,所以我们已经做到了。

现在我们可以这样做:

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

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

1
i.M(f);

会发生什么? C.M期待一种能够接受长颈鹿并返回哺乳动物的功能,但是它被赋予一种能够接受老虎并返回蜥蜴的功能,所以有人会有一个非常糟糕的一天。

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

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

这就解释了为什么这是非法的。要解释它是如何非法,编译器必须检查以下是否为B M(F f);

  • B是协同有效的。因为它被宣布为"out",所以它是。
  • F违反有效。它不是。泛型委托的"有效逆转"定义的相关部分是:如果第i个类型参数被声明为逆变,则Ti必须是协变有效的。好。第一个类型参数T被声明为逆变。因此,第一个类型参数A必须是协变有效的。但它并不是有效的,因为它被宣布为逆变。这就是你得到的错误。类似地,B也很糟糕,因为它必须是有效的,但B是协变的。在找到第一个问题后,编译器不会继续查找其他错误;我考虑过它,但拒绝它是一个太复杂的错误信息。

我还注意到,即使代表不是变体,你仍会遇到这个问题;在我的反例中没有任何地方我们使用F在其类型参数中是变体的事实。如果我们尝试,将报告类似的错误

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

代替。


方差是指能够用比最初声明的更多或更少的派生类型替换类型参数。例如,IEnumerable< T >对于T是协变的,这意味着如果以对IEnumerable对象的引用开始,则可以将该引用分配给类型为IEnumerable的变量,其中V可从(例如U继承V)。这是有效的,因为任何尝试使用IEnumerable的代码只想接收V的值,并且因为V可以从U分配,所以只接收U的值也是有效的。

对于像T这样的协变参数,您必须分配一种类型,其中目标类型与T相同,或者可以从T分配。对于逆变参数,它必须采用另一种方式。目标类型必须与type参数相同或可分配。

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

当您声明Test时,您承诺将该接口Test的实例分配给任何类型为Test的目标是有效的,其中U可以分配给TIn并且TOut可以分配给V(当然它们是相同的)。

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

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

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

一个具体的例子:

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

但是您的接口实现要求代码传递类型为Func的委托。如果允许您将接口实现视为(如上所述)作为Test,则使用重构实现的代码将能够将Func的委托传递给方法F()。实现中的方法F()允许将类型object的任何值传递给委托,但该类型Func的委托只期望将具有类型string的值传递给它。如果F()传递其他内容,例如只是一个普通的new object(),委托实例将无法使用它。它期待string

所以,事实上,编译器正在完全按照预期做的:它阻止你编写非类型安全的代码。如声明的那样,如果允许您以变体方式使用该接口,那么实际上您可以编写在编译时允许的代码,可能会在运行时中断。这与泛型的整个要点完全相反:能够在编译时确定代码是类型安全的!

现在,如何解决困境。不幸的是,在你的问题中没有足够的背景知道什么是正确的方法。您可能只需要放弃差异。通常,实际上并不需要制作类型变体;在某些情况下它很方便,但不是必需的。如果是这种情况,那么就不要让接口的参数变异。

或者,你可能真的想要方差,并认为以不同的方式使用界面是安全的。这很难解决,因为你的基本假设是不正确的,你需要以其他方式实现代码。如果您可以反转Func中的参数,代码将编译。即制作方法F(Func transform)。但是你的问题中没有任何东西表明在你的场景中实际上是可行的。

同样,没有更多的背景,就不可能说"其他方式"对你有用。 但是,现在希望你能够按照你现在编写的方式理解代码中的危险,你可以重新审视导致你进入这种非类型安全的接口声明的设计决策,并且可以提出一些有效的东西。 如果您遇到问题,请发布一个新问题,提供更详细的信息,说明您认为这是安全的,您将如何使用该界面,您考虑的替代方案,以及为什么这些都不适合您。

好。


TIn =类知道如何读取它,并允许实现将其视为派生类型少于实际的类型。您可以传递一个比预期更多派生的实例,但这无关紧要,因为派生类可以执行基类可以执行的所有操作。

TOut =实现知道生成一个,并且允许实现生成比调用者期望更多派生的类型。同样,无关紧要 - 调用者可以将更多派生类分配给派生程度较低的变量而没有任何问题。

但 -

如果你传递类a Func,并且你希望类能够调用它,那么该类必须能够产生TIn并读取TOut。这与上述相反。

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

您可以通过消除方差来消除问题 - 摆脱inout关键字 - 这将导致类无法替换更少/更多的派生类型(这称为"不变")但会允许您同时读写类型。


删除进出关键词:

1
2
3
4
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

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


从接口定义中删除输入和输出关键字:

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