当使用接口的委托作为参数类型时,逆变无效。

10

考虑具有委托的逆变接口定义:

public interface IInterface<in TInput>
{
    delegate int Foo(int x);
    
    void Bar(TInput input);
    
    void Baz(TInput input, Foo foo);
}
Baz的定义遇到错误:

CS1961
无效的变量:类型参数'TInput'在'IInterface<TInput>.Baz(TInput,IInterface<TInput>.Foo)'上必须是协变有效的。 'TInput'是逆变的。


我的问题是为什么?乍一看,这应该是有效的,因为Foo委托与TInput无关。 我不知道是编译器过于保守还是我漏了什么。
请注意,通常您不会在接口中声明委托,特别是在早于C# 8的版本中无法编译此类代码,因为接口中的委托需要默认接口实现。
如果允许此定义,是否有一种方式可以破坏类型系统,或者编译器是保守的?
2个回答

4

简而言之,根据ECMA-335规范,这是正确的,令人困惑的是,在某些情况下它确实有效

假设我们有两个变量

IInterface<Animal> i1 = anInterfaceAnimalValue;
IInterface<Cat>    i2 = anInterfaceCatValue;

我们可以进行这些调用。
i1.Baz(anAnimal, j => 5);
//this is the same as doing
i1.Baz(anAnimal, new IInterface<Animal>.Foo(j => 5));

i1.Baz(aCat, j => 5);
//this is the same as doing
i1.Baz(aCat, new IInterface<Animal>.Foo(j => 5));


i2.Baz(aCat, j => 5);
//this is the same as doing
i2.Baz(aCat, new IInterface<Cat>.Foo(j => 5));

如果我们现在将i1 = i2;赋值,会发生什么?
i1.Baz(anAnimal, j => 5);
//this is the same as doing
i1.Baz(anAnimal, new IInterface<Animal>.Foo(j => 5));

然而,IInterface<Cat>.Baz(实际的对象类型)不接受IInterface<Animal>.Foo,它只接受IInterface<Cat>.Foo。这两个委托具有相同的签名并不意味着它们是相同的类型。


让我们深入了解一下

首先,请记住,接口中的协变通用类型可以出现在输出位置(允许更派生的类型),逆变则可以出现在输入位置(允许更基础的类型)。

泛型中的协变和逆变

通常情况下,协变类型参数可用作委托的返回类型,而逆变类型参数可用作参数类型。 对于接口,协变类型参数可以用作接口方法的返回类型,而逆变类型参数可以用作接口方法的参数类型。

对于您传入的参数的类型参数,有些令人困惑:如果T是协变的(输出),函数可以使用看起来像是输入void(Action<T>),并且接受一个更具体的委托。 它还可以返回Func<T>

如果T是逆变的,则相反。

请参阅Eric Lippert大佬的这篇文章以及Peter Duniho在同一问题上的回答,以进一步解释这一点。

其次,定义CLI规范的ECMA-335中说了以下内容(我加粗了):

II.9.1泛型类型定义

