为什么协变和逆变不支持值类型

165

IEnumerable<T>协变的,但它不支持值类型,只支持引用类型。以下简单代码可以成功编译:

IEnumerable<string> strList = new List<string>();
IEnumerable<object> objList = strList;

但是将类型从string更改为int会导致编译错误:

IEnumerable<int> intList = new List<int>();
IEnumerable<object> objList = intList;

原因在于 MSDN 中有解释:

协变性仅适用于引用类型;如果为变体类型参数指定值类型,则该类型参数对于生成的构造类型是不变的。

我搜索并发现一些问题提到了原因是值类型和引用类型之间的装箱(boxing)。但这并没有清楚地解释为什么装箱是原因。

请问能否给出一个简单详细的解释,说明为什么协变性和逆变性不支持值类型,以及装箱如何影响这一点?


3
Eric在Stack Overflow上对我类似问题的回答也值得参考:https://dev59.com/W2855IYBdhLWcg3w5Igb - thorn0
1
可能是cant-convert-value-type-array-to-params-object的重复问题。 - nawfal
4个回答

134

基本上,当CLR可以确保不需要对值进行任何表现形式的更改时,方差应用。引用看起来都一样 - 因此您可以使用IEnumerable<string>作为IEnumerable<object>而不需要任何表示形式的更改;本地代码本身不需要知道您正在做什么,只要基础架构已经保证它肯定有效。

对于值类型,这是行不通的 - 要将IEnumerable<int>视为IEnumerable<object>,使用序列的代码必须知道是否执行装箱转换。

您可能希望阅读Eric Lippert关于表示和标识的博客文章,以了解更多有关这个主题的信息。

编辑:我自己重新阅读了Eric的博客文章,至少它与标识一样重要,尽管两者是相关的。特别是:

这就是协变和逆变接口和委托类型所需求的。为了确保一个变体引用转换始终是保留标识的,所有涉及类型参数的转换也必须保留标识。确保所有关于类型参数的非平凡转换都保留标识最简单的方法是将它们限制为引用转换。


5
@CuongLe:在某种意义上来说,这是一个实现细节,但我认为这是限制的根本原因。 - Jon Skeet
2
@AndréCaron:Eric的博客文章在这里非常重要——它不仅涉及到表示,还包括标识保留。但是表示保留意味着生成的代码根本不需要关心这一点。 - Jon Skeet
1
准确地说,无法保留标识,因为“int”不是“object”的子类型。需要表示性更改只是这一事实的结果。 - André Caron
3
为什么 int 不是 object 的子类型? Int32 继承自 System.ValueType,而 System.ValueType 又继承自 System.Object。 - David Klempfner
1
@DavidKlempfner 我认为 @AndréCaron 的评论措辞不太恰当。任何值类型,例如 Int32 都有两种表示形式,“装箱”和“拆箱”。编译器必须插入代码以从一种形式转换为另一种形式,尽管这在源代码级别通常是不可见的。实际上,只有“装箱”形式被底层系统视为 object 的子类型,但是每当将值类型分配给兼容接口或类型为 object 的东西时,编译器会自动处理这个问题。 - Steve
显示剩余5条评论

12

如果你考虑底层表示方式(尽管这只是一种实现细节),或许更容易理解。这里是一组字符串:

IEnumerable<string> strings = new[] { "A", "B", "C" };

您可以将strings视为具有以下表示形式:

[0]:字符串引用 ->“A”
[1]:字符串引用 ->“B”
[2]:字符串引用 ->“C”

这是一个由三个元素组成的集合,每个元素都是对一个字符串的引用。 您可以将其转换为对象集合:

IEnumerable<object> objects = (IEnumerable<object>) strings;

基本上,它是相同的表示形式,只不过现在引用是对象引用:

[0]:对象引用 ->“A”
[1]:对象引用 ->“B”
[2]:对象引用 ->“C”

表示形式没有变化,只是引用的处理方式不同;您不能再访问 string.Length 属性,但仍然可以调用 object.GetHashCode()。将其与 int 的集合进行比较:

