为什么我的C#数组在转换为对象时丢失了类型标志信息?

43

调查一个bug,我发现这是由c#中的这个奇怪的问题引起的:

sbyte[] foo = new sbyte[10];
object bar = foo;
Console.WriteLine("{0} {1} {2} {3}",
        foo is sbyte[], foo is byte[], bar is sbyte[], bar is byte[]);
输出结果为“True False True True”,然而我本来期望"bar is byte[]"返回False。显然,bar既是一个byte[]又是一个sbyte[]?其他有符号/无符号类型(如Int32[]UInt32[])也会出现相同的情况,但对于Int32[]Int64[]这样的情况则不会出现。

有人能解释一下这种行为吗?这是在.NET 3.5中发生的。

4个回答

70

更新:我已将这个问题作为博客文章的基础,链接在此:

https://web.archive.org/web/20190203221115/https://blogs.msdn.microsoft.com/ericlippert/2009/09/24/why-is-covariance-of-value-typed-arrays-inconsistent/

请查看博客评论以获得有关此问题的详细讨论。感谢您提出这个好问题!
您发现了CLI类型系统和C#类型系统之间有趣而不幸的不一致性。
CLI有“赋值兼容”的概念。如果已知数据类型S的值x与已知数据类型T的特定存储位置y“赋值兼容”,则可以将x存储在y中。否则,这样做是不可验证的代码,验证器将禁止它。
例如,CLI类型系统表示引用类型的子类型与引用类型的超类型是赋值兼容的。如果有一个字符串,您可以将其存储在类型为object的变量中,因为两者都是引用类型,而string是object的子类型。但相反的情况并不成立;超类型与子类型不兼容。您不能将仅已知为对象的内容放入类型为字符串的变量中,而不先进行转换。
基本上,“赋值兼容”意味着“将这些确切的位插入此变量是有意义的”。源值到目标变量的赋值必须“保留表示”。有关详细信息,请参见我的文章:

http://ericlippert.com/2009/03/03/representation-and-identity/

CLI的规则之一是"如果X与Y可以赋值兼容,则X[]与Y[]也可以赋值兼容"。

也就是说,数组在赋值兼容方面是协变的。但实际上这是一种有缺陷的协变性;请参阅我的文章以了解详细信息。

https://web.archive.org/web/20190118054040/https://blogs.msdn.microsoft.com/ericlippert/2007/10/17/covariance-and-contravariance-in-c-part-two-array-covariance/

这不是C#的一个规则。C#的数组协变规则是“如果X是隐式可转换为引用类型Y的引用类型,则X[]隐式可转换为Y[]”。那是一个微妙不同的规则,因此存在混淆情况。
在CLI中,uint和int可以赋值兼容。但在C#中,int和uint之间的转换是显式的,而不是隐式的,并且这些都是值类型,而不是引用类型。因此,在C#中,将int[]转换为uint[]是不合法的。
但在CLI中是合法的。现在我们面临选择。
1. 实现“is”,以便当编译器无法静态确定答案时,它实际上调用一个方法来检查所有C#对保持标识性转换的规则。这很慢,99.9%的时间与CLR规则匹配。但我们承受性能损失,以便100%符合C#的规则。
2. 实现“is”,以便当编译器无法静态确定答案时,它执行极快的CLR分配兼容性检查,并接受一个uint[]是int[]的事实,即使在C#中实际上并不合法。
我们选择了后者。不幸的是,C#和CLI规范在这个小点上存在分歧,但我们愿意接受不一致性。

3
嗨,Eric,出于好奇,你们是决定接受这种不一致性还是之前没有预料到呢?只是想知道。 - Joan Venge
6
@Joan:我不知道,那是在我的时间之前。请记住,C#和CLR同时在演进,基于对语言和运行时规则的不完整信息做出了各种决策。我的猜测是这个问题可能只是“掉进了裂缝”,等到我们意识到它时已经太晚了。不过这只是一个猜测。在1999年的最初语言设计笔记档案中没有任何关于这个问题的内容。 - Eric Lippert
5
很好的问题。显然,我们更喜欢规范和实现相同。在它们不同的地方,我们更希望规范表明我们想要的事情是真实存在的。如果我们让规范说明一项我们不喜欢且不想要的语言特性,以便使实现一致,那么怎么办呢?(当然,这将牵涉到删除所有强制执行所需语义的现有编译时检查。)在这些选择中,我们宁愿保持不一致。这是最小的一种恶。 - Eric Lippert
5
如果我们从不需要考虑任何向后兼容性问题,那么是的,我们可以通过引入破坏性更改来修复问题和进行改进,而成本将大大降低。但这与事实相反。事实是,破坏性更改极大地增加了升级成本。我们努力降低您的升级成本,以鼓励升级到更好的产品。有时进行破坏性更改所带来的益处是值得成本的,但你不能只是希望成本消失。 - Eric Lippert
1
@shansfk:有两个原因。首先,假设我们有Animal、Tiger和Giraffe。你能用老虎的列表作为动物的列表吗?不行。为什么?因为你可以把长颈鹿放进动物列表里,但不能放进老虎列表里。List<T>必须是不变的。你可以使用IEnumerator<Tiger>作为IEnumerator<Animal>,因为没有办法将长颈鹿放入枚举器中。你只能拿出老虎,所以IEnumerator<T>可以是协变的。 - Eric Lippert
显示剩余8条评论

10

我将片段放到了Reflector中:

sbyte[] foo = new sbyte[10];
object bar = foo;
Console.WriteLine("{0} {1} {2} {3}", new object[] { foo != null, false, bar is sbyte[], bar is byte[] });

C#编译器正在优化前两个比较(foo is sbyte[]foo is byte[])。正如您所看到的,它们已经被优化为foo != null,并且始终为false


4

还有其他有趣的内容:

    sbyte[] foo = new sbyte[] { -1 };
    var x = foo as byte[];    // doesn't compile
    object bar = foo;
    var f = bar as byte[];    // succeeds
    var g = f[0];             // g = 255

我这里漏掉了些什么吗?不是这是你所期望的吗?那么哪里有问题呢? - cdm9002
不是 g = 255,这是预期的,而是 bar as byte[] 不会返回 null。 - Ben M
3
好的 - 现在你已经阅读了我的答案,你可以推断出这里发生了同样的事情。对于第一个,我们知道在编译时它违反了C#的规则。对于第二个,我们不知道。所以我们要么(1)发出一个实现C#语言所有规则来进行转换的方法,要么(2)使用CLR转换规则,这些规则与C#的规则在一些奇怪的情况下略有不同。我们选择了(2)。 - Eric Lippert

-1

输出肯定是正确的。bar既可以是sbyte[]也可以是byte[],因为它与两者都兼容,由于bar只是一个对象,所以它“可能是”有符号或无符号的。

“is”被定义为“表达式可以转换为类型”。


2
但是由于bar的类型是object,任何其他类型的基本类型,这意味着您可以说bar is <任何给定类型>并且它将返回true,但事实并非如此。为什么您可以将sbyte[]强制转换为byte[],只是因为它恰好通过引用类型? - Fredrik Mörk

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