在声明中,通用参数的作用域为:

  • snip...
  • 所有成员(实例和静态字段、方法、构造函数、属性和事件),除了嵌套类。 [注意:C#允许从封闭类中使用通用参数以在嵌套类中使用,但会将所需的任何额外通用参数添加到元数据中的嵌套类定义。 end note]

嵌套类型,例如Foo委托,实际上在作用域中并没有泛型T类型。它们是由C#编译器添加的。


现在,请看下面的代码,我已经标注了哪些行不会编译:

public delegate void FooIn<in T>(T input);
public delegate T FooOut<out T>();

public interface IInterfaceIn<in T>
{
    void BarIn(FooIn<T> input);     //must be covariant
    FooIn<T> BazIn();
    void BarOut(FooOut<T> input);
    FooOut<T> BazOut();             //must be covariant

    public delegate void FooNest();
    public delegate void FooNestIn(T input);
    public delegate T FooNestOut();
    
    void BarNest(FooNest input);        //must be covariant
    void BarNestIn(FooNestIn input);    //must be covariant
    void BarNestOut(FooNestOut input);  //must be covariant
    FooNest BazNest();
    FooNestIn BazNestIn();
    FooNestOut BazNestOut();
}

public interface IInterfaceOut<out T>
{
    void BarIn(FooIn<T> input);
    FooIn<T> BazIn();               //must be contravariant
    void BarOut(FooOut<T> input);   //must be contravariant
    FooOut<T> BazOut();
    
    public delegate void FooNest();
    public delegate void FooNestIn(T input);
    public delegate T FooNestOut();
    
    void BarNest(FooNest input);        //must be contravariant
    void BarNestIn(FooNestIn input);    //must be contravariant
    void BarNestOut(FooNestOut input);  //must be contravariant
    FooNest BazNest();
    FooNestIn BazNestIn();
    FooNestOut BazNestOut();
}

暂时先采用 IInterfaceIn

拿无效的 BarIn 举例。它使用了协变的类型参数 FooIn

现在,如果我们有一个 anAnimalInterfaceValue,那么我们就可以使用 FooIn<Animal> 参数调用 BarIn()。这意味着委托接受一个 Animal 参数。如果我们将其转换为 IInterface<Cat>,那么我们就可以使用 FooIn<Cat> 调用它,这个方法需要一个 Cat 参数,并且底层对象不期望这样一个严格的委托,而是期望能够传递任何一个 Animal

因此,BarIn 只能使用相同或者更少派生的类型,因此它不能接收可能更多派生的 IInterfaceInT

BarOut 是有效的,因为它使用反变的 T

现在让我们来看看嵌套的 FooNestInFooNestOut。它们重新声明了封装类型的 T 参数。 FooNestOut 是无效的,因为它在输出位置使用了协变的 in TFooNestIn 是有效的。

让我们继续看看 BarNestBarNestInBarNestOut。这些都是无效的,因为它们使用具有协变泛型参数的委托。关键在于,我们并不关心委托是否在必要的位置上使用了类型参数,我们关心的是委托的泛型参数的差异是否与我们提供的类型匹配。

你会说,“那么为什么嵌套的 IInterfaceOut 参数不起作用呢?”

让我们再次看一下 ECMA-335,在其中它谈到了泛型参数的有效性,并断言每个泛型类型的每个部分都必须是有效的(加粗是我的解释,S 表示泛型类型,例如 List<T>T 表示类型参数,var 表示相应参数的 in/out):

II.9.7 成员签名的有效性

给定注释泛型参数 S = <var_1 T_1, ..., var_n T_n>,我们定义了对于 S 相关的类型定义各个组件是有效的含义。我们定义了一个注解的反转运算,表示为 ¬S,意思是“将负号变为正号,将正号变为负号”。

方法。 如果一个方法签名 tmeth(t_1,...,t_n) 相对于 S 是有效的当且仅当:

  • 其结果类型签名 t 相对于 S 是有效的;并且
  • 每个参数类型签名 t_i 相对于 ¬S 是有效的。
  • 每个方法泛型参数约束类型 t_j 相对于 ¬S 是有效的。【注:换句话说,结果表现为协变,而参数则表现为逆变……】

因此,我们要翻转方法参数中使用的类型的变异性(variance)

所有这一切的结果是,在方法参数位置上不可能使用嵌套的协变型或逆变型,因为所需的变异性已经被翻转了,因此不会匹配。无论我们如何做,都行不通。

相反,在返回位置上使用委托总是奏效的。


(叹气...又要重写一遍了)(只是用更简单的语法编写单个成员接口。) - Peter Duniho
他们是多个成员。请参阅ECMA-335 II.14.6.3,以及反编译器。诚然,ECMA并不“要求”异步方法,但微软的CLI实现确实需要这样做。 - Charlieface
他们不止一个成员 - 啊,你误解了我的意思。正如我上面所提到的,我不是在谈论委托的机械(实现细节)方面,而是语义方面。你只能使用委托类型“实现”单个“接口”成员。它比接口更灵活,因为任何地方的代码都可以“实现”该成员。但它只能表示单个“接口”成员。 - Peter Duniho
@EricLippert 是的,我很想听听您的想法。我无法确定关于 IInterfaceOut 的最后一部分有什么问题,但某些地方感觉不太对。为什么 IInterfaceOut.BarIn 能工作,但 IInterfaceOut.BarNestBarNestIn 却不能?我甚至认为自己也没有完全理解它为什么这样做。如果您愿意,可以直接编辑我的帖子。我最初还更强调了嵌套重新声明的内容,但后来意识到这实际上基本上是无关紧要的。 - Charlieface
@Charlieface:在接口委托功能被添加之前,我就离开了微软,但我可能还记得我们当时讨论的设计方案的足够信息来解决它。但今天不行! - Eric Lippert
显示剩余7条评论

0

我不确定这是协变还是逆变问题。

  1. Foo 委托不是接口的成员,而是嵌套类型声明。
  2. IInterface<A>.FooIInterface<B>.Foo 是两种不同的类型。
  3. 这使得两个不同的 IInterface<T>.Baz 方法(其中 T = AB)的 foo 参数不兼容。
  4. 因此,您不能将 IInterface<A> 替换为 IInterface<B> 或反之(无论 AB 之间的继承关系如何)。
  5. 结论:IInterface<T> 不能是变体(既不是协变也不是逆变)。

解决方案:

  • 将委托移到顶层(在命名空间的主体中)。它是一个类型声明,所以不需要嵌套。
  • 或者将其嵌入到没有类型参数的类型中。例如,您可以为此创建一个非泛型的 IInterface(并保留您的泛型版本)。

但是 @EricLippert 肯定更清楚。


希望我文本中的@EricLippert能够联系家人 :-) - Olivier Jacot-Descombes
根据你的结论,似乎这确实是一个方差问题,所以我认为你应该删除你的第一句话。 - Servy
@Servy 好的,我稍微修改了一下我的句子。 - Olivier Jacot-Descombes
如果这不是一个差异问题(意思是IInterface<Cat>.Foo永远不会分配兼容到IInterface<Animal>.Foo),那么你如何解释我给出的FooNest BazNest()示例,它确实编译了呢?顺便说一下,不确定你的反对票是什么意思,我说错了什么吗? - Charlieface
@Charlieface:我没踩(实际昨天还点了赞)。你也许是对的。我感觉嵌套类型不会参与周围类型的差异。 - Olivier Jacot-Descombes

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