通用类型参数协方差和多个接口实现

时间:2013-01-28 12:27:42

标签: c# generics types interface covariance

如果我有一个带有协变类型参数的通用接口,如下所示:

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<Derived1>IGeneric<Derived2>,则按预期运行:

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<Base>会产生以下结果:

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

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

为什么允许这样做?

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

5 个答案:

答案 0 :(得分:26)

如果你测试了两个:

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<Derived2>IGeneric<Derived1>

  • Q2:语句IGeneric<Base> b=x;是否可以使其签名与类型参数相同?
    A2:不。您通过通用协变接口定义调用了该方法。

因此,您的通话符合未指定的条件。但这怎么可能发生呢?
请记住,无论您指定哪个界面来引用DoubleDown类型的对象,它始终都是DoubleDown 。也就是说,它始终具有这两种GetName方法。实际上,您指定用于引用它的界面执行 合同选择

以下是来自真实测试的捕获图像的一部分

enter image description here

此图显示了在运行时使用GetMembers返回的内容。在所有情况下,您引用它IGeneric<Derived1>IGeneric<Derived2>IGeneric<Base>都没有什么不同。以下两张图片更详细地显示:

enter image description here enter image description here

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

现在,你知道为什么。

答案 1 :(得分:23)

编译器不能在行上抛出错误

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

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

换句话说,让我们说你只是编写了方法:

public void CallIt(IGeneric<Base> b)
{
    string name = b.GetName();
}

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

public void CallItOnDerived1(IGeneric<Derived1> b)
{
    return CallIt(b); //b will be cast to IGeneric<Base>
}

再次,许多消费者可以拨打CallItCallItOnDerived1,并且没问题。但是我们的消费者传递DoubleDown也是一个完全合法的调用,当他们调用CallItOnDerived1时,由于从DoubleDown转换为IGeneric<Derived1>肯定是正常的,因此无法导致编译错误。因此,除了DoubleDown的定义之外,编译器没有任何意义可以抛出错误,但是这将消除在没有解决方法的情况下做一些可能有用的事情的可能性。

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

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

鉴于语言改变支持这一点的几率几乎为零,我认为当前的行为是正常的,除了它应该在规范中列出,以便CLR的所有实现都应该表现为同样的方式。

答案 2 :(得分:11)

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

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

  1. 在VB中,如果您声明一个同时实现CIEnumerable(Of Fish)的类IEnumerable(Of Dog),那么它会发出警告,说明两者在常见情况下会发生冲突{ {1}}。这足以消除完全用VB编写的代码中的方差模糊性。

    但是,如果在C#中声明了问题类,则无效。另请注意,如果没有人调用有问题的成员,声明这样的类是完全合理的。

  2. 在VB中,如果您将此类IEnumerable(Of Animal)中的强制转换投射到C,那么它会对投射进行警告。即使您从元数据导入了问题类,这足以消除方差歧义

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

    • 问题:

        

      为什么VB会发出这些警告,但C#没有?

      答案:

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

      Eric Lippert正在使用C#进行操作。他有智慧和成熟,认为在编译器中编写这样的警告将花费大量时间,可以更好地在其他地方使用,并且足够复杂以至于带来高风险。实际上,VB编译器在这些非常警告中存在错误,这些警告仅在VS2012中修复。

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

    • 问题:

        

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

      答案:

      它基于原始源代码中继承语句的词法排序,即您声明IEnumerable(Of Animal)实现C和{{1}的词法顺序}}。

答案 3 :(得分:11)

圣洁的善良,这里有很多非常好的答案,这是一个非常棘手的问题。总结:

  • 语言规范没有明确说明该怎么做。
  • 当有人试图模仿界面协方差或逆变时,通常会出现这种情况;既然C#具有接口差异,我们希望更少的人使用这种模式。
  • 大多数时候“只选一个”是一种合理的行为。
  • CLR如何实际选择在模糊协变转换中使用哪个实现是实现定义的。基本上,它扫描元数据表并选择第一个匹配,并且C#恰好以源代码顺序发出表。但你不能依赖这种行为;要么改变,恕不另行通知。

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

答案 4 :(得分:2)

试图深入研究“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是一个构造类型时才会出现这种情况,其中泛型类型中声明的两个成员具有不同的签名,但类型参数使它们的签名相同。