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

43

首先要记住,.NET中的String既实现了IConvertible接口,也实现了ICloneable接口。

现在,考虑以下相当简单的代码:

//contravariance "in"
interface ICanEat<in T> where T : class
{
  void Eat(T food);
}

class HungryWolf : ICanEat<ICloneable>, ICanEat<IConvertible>
{
  public void Eat(IConvertible convertibleFood)
  {
    Console.WriteLine("This wolf ate your CONVERTIBLE object!");
  }

  public void Eat(ICloneable cloneableFood)
  {
    Console.WriteLine("This wolf ate your CLONEABLE object!");
  }
}

那么请尝试以下操作(在某个方法内部执行):
ICanEat<string> wolf = new HungryWolf();
wolf.Eat("sheep");

当编译此代码时,不会出现编译错误或警告。运行时,它看起来像是调用的方法取决于我在HungryWolfclass声明中接口列表的顺序。(尝试交换逗号()分隔列表中的两个接口。)
问题很简单:这不应该给出编译时警告(或在运行时抛出异常)吗? 我可能不是第一个想到编写此类代码的人。我使用了接口的逆变性,但您可以使用接口的协变性创建完全类似的示例。事实上,Lippert先生很久以前就做到了这一点。在他的博客评论中,几乎每个人都认为这应该是一个错误。然而,他们默默地允许这样做。为什么? --- 扩展问题: 以上我们利用了String既是Iconvertible(接口)又是ICloneable(接口)的事实。这两个接口都不是从另一个接口派生的。
现在这里有一个基类的例子,在某种意义上,更糟糕一些。
请记住,StackOverflowException既是SystemException(直接基类),也是Exception(基类的基类)。然后(如果ICanEat<>与之前相同):
class Wolf2 : ICanEat<Exception>, ICanEat<SystemException>  // also try reversing the interface order here
{
  public void Eat(SystemException systemExceptionFood)
  {
    Console.WriteLine("This wolf ate your SYSTEM EXCEPTION object!");
  }

  public void Eat(Exception exceptionFood)
  {
    Console.WriteLine("This wolf ate your EXCEPTION object!");
  }
}

使用以下方式测试:

static void Main()
{
  var w2 = new Wolf2();
  w2.Eat(new StackOverflowException());          // OK, one overload is more "specific" than the other

  ICanEat<StackOverflowException> w2Soe = w2;    // Contravariance
  w2Soe.Eat(new StackOverflowException());       // Depends on interface order in Wolf2
}

仍然没有警告、错误或异常。仍然依赖于类声明中接口列表的顺序。但我认为这次更糟糕的原因是,有人可能会认为重载解析总是会选择SystemException,因为它比Exception更具体。


奖励开放之前的状态:两个用户提供了三个答案。

奖励截止日当天的状态:仍未收到新答案。如果没有答案出现,我将不得不把奖励授予Moslem Ben Dhaou。


14
Stack Overflow 需要一个新的标记选项:"针对 Jon Skeet 标记"。 - Bobson
1
写那样的代码真是太傻了 :) - leppie
我有点理解这个问题。您希望类型参数显式绑定到具体类型。 - leppie
2
@leppie:你曾经参与过一个项目,不用写愚蠢的代码吗? - Stefan Steinegger
1
@OlivierJacot-Descombes(注意:wolf既不是ICloneable也不是IConvertible。它以两种方式实现了ICanEat<>接口。)请自行尝试。拿走我的HungryWolf代码。将两个公共方法更改为显式接口实现。运行包含Eat("sheep")的相同两行代码。你如何知道你的两个显式接口实现中的哪一个被运行?当你尝试后,请再次发表评论。 - Jeppe Stig Nielsen
显示剩余8条评论
4个回答

