泛型类型参数协方差和多接口实现

Posted

技术标签:

【中文标题】泛型类型参数协方差和多接口实现【英文标题】:Generic type parameter covariance and multiple interface implementations 【发布时间】:2013-01-11 19:00:48 【问题描述】:

如果我有一个带有协变类型参数的泛型接口,像这样:

interface IGeneric<out T>

    string GetName();

如果我定义了这个类层次结构:

class Base 
class Derived1 : Base
class Derived2 : Base

然后我可以在一个类上实现接口两次,就像这样,使用显式接口实现:

class DoubleDown: IGeneric<Derived1>, IGeneric<Derived2>

   string IGeneric<Derived1>.GetName()
   
     return "Derived1";
   

   string IGeneric<Derived2>.GetName()
   
     return "Derived2";
     

如果我使用(非泛型)DoubleDown 类并将其转换为 IGeneric&lt;Derived1&gt;IGeneric&lt;Derived2&gt; 它会按预期运行:

var x = new DoubleDown();
IGeneric<Derived1> id1 = x;        //cast to IGeneric<Derived1>
Console.WriteLine(id1.GetName());  //Derived1
IGeneric<Derived2> id2 = x;        //cast to IGeneric<Derived2>
Console.WriteLine(id2.GetName());  //Derived2

但是,将 x 转换为 IGeneric&lt;Base&gt; 会得到以下结果:

IGeneric<Base> b = x;
Console.WriteLine(b.GetName());   //Derived1

我预计编译器会发出错误,因为调用在两个实现之间不明确,但它返回了第一个声明的接口。

为什么允许这样做?

(灵感来自A class implementing two different IObservables?。我试图向同事证明这会失败,但不知何故,它没有)

【问题讨论】:

关于Console.WriteLine(b.GetName());编译器不能发出任何错误;它有一个 IGeneric 来调用 getName ,这是完全有效的调用。 @MiserableVariable 编译器不止有一个有效的实现——它有两个。在其他情况下,您可能会得到一个模棱两可的调用编译时错误,在这种情况下,您不会得到未指定的行为。 @SWeko 编译器只查看bstatic 类型,即IGeneric&lt;Base&gt;GetName 调用对其有效。如果您建议错误应该在DoubleDown 中,那不是错误,因为有一个明确定义的规则,即未指定匹配。 @MiserableVariable 正如我在其他答案中所说的那样,这种情况不在#13.4.4 的两点中的任何一点中涵盖。 这与我之前提出的问题非常相似(由下面 jam40jeff 的答案链接)。另请注意,Eric Lippert 在他的 C#-4.0 之前的博客文章 Covariance and Contravariance in C#: Dealing With Ambiguity 中确切地询问了这个问题。他假设IEnumerable&lt;&gt; 是协变的,并创建了一个类C 既是IEnumerable&lt;Giraffe&gt; 又是IEnumerable&lt;Turtle&gt;。然后该类的一个实例,通过协方差,是IEnumerable&lt;Animal&gt;。所以,同样的模棱两可。 【参考方案1】:

如果你已经测试了这两个:

class DoubleDown: IGeneric<Derived1>, IGeneric<Derived2> 
    string IGeneric<Derived1>.GetName() 
        return "Derived1";
    

    string IGeneric<Derived2>.GetName() 
        return "Derived2";
    


class DoubleDown: IGeneric<Derived2>, IGeneric<Derived1> 
    string IGeneric<Derived1>.GetName() 
        return "Derived1";
    

    string IGeneric<Derived2>.GetName() 
        return "Derived2";
    

您一定已经意识到,实际结果会随着您声明要实现的接口的顺序而变化。但我想说它只是未指定

首先,规范(§13.4.4 接口映射)说:

如果有多个成员匹配,则未指定哪个成员是 I.M. 的实现 这种情况在 S 是构造类型时发生,其中在泛型类型中声明的两个成员具有不同的签名,但类型参数使它们的签名相同.

这里有两个问题需要考虑:

Q1:您的通用接口是否有不同的签名? A1:是的。它们是IGeneric&lt;Derived2&gt;IGeneric&lt;Derived1&gt;

