44

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

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

但是,将 to 强制转换xIGeneric<Base>产生以下结果:

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

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

为什么允许这样做?

(受A 类实现两个不同的 IObservables 的启发?我试图向同事表明这会失败,但不知何故,它没有)

4

5 回答 5

27

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

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";
    }
}

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

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

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

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

  • Q1:你们的通用接口有不同的签名吗?
    A1:是的。它们是IGeneric<Derived2>IGeneric<Derived1>

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

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

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

以下为实测截图部分

在此处输入图像描述

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

在此处输入图像描述 在此处输入图像描述

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

于 2013-02-05T17:05:22.823 回答
25

编译器不能就行抛出错误

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

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

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

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

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

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

再一次,许多消费者可以打电话CallIt或打电话CallItOnDerived1就可以了。但是我们的消费者传递DoubleDown也正在进行一个完全合法的调用,当他们调用时不会导致编译器错误,CallItOnDerived1因为转换 from DoubleDowntoIGeneric<Derived1>当然应该没问题。因此,除了可能在 的定义上之外,编译器不会在任何时候抛出错误DoubleDown,但这将消除在没有解决方法的情况下做一些可能有用的事情的可能性。

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

当逆变导致歧义时,没有警告或错误(或运行时失败)

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

于 2013-02-09T07:32:18.433 回答
11

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

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

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

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

  2. 在 VB 中,如果您从这样的类执行强制C转换为IEnumerable(Of Animal),那么它会在强制转换时发出警告。即使您从 metadata 导入问题类,这也足以消除方差歧义。

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

  • 问题:

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

    回答:

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

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

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

  • 问题:

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

    回答:

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

于 2013-03-13T19:02:37.093 回答
11

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

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

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

于 2013-03-13T21:18:23.190 回答
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 匹配的显式接口成员实现的声明,则该成员是 IM 的实现

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

于 2013-01-28T13:26:10.157 回答