9
我认为编译器在VB.NET中的警告处理得更好,但我仍然认为这还不够。不幸的是,“正确的方式”可能要么禁止某些潜在有用的东西(使用两个协变或逆变泛型类型参数实现相同接口),要么引入语言中的新内容。
目前,编译器除了在HungryWolf类中分配错误之外,没有其他地方可以分配错误。这是一个类声称知道如何以一定的方式吃ICloneable或任何实现或继承自它的东西。
并且,我也知道如何以一定的方式吃IConvertible或任何实现或继承自它的东西。
然而,它从未说明如果在其盘子上收到既是ICloneable又是IConvertible的内容时应该做什么。如果给它一个HungryWolf实例,这不会给编译器带来任何麻烦,因为它可以确定地说“嘿,我不知道在这里该怎么做!”但是当给出ICanEat实例时,它会给编译器带来困扰。编译器不知道变量中对象的实际类型,只知道它确实实现了ICanEat接口。
不幸的是,当HungryWolf存储在该变量中时,它模糊地两次实现了完全相同的接口。因此,我们肯定不能在尝试调用ICanEat.Eat(string)时抛出错误,因为该方法存在并且对于可以放入ICanEat变量的许多其他对象来说是完全有效的(batwad在他的答案中已经提到过这一点)。
此外,尽管编译器可能会抱怨将 HungryWolf 对象分配给 ICanEat<string> 变量的赋值存在歧义,但它无法阻止它在两个步骤中发生。可以将 HungryWolf 赋值给 ICanEat<IConvertible> 变量,该变量可传递到其他方法并最终分配到 ICanEat<string> 变量中。这两种方式都是完全合法的赋值方式,编译器无法对其进行投诉。
因此,第一种选择是,在 ICanEat 的泛型类型参数是逆变时,禁止 HungryWolf 类同时实现 ICanEat<IConvertible>ICanEat<ICloneable> 接口,因为这两个接口可能被统一。然而,这删除了使用有用代码的能力,没有其他替代方法。

第二个选项 需要对编译器进行改变,包括IL和CLR。它将允许 HungryWolf 类实现两个接口,但也需要实现 ICanEat<IConvertible & ICloneable> 接口,其中泛型类型参数实现了这两个接口。这可能不是最佳语法(Eat(T) 方法的签名是什么样子的?Eat(IConvertible & ICloneable food)?)。更好的解决方案可能是在实现类上自动生成泛型类型,使类定义如下:

class HungryWolf:
    ICanEat<ICloneable>, 
    ICanEat<IConvertible>, 
    ICanEat<TGenerated_ICloneable_IConvertible>
        where TGenerated_ICloneable_IConvertible: IConvertible, ICloneable {
    // implementation
}

然后需要更改IL,以便能够像泛型类一样构造接口实现类型,用于 callvirt 指令:

