泛型类型参数的协变性和多接口实现

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

然而,将x转换为IGeneric<Base>,会得到以下结果:
IGeneric<Base> b = x;
Console.WriteLine(b.GetName());   //Derived1

我原本期望编译器会发出错误提示,因为这个调用在两个实现间不明确,但是它返回了第一个声明的接口。

为什么允许这样的情况呢?

(灵感来源于一个类实现两个不同的IObservables?。我试图向同事展示这将失败,但不知何故,它没有失败)


关于 Console.WriteLine(b.GetName());编译器 无法发出任何错误;它有一个 IGeneric<Base> 来调用 getName,这是完全有效的调用。 - Miserable Variable
@MiserableVariable 编译器不仅有一个有效的实现,而是有两个。在其他情况下,您可能会在编译时出现模糊调用错误,但在这种情况下,您不会出现这种错误,而是得到未指定的行为。 - SWeko
@SWeko 编译器只查看 b静态 类型,即 IGeneric<Base>,在该类型上调用 GetName 是有效的。如果您认为错误应该出现在 DoubleDown 中,那么这不是一个错误,因为有一个明确定义的规则,即匹配是未指定的。 - Miserable Variable
@MiserableVariable,就像我在其他答案中所说的那样,这种情况在#13.4.4的两个要点中都没有涉及。 - SWeko
2
这与我之前提出的一个问题非常相似(由jam40jeff的答案下面链接)。还要注意Eric Lippert在他的C# 4.0之前的博客文章Covariance and Contravariance in C#: Dealing With Ambiguity中提到了这个问题。他假设IEnumerable<>是协变的,并创建了一个既是IEnumerable<Giraffe>又是IEnumerable<Turtle>的类C。然后,通过协变,该类的实例就成为了IEnumerable<Animal>。因此,存在相同的歧义。 - Jeppe Stig Nielsen
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";
    }
}

你一定已经意识到实际结果会根据声明接口的顺序而改变。但我要说这只是未指明的。

首先,规范(§13.4.4 Interface mapping)表示:

  • 如果有多个成员匹配,则未指定哪个成员是 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

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


你所说的意思是它应该有一个名为 IGeneric<Base>.GetName() 的方法,但实际上它并没有。即使它确实有这个方法,仍然会在运行时选择(映射)实际契约,并落入 §13.4.4 中所述的第二种情况。所有实现都不会导致编译问题,但当前的实现更简单。 - Ken Kin
我完全同意@SWeko的观点,这是规范中的一个缺陷;如果您查看C++标准,这些内容会有更详细的描述。原因在于用于协变的接口的优先级与它们在类型上定义的顺序相同 - 这与它们在方法级别上绑定的方式无关(可以通过typeof(DoubleDown).GetInterfaces()观察到,并且IL明确定义了方法/接口映射)。处理此歧义的最佳解决方案是在规范中描述确切的行为。 - atlaste
@KenKin 如果这很重要,而不是实现细节:是的。这意味着例如 Mono 在这种特定情况下可能与 .NET 表现不同(顺便说一句,我还没有测试过)。 - atlaste
@Stefan de Bruijn:我可以告诉你原因,但是“specification flawn”是我无法解释的范畴之外。 - Ken Kin
3
在规范中设定“明确定义的疑虑区域”是明智的,这样做有多种原因;说明某种行为是“由实现定义”的,并不一定是规范上的缺陷。请参见http://blogs.msdn.com/b/ericlippert/archive/2012/06/18/implementation-defined-behaviour.aspx,了解其中的一些讨论问题。 - Eric Lippert
显示剩余4条评论

26

编译器无法在此行抛出错误

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时可能会出现编译器抛出错误的情况,但这会消除无需解决方案即可完成的潜在有用操作。

我在其他地方更详细地回答了这个问题,并提供了一个可能的解决方案,如果语言可以改变:

当逆变导致歧义时没有警告、错误(或运行时故障)

考虑到语言支持此功能的机会几乎为零,我认为当前的行为是可以接受的,只是应该在规范中明确说明,以便CLR的所有实现都应该以相同的方式行事。