IEnumerable<int> ints = new[] { 1, 2, 3 };
[0] : int = 1
[1] : int = 2
[2] : int = 3

要将此转换为 IEnumerable<object>,需要通过装箱将数据转换为以下形式:

[0] : object reference -> 1
[1] : object reference -> 2
[2] : object reference -> 3

这种转换需要进行更多操作而不仅仅是转换类型。


2
装箱不仅仅是一种“实现细节”。装箱的值类型与类对象以相同的方式存储,并且在外部世界看来,它们的行为就像类对象一样。唯一的区别是,在装箱值类型的定义中,this 引用一个结构体,其字段覆盖了存储它的堆对象的字段,而不是引用持有它们的对象。没有干净的方法让装箱值类型实例获取对封闭堆对象的引用。 - supercat

7
我认为一切都始于LSP(里氏替换原则)的定义,该原则声称:
如果q(x)是关于类型T的对象x可证明的属性,则对于类型S的子类型T的对象y,q(y)应该为真。
但是,在C#中,值类型(例如int)不能替代对象。证明非常简单:
int myInt = new int();
object obj1 = myInt ;
object obj2 = myInt ;
return ReferenceEquals(obj1, obj2);

即使我们将"引用"分配给相同的对象,这仍然会返回false


1
我认为你使用了正确的原则,但没有证明可以提供:int不是object的子类型,因此该原则不适用。你的“证明”依赖于一个中间表示Integer,它是object的子类型,并且语言具有隐式转换(object obj1=myInt;实际上扩展为object obj1=new Integer(myInt))。 - André Caron
22
@André: C#不是Java。C#的 int 关键字是BCL的 System.Int32 的别名,实际上是 object 的一个子类型(即 System.Object 的别名)。实际上, int 的基类是 System.ValueType,其基类是 System.Object。尝试计算以下表达式并查看:typeof(int).BaseType.BaseTypeReferenceEquals 返回 false 的原因是 int 被装箱到两个单独的盒子中,每个盒子的标识都与任何其他盒子不同。因此,两个装箱操作始终产生两个永远不相同的对象,而不管被装箱的值如何。 - Allon Guralnek
@AllonGuralnek:每个值类型(例如System.Int32List<String>.Enumerator)实际上代表两种不同的东西:存储位置类型和堆对象类型(有时称为“装箱值类型”)。类型派生自System.ValueType的存储位置将保存前者;类型也是如此的堆对象将保存后者。在大多数语言中,从前者到后者存在扩展转换,从后者到前者存在缩小转换。请注意,虽然装箱值类型具有与值类型存储位置相同的类型描述符,... - supercat
它们在语义上更像可变引用类型(即使所谓的“不可变”值类型在装箱时也是可变的)。例如,将一个类型为List<String>.Enumerator的变量复制到另一个变量中将复制其状态;将其转换为IEnumerator<String>将将其转换为其装箱等效项。然而,将其复制到另一个类型为IEnumerator<String>的变量中将存储对原始装箱对象的引用,而不是复制其状态。 - supercat
按照这个逻辑,C#中的每个类都违反了LSP,因为GetType()总是返回与基类不同的值。LSP定义中的“属性”形式实际上是在给定上下文中暗示的预期行为,这比仅仅返回值更高层次的抽象。此外,ReferenceEquals()不是整数的属性或行为,因此您的证明无效。 - Milos Mrdovic
显示剩余2条评论

3

这实际上取决于一些具体的实现细节:值类型和引用类型的实现方式不同。

如果你强制将值类型视为引用类型(例如,通过接口引用它们进行包装),那么就可以获得方差。

最容易看出区别的方法就是考虑一个Array:值类型的数组直接在内存中连续地放置,而引用类型的数组仅在内存中连续地放置指针,指向单独分配的对象。

另外一个相关的问题(*)是,几乎所有的引用类型在方差方面都有相同的表示形式,并且很多代码不需要知道类型之间的区别,因此协变和逆变是可能的(通常仅通过省略额外的类型检查即可轻松实现)。

(*) 这可能被认为是同一个问题...


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