.class auto ansi nested private beforefieldinit HungryWolf 
    extends 
        [mscorlib]System.Object
    implements 
        class NamespaceOfApp.Program/ICanEat`1<class [mscorlib]System.ICloneable>,
        class NamespaceOfApp.Program/ICanEat`1<class [mscorlib]System.IConvertible>,
        class NamespaceOfApp.Program/ICanEat`1<class ([mscorlib]System.IConvertible, [mscorlib]System.ICloneable>)!TGenerated_ICloneable_IConvertible>

CLR需要通过构建一个带有string作为TGenerated_ICloneable_IConvertible的通用类型参数的HungryWolf接口实现来处理callvirt指令,并检查它是否比其他接口实现更匹配。

对于协变,所有这些都会更简单,因为需要实现的额外接口不必是具有约束的通用类型参数,而只需是两个其他类型之间最派生的基类型,这在编译时已知。

如果同一接口被实现超过两次,则需要实现的额外接口数量呈指数级增长,但这是在单个类上实现多个逆变(或协变)所需的灵活性和类型安全性的代价。

我怀疑这将进入框架,但这是我的首选解决方案,特别是因为新的语言复杂性始终限制于希望执行当前危险操作的类。


编辑:
感谢Jeppe提醒我,由于必须考虑公共接口,协变性和逆变性并不比较简单。在stringchar[]的情况下,最大共性集合将是{object, ICloneable, IEnumerable<char>} (IEnumerableIEnumerable<char>覆盖)。

然而,这将需要一种新的语法来表示接口泛型类型参数约束,以指示泛型类型参数只需要:

  • 继承指定类或实现至少一个指定接口的类
  • 实现至少一个指定接口

可能会像这样:

interface ICanReturn<out T> where T: class {
}

class ReturnStringsOrCharArrays: 
    ICanReturn<string>, 
    ICanReturn<char[]>, 
    ICanReturn<TGenerated_String_ArrayOfChar>
        where TGenerated_String_ArrayOfChar: object|ICloneable|IEnumerable<char> {
}

在这种情况下(其中一个或多个接口是共同的),泛型类型参数TGenerated_String_ArrayOfChar始终必须被视为object,即使公共基类已经派生自object;因为公共类型可能实现一个共同的接口而不继承公共基类

已接受并点赞。关于协变使事情更简单的问题:假设您有一个实现 ICanProduce<string>ICanProduce<char[]> 的类,其中 ICanProduce<out TProduct> 是泛型参数中的 "out"。显然,stringchar[] 的最派生公共基类是明确定义的(它是 System.Object)。但是也有接口。ICloneablestringchar[] 的共同接口。但是 IEnumerableIEnumerable<char> 也是如此。因此,即使可以轻松确定“最后”公共基类,我认为我们仍然面临接口的问题。 - Jeppe Stig Nielsen
+1 非常棒的解释。这绝对值得更高的评价。 - atlaste
我已经修改了这个答案。这是一个关于方差的问题,我试图解决:[评估最小协方差类型以适应两种类型]。 - Ken Kin
1
感谢Ken重新格式化和清理了一些东西。我回过头来尝试进一步清理了一些东西。此外,我将“正确的事情”中的一个加粗(改为引号)以更好地传达我的意思,即“主观上正确的事情”,而不是强调要字面理解。 - jam40jeff
1
这并不比修订在您的协议范围内更好。干杯! - Ken Kin

6
在这种情况下,编译器错误无法生成,因为代码是正确的,只要不同时继承两个内部类型的所有类型都应该能够正常运行。如果您同时从类和接口继承(即在代码中使用base object类),问题也是一样的。
由于“Interface ICustom(Of In T)”中的“In”和“Out”参数,“ICustom(Of Foo)”接口与另一个实现的接口“ICustom(Of Boo)”存在歧义,因此出现了VB.Net编译器警告。
我同意C#编译器也应该抛出类似的警告。请参考这个Stack Overflow question。Lippert先生确认,运行时将选择一个,并且应避免这种编程方式。

1
“Cannot be” 是一个强有力的陈述。你提供的关于为什么会生成这样一个警告的解释是不充分的,无法证明你的观点。 - Hari
我的解释是为什么编译器错误不能生成警告。我清楚地说明了C#编译器应该抛出类似VB.Net编译器的警告。 - Moslem Ben Dhaou
你提供的讨论串很有趣且相关!但这并没有回答我的问题。 - Jeppe Stig Nielsen
1
你还没有解决问题。问题不在Wolf类上,而是使用了逆变接口。编译器应该发现有两个匹配的方法可以被调用,因此会出现编译器错误。如果类型没有实现这两个接口,那么它应该运行,就好像重载方法是可以的,只要参数不超过1个。尝试调用new HungryWolf().Eat("sheep"),你会得到一个模棱两可的调用错误。这有什么不同吗? - Robert Slaney
如果您使用的T仅从一个接口继承,则运行时和编译器都将知道要使用哪个方法(因为只能使用一个,因为第二个不匹配签名),因此代码对编译器是可接受的。尽管在这种情况下应该像VB一样抛出警告。 - Moslem Ben Dhaou
"Sheep" 是一个字符串,并且仍然从两个接口继承。因此,这与我的解释不符。如果您创建了一个从 ICloneable 继承但不从 IConvertible 继承的类型,则不会有问题。 - Moslem Ben Dhaou

4

这里有一个我想到的关于缺乏警告或错误提示的解释,而我甚至还没有喝酒!

将您的代码进一步抽象化,意图是什么?

ICanEat<string> beast = SomeFactory.CreateRavenousBeast();
beast.Eat("sheep");

你正在喂养某个东西。野兽实际上如何进食取决于它自己。如果给它汤,它可能会决定使用勺子。它可能会用刀叉吃羊肉。它可能会用牙齿把它撕成碎片。但重点是,作为这个类的调用者,我们不关心它如何进食,我们只想让它被喂养。
最终,野兽决定如何食用所给予的东西。如果野兽决定可以吃一个ICloneable和一个IConvertible,那么它就需要找出如何处理这两种情况。为什么管理员要关心动物如何吃饭呢?
如果这确实导致编译时错误,则实现新接口将成为一种破坏性变化。例如,如果你将HungryWolf作为ICanEat进行发布,然后几个月后添加ICanEat,则会破坏每个使用实现了这两个接口的类型的地方。
这是双向的。如果泛型参数只实现了ICloneable,则它将与wolf相当愉快地工作。测试通过,发布,谢谢您,Wolf先生。明年你为你的类型添加IConvertible支持,然后BANG!这个错误真的没有什么帮助。

3
当你添加了另一个接口并且现有的调用Eat(string)的代码正在调用——很可能是——另一种实现时,为什么会这样呢?因为您在现有接口之前添加了新接口。有人说过接口的顺序很重要吗?引发错误可能会使添加接口成为一个破坏性变更。但忽略这种歧义则成为一种赌博。 - Stefan Steinegger
这就是隐式和显式接口方法之间的区别,也是我认为隐式版本非常邪恶并且应该避免的原因所在。让我们还实现ICanEat<IComparable>。有些类型既是IComparable又是IConvertible。至少显式版本(void ICanEat<IComparable>.Eat(IComparable comparable) {})不会导致编译失败,如果你直接使用HungryWolf的话。 - Robert Slaney
这个答案可能不是原因。请看我的另一个答案:ICanEat<T>不是模糊的,而HungryWolf是。 - batwad
术语“未定义行为”是指描述一些情况,其中没有任何保证并且任何事情都可能发生(崩溃、损坏的硬盘驱动器、猫和狗之间的和平共处等)。 这里的情况远非如此严重。 ICanEat<string>.Eat 是否绑定到 ICanEat<ICloneable>.Eat,或者绑定到 ICloneable<IConvertible>.Eat,或者有时绑定到一个,有时绑定到另一个,这是未指定的,但是对 ICanEat<string>.Eat 的任何特定调用将导致对前述例程之一的唯一调用。 - supercat
顺便说一下,有很多情况存在歧义,能够明确地偏向一个绑定会很有帮助,但即使编译器随意选择一个绑定而不是拒绝执行任何操作也比较有用。例如,需要具有“Count”方法的代码可以接受“ICollection”或“ICollection<T>”。如果有一个方法接受“ICollection”,另一个方法接受名称不同的“ICollection<T>”,那么实现这两个接口的东西可以传递给任何一个方法,然后... - supercat
显示剩余8条评论

2
另一个解释:没有歧义,因此没有编译器警告。
请耐心等待。
ICanEat<string> wolf = new HungryWolf();
wolf.Eat("sheep");

没有错误是因为ICanEat<T>只包含一个名为Eat的方法,且参数正确。但如果改成以下内容,就会出现错误:

HungryWolf wolf = new HungryWolf();
wolf.Eat("sheep");

HungryWolf是不明确的,而不是ICanEat<T>。如果你期望编译器出现错误,那么你正在要求编译器在调用Eat时查看变量的值,并确定它是否模棱两可,这不一定是它可以推断出来的。考虑一个只实现ICanEat<string>的类UnambiguousCow

ICanEat<string> beast;

if (somethingUnpredictable)
    beast = new HungryWolf();
else
    beast = new UnambiguousCow();

beast.Eat("dinner");

编译器错误会在哪里以及如何引发?


也许我期望编译器错误出现在 new HungryWolf()(类型为 HungryWolf 的表达式)被逆变地转换为 ICanEat<string> 时。这是一个很好的问题,但我觉得应该有一些编译器消息“存在”。你是否知道如果你这样说 class Eater<T, S> { public void Eat(T t) { } public void Eat(S s) { } } 然后从构造的泛型类型派生,就像这样:class Problem : Eater<string, string>, ICanEat<String> { }。当 TS 相同时,Eater<T, S> 的两个方法将统一,在这种情况下编译器会关心! - Jeppe Stig Nielsen
我猜编译器只是想确保将 HungryWolf 转换为 ICanEat<string> 是有效的转换,而它确实是。这个转换有歧义吗?我不确定。当编译器在绑定方法时引发歧义错误时,从我记得的情况来看,通常是因为方法绑定。Eat 方法在 ICanEat<string> 上不是模棱两可的,而 HungryWolf 的实现方式与你的 Problem 类不同,尽管它生成了警告 CS1956,但听起来像是 HungryWolf 应该在某个地方生成的东西。好问题! - batwad
你说Eat方法不会产生歧义。那么编译器应该使用这两个重载中的哪一个呢?是ate your CONVERTIBLE还是ate your CLONEABLE - Jeppe Stig Nielsen
这并不含糊,因为您已将 beast 定义为 ICanEat<string>,并且在该接口上只有一个名为 Eat 的方法,它需要一个 string 参数,而您正在传递 "dinner"。没有任何歧义。 - batwad

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