1
很好的回答!我已经点赞了。你提供了更深入的解释来说明事实。在SWeko和我之间的评论中,我回答说当前的实现更简单 - Ken Kin
编译器应该在什么时候抛出错误?当你将 DoubleDown 隐式转换为 IGeneric<Base> 时,编译器应该抛出错误,因为这是不明确的(编译器不知道要使用哪个接口)。 - Ark-kun
@jam40jeff 第二种情况没问题。当你将 DoubleDown 转换为 IGeneric<Derived1> 时,你明确选择了 IGeneric<Derived1> 的实现。这里没有任何歧义。 - Ark-kun
1
@Ark-kun 你正在从一个方法的上下文中考虑问题(即使编译器也不会跟踪变量中的运行时类型。当变量是 IGeneric<Base> 类型时,你还没有“选择”要使用哪个实现。 - jam40jeff
+1 伙计们讨论得不错 - 短缺像 IGeneric<IDerived1>, IGeneric<IDerived2> default 这样的东西 - 对于“默认值”来说,这是个好选择。或者我个人不会编译这个类 - 在我看来,其他所有方法都不是解决方案。 - NSGaga-mostly-inactive
显示剩余5条评论

11
问题是“为什么这不会产生编译器警告?”,在VB中,它会(我实现了它)。 类型系统没有足够的信息在调用时提供有关方差模糊的警告。因此,必须更早地发出警告...
1. 在VB中,如果声明一个类C,该类实现IEnumerable(Of Fish)和IEnumerable(Of Dog),则会发出警告,说在常见情况IEnumerable(Of Animal)中两者会冲突。这已足以消除完全使用VB编写的代码中的方差二义性。 但是,如果问题类是在C#中声明的,则无法提供帮助。还请注意,如果没有人在其上调用有问题的成员,则完全可以声明这样的类。
2. 在VB中,如果将这种类C强制转换为IEnumerable (Of Animal),则会在转换时发出警告。这已足以消除方差歧义,即使您从元数据导入了问题类。 但是,它是一个较差的警告位置,因为它不可操作:您无法更改转换。唯一可操作的警告对于人们来说将是返回并更改类定义。还请注意,如果没有人在其上调用有问题的成员,则完全可以执行这样的转换。
问题是:“为什么VB会发出这些警告,但C#不会?” 当我将它们放入VB中时,我对形式化计算机科学充满热情,只编写了几年编译器,并且有时间和热情将它们编码。

Eric Lippert 在 C# 中为它们编写了警告。他明智而成熟地认识到,在编译器中编写此类警告需要花费大量时间,而这些时间可以更好地用在其他方面,并且足够复杂,存在高风险。事实上,VB 编译器中也存在这些警告的错误,直到 VS2012 才修复。

此外,坦率地说,很难想出足够有用以至于人们能理解的警告消息。顺便提一下:

  • 问题:

    CLR 如何解决选择调用哪个方法的歧义?

    答案:

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


6
你非常善良,Lucian,有点自责;这是一个棘手的决定,我可以看到双方的争论。我注意到我确实在 C# 中添加了一个警告,因为存在一种不幸的类型统一而导致实现定义行为的类似情况。链接:http://blogs.msdn.com/b/ericlippert/archive/2006/04/06/570126.aspx - Eric Lippert

11

哇,这里有很多非常好的答案来回答一个相当棘手的问题。总结如下:

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

我只想补充一件事,那就是:什么是坏消息,接口重新实现语义在这些模糊情况下与CLI规范中指定的行为并不完全匹配。好消息是,当重新实现存在此类模糊性的接口时,CLR的实际行为通常是您想要的行为。发现这个事实引发了我、安德斯和一些CLI规范维护者之间的激烈辩论,最终结果是没有对规范或实现进行更改。由于大多数C#用户甚至不知道什么是接口重新实现,我们希望这不会对用户产生不利影响。 (没有客户提醒我过。)


如果类Foo实现了IEnumerable<Bar>,而类DerivedFoo实现了IEnumerable<DerivedBar>,那么所描述的情况是否适用?是否有任何实际手段可以避免创建模糊的绑定,同时允许将DerivedFoo传递给需要不仅是Bar类型,而且更具体地是DerivedBar类型的可枚举对象的代码? - supercat

2
尝试深入了解"C#语言规范",看起来行为没有被指定(如果我没有迷路的话)。
7.4.4函数成员调用
函数成员调用的运行时处理包括以下步骤,其中M是函数成员,如果M是实例成员,则E是实例表达式:
[...]
o 确定要调用的函数成员实现:
• 如果E的编译时类型是接口,则要调用的函数成员是由E引用的实例的运行时类型提供的M的实现。通过应用接口映射规则(§13.4.4)来确定这个函数成员。
13.4.4接口映射
类或结构C的接口映射会为C中基类列表中指定的每个接口的每个成员定位一个实现。对于特定接口成员I.M,其中I是声明成员M的接口,通过检查每个类或结构S来确定其实现,从C开始,并针对C的每个后续基类重复此操作,直到定位到匹配项:
• 如果S包含与I和M匹配的显式接口成员实现的声明,则该成员是I.M的实现。
• 否则,如果S包含与M匹配的非静态公共成员的声明,则该成员是I.M的实现。如果有多个成员匹配,则未指定哪个成员是I.M的实现。只有在S是构造类型,其中两个成员在泛型类型中声明具有不同的签名,但类型参数使其签名相同时,才会出现这种情况。

2
我认为这不是这样的。接口映射是关于决定类中哪个方法最终实现了每个在接口中声明的方法,更不用说这里涉及的成员不是公共的(接口是显式实现的)。 - Jon
谢谢 Jon。我编辑了答案,方法调用解析使用相同的机制,检查运行时类型。 - Teudimundo
1
那句话似乎在这里很相关。但我认为行为实际上是由第一个要点解释的:DoubleDown 的基接口依次搜索,直到找到映射到 IGeneric<Base>.GetName() 的方法。因此,方法调用映射到 IGeneric<Derived1>.GetName(),因为该接口首先出现在继承接口列表中。 - Jon
2
确实,第一个指的是显式方法。但这里说的是映射迭代C的基类,我不认为接口也会涉及到。无论如何,我对第二点在这种情况下是否相关并不确定。 - Teudimundo
第二点在这种情况下不适用,因为 EIMI 在第一点中被明确排除。在这种情况下,我们实际上在第一点上有多个匹配项,因此第二点甚至不被考虑。 - SWeko
Mads和我(我认为Lucian也在这个问题上发表了意见!)制定了规范中的第二个要点,以处理我在这里描述的情况:http://blogs.msdn.com/b/ericlippert/archive/2006/04/06/570126.aspx - Eric Lippert

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