Q2:语句IGeneric&lt;Base&gt; b=x; 是否可以使它们的签名与类型参数相同? A2:没有。您通过通用协变接口定义调用了该方法。

因此您的调用满足未指定条件。但这怎么会发生呢?

请记住,无论您指定什么接口来引用DoubleDown 类型的对象,它始终是DoubleDown。也就是说,它总是有这两个GetName 方法。您指定引用它的接口实际上执行合约选择

以下为实测截图部分

这张图片显示了在运行时GetMembers 会返回什么。在所有情况下,您都引用它,IGeneric&lt;Derived1&gt;IGeneric&lt;Derived2&gt;IGeneric&lt;Base&gt;,没有什么不同。下面两张图展示了更多细节:

如图所示,这两个通用派生接口既没有相同的名称,也没有其他签名/令牌使它们相同。

【讨论】:

您的分析是正确的,但是,由于我们正在处理显式接口方法实现,因此应该在13.4.4的第一点中涵盖。我认为第二点的多重条款处理的是完全不同的情况。恕我直言,这只是规范中的一个缺陷。顺便说一句,感谢赏金。我本来想放一个,但生活挡住了:) 你所说的意味着它应该有一个名为 IGeneric&lt;Base&gt;.GetName() 的方法,实际上它没有。即使它做了而不是做什么,仍然会导致 actual contract 在运行时选择(映射​​),并陷入第 13.4.4 节所述的第二种情况。没有一个实现会导致编译问题,但当前的实现更简单。 在第 1 点中,使用的短语是“匹配 I 和 M 的显式接口成员实现”,而 IGeneric&lt;DerivedX&gt;.GetName() 确实匹配 IGeneric&lt;Base&gt;.GetName()(虽然不一样,这是协/逆变)。在第 2 点中,据说方法必须是“与 M 匹配的非静态公共成员的声明”,IGeneric&lt;DerivedX&gt;.GetName() 不是。 哦,也许我刚刚发现你为什么会这样认为IGeneric&lt;DerivedX&gt;.GetName() 不是。如果您使用接口或其成员显式指定除 public 之外的修饰符并尝试编译,您只会看到这一点。 在规范中有“严格定义的怀疑和不确定区域”是谨慎的原因有很多;说特定行为是“实现定义的”不一定是规范中的缺陷。有关其中一些问题的讨论,请参阅 blogs.msdn.com/b/ericlippert/archive/2012/06/18/…。【参考方案2】:

编译器不能就行抛出错误

IGeneric<Base> b = x;
Console.WriteLine(b.GetName());   //Derived1

因为编译器可以知道没有歧义。 GetName() 实际上是接口IGeneric&lt;Base&gt; 上的有效方法。编译器不会跟踪 b 的运行时类型以知道其中存在可能导致歧义的类型。所以由运行时决定做什么。运行时可能会抛出异常,但 CLR 的设计者显然反对这一点(我个人认为这是一个不错的决定)。

换一种说法,假设您只是简单地编写了方法:

public void CallIt(IGeneric<Base> b)

    string name = b.GetName();

并且你没有在你的程序集中提供实现IGeneric&lt;T&gt; 的类。你分发这个和许多其他人只实现这个接口一次,并且能够很好地调用你的方法。但是,最终有人会使用您的程序集并创建 DoubleDown 类并将其传递给您的方法。编译器应该在什么时候抛出错误?当然,包含对GetName() 的调用的已编译和分发的程序集不会产生编译器错误。你可以说从DoubleDownIGeneric&lt;Base&gt; 的赋值产生了歧义。但是我们可以再一次在原始程序集中添加另一个间接级别:

public void CallItOnDerived1(IGeneric<Derived1> b)

    return CallIt(b); //b will be cast to IGeneric<Base>

再一次,许多消费者可以拨打CallItCallItOnDerived1 并没有问题。但是我们的消费者通过DoubleDown 也进行了一个完全合法的调用,当他们调用CallItOnDerived1 时不会导致编译器错误,因为从DoubleDown 转换为IGeneric&lt;Derived1&gt; 肯定没问题。因此,除了DoubleDown 的定义之外,编译器不会抛出任何错误,但这将消除在没有解决方法的情况下做一些可能有用的事情的可能性。

我实际上已经在其他地方更深入地回答了这个问题,并且如果可以更改语言,我还提供了一个潜在的解决方案:

No warning or error (or runtime failure) when contravariance leads to ambiguity

鉴于更改语言以支持这一点的可能性几乎为零,我认为当前的行为是可以的,除了它应该在规范中进行布局,以便 CLR 的所有实现都预期表现同样的方式。

【讨论】:

不错的答案!我投了赞成票。你提供了一个更深入的假设来解释这个事实。我的回答是 SWeko 和我之间的问题,我回答说 当前的实现更简单 "编译器应该在什么时候抛出错误?"当您将 DoubleDown 隐式转换为 IGeneric&lt;Base&gt; 时,编译器应该会抛出错误,因为它不明确(编译器不知道要使用两个接口中的哪一个)。 @Ark-kun 即使编译器在这种情况下会出错,但在第二种情况下(DoubleDown 首先分配给IGeneric&lt;Derived1&gt;,然后IGeneric&lt;Dervied1&gt; 是分配给IGeneric&lt;Base&gt;) 编译器可以抛出错误。 谢谢。另外,我想指出,我的答案末尾附近的链接实际上会导致另一个更深入的答案。 @Ark-kun 您正在查看一种方法的上下文中的内容(即使这样,编译器也不会跟踪变量中内容的运行时类型。当变量为输入 IGeneric&lt;Base&gt; 你还没有“选择”使用哪个实现。【参考方案3】:

天哪,对于一个相当棘手的问题,这里有很多非常好的答案。总结:

语言规范没有明确说明在这里做什么。 当有人试图模拟接口协变或逆变时,通常会出现这种情况;既然 C# 有接口变化,我们希望更少的人会使用这种模式。 大多数时候“只选一个”是一种合理的行为。 CLR 如何在模棱两可的协变转换中实际选择使用哪个实现是实现定义的。基本上,它会扫描元数据表并选择第一个匹配项,而 C# 恰好按源代码顺序发出表。但是,您不能依赖这种行为;两者均可更改,恕不另行通知。

我只想添加另外一件事,那就是:坏消息是 接口重新实现语义 在出现此类歧义的情况下与 CLI 规范中指定的行为不完全匹配.好消息是,当重新实现具有这种歧义的接口时,CLR 的实际行为通常是您想要的行为。发现这一事实引发了我、Anders 和一些 CLI 规范维护者之间的激烈辩论,最终结果是规范或实现都没有改变。由于大多数 C# 用户甚至不知道从什么接口重新实现开始,我们希望这不会对用户产生不利影响。 (从来没有客户引起我的注意。)

【讨论】:

如果类 Foo 实现 IEnumerable, and class DerivedFoo` 实现 IEnumerable&lt;DerivedBar&gt;,上述情况是否适用?是否有任何实际的方法可以避免创建模棱两可的绑定,同时允许将DerivedFoo 传递给需要可枚举事物的代码,这些事物不仅是一种Bar,更具体地说是一种@987654326 @?【参考方案4】:

问的问题是,“为什么这不会产生编译器警告?”。 在VB中,它确实(我实现了它)。

类型系统没有携带足够的信息来提供在调用时关于方差歧义的警告。所以警告必须更早发出......

    在 VB 中,如果你声明了一个实现 IEnumerable(Of Fish)IEnumerable(Of Dog) 的类 C,那么它会给出一个警告,指出两者在常见情况下会发生冲突 IEnumerable(Of Animal)。这足以消除完全用 VB 编写的代码中的方差歧义。

    但是,如果问题类是在 C# 中声明的,这将无济于事。另请注意,如果没有人在其上调用有问题的成员,声明这样一个类是完全合理的。

    在 VB 中,如果您从此类 C 转换为 IEnumerable(Of Animal),那么它会在转换时发出警告。这足以消除方差歧义即使您从元数据导入问题类

    但是,这是一个糟糕的警告位置,因为它不可操作:您不能去更改演员表。对人们唯一可行的警告是返回并更改类定义。另请注意,如果没有人调用有问题的成员,那么执行这样的演员表是完全合理的。

问题:

为什么 VB 会发出这些警告,而 C# 却不会?

答案:

当我把它们放到 VB 中时,我对正式的计算机科学充满热情,并且只写了几年编译器,我有时间和热情来编写它们。

Eric Lippert 是用 C# 做的。他有智慧和成熟,看到在编译器中编写此类警告将花费大量时间,而这些时间本可以更好地花在其他地方,并且足够复杂,因此具有很高的风险。事实上,VB 编译器在这些警告中存在错误,这些错误仅在 VS2012 中修复。

另外,坦率地说,不可能提出足够有用的警告信息以使人们能够理解。顺便说一句,

问题:

在选择调用哪一个时,CLR 如何解决歧义?

答案:

它基于原始源代码中继承语句的词法顺序,即您声明C 实现IEnumerable(Of Fish)IEnumerable(Of Dog) 的词法顺序。

【讨论】:

你是个非常善良的卢锡安,对自己有点不必要的苛刻;这是一个艰难的决定,我可以看到双方的争论。而且我注意到我确实向 C# 添加了一个警告,以针对由于不幸的类型统一而具有实现定义的行为的类似情况:blogs.msdn.com/b/ericlippert/archive/2006/04/06/570126.aspx【参考方案5】:

试图深入研究“C# 语言规范”,它看起来没有指定行为(如果我没有迷路的话)。

7.4.4 函数成员调用

函数成员调用的运行时处理包括以下步骤,其中 M 是函数成员,如果 M 是实例成员,则 E 是实例表达式:

[...]

o 确定要调用的函数成员实现:

• 如果 E 的编译时类型是接口,则调用的函数成员是由 E 引用的实例的运行时类型提供的 M 的实现。该函数成员由 应用确定接口映射规则(第 13.4.4 节)确定由 E 引用的实例的运行时类型提供的 M 的实现。

13.4.4 接口映射

类或结构 C 的接口映射为 C 的基类列表中指定的每个接口的每个成员定位一个实现。特定接口成员 IM 的实现,其中 I 是声明成员 M 的接口, 通过检查每个类或结构 S 来确定,从 C 开始并针对 C 的每个连续基类重复,直到找到匹配项:

• 如果 S 包含与 I 和 M 匹配的显式接口成员实现的声明,则该成员是 I.M 的实现。

• 否则,如果 S 包含与 M 匹配的非静态公共成员的声明,则该成员是 IM 的实现 如果多个成员匹配,未指定哪个成员是 IM 的实现。这种情况只有在 S 是构造类型时才会发生,其中在泛型类型中声明的两个成员具有不同的签名,但类型参数使它们的签名相同。

【讨论】:

我不认为是这样。接口映射是关于决定类中的哪个方法最终实现类实现的接口中声明的每个方法,更不用说这里讨论的成员不是公共的(接口是显式实现的)。 谢谢乔恩。我编辑了答案,方法调用解析使用相同的机制,检查运行时类型。 其他引用似乎与此处相关。但我认为这种行为实际上是由 first 项目符号解释的:依次搜索DoubleDown 的基本接口,直到找到映射到IGeneric&lt;Base&gt;.GetName() 的方法。所以方法调用映射到IGeneric&lt;Derived1&gt;.GetName(),因为该接口首先出现在继承的接口列表中。 第一个确实是指显式方法。但是这里它说映射迭代 C 的基类,我不希望接口也参与其中。无论如何,我不太确定第二点在这种情况下是否相关。 第二点不适用于这种情况,因为 EIMI 明确排除在第一点中。在这种情况下,我们实际上在第一点上有多个匹配项,因此甚至不考虑第二点。

以上是关于泛型类型参数协方差和多接口实现的主要内容,如果未能解决你的问题,请参考以下文章

泛型类型参数是实现接口的类

来自接口实现的 Typescript 泛型推断

当两个类型参数在C#中实现公共接口时,如何将泛型强制转换为它实现的接口

泛型反射

C#中使用委托接口匿名方法泛型委托实现加减乘除算法

Java泛型