繁体   English   中英

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

[英]Generic type parameter covariance and multiple interface implementations

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

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

但是,将xIGeneric<Base>会得到以下结果:

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

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

为什么这是允许的?

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

如果您已经测试了以下两个:

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:不可以。您通过通用协变接口定义调用了该方法。

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

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

以下为实测截图部分

在此处输入图片说明

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

在此处输入图片说明在此处输入图片说明

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

编译器不能抛出错误就行

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>提供实现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的定义上DoubleDown ,编译器不会在任何时候抛出错误,但这将消除在没有解决方法的情况下做一些可能有用的事情的可能性。

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

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

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

问题是,“为什么这不会产生编译器警告?”。 在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)

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

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

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

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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM