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


149

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

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

但是从更改stringint会得到编译错误:

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

原因在MSDN中进行了解释:

方差仅适用于引用类型;如果为变量类型参数指定值类型,则该类型参数对于生成的构造类型而言是不变的。

我搜索后发现,提到的一些问题的原因是值类型和引用类型之间的装箱。但这仍不能使我清楚为何拳击是原因?

有人可以给出一个简单而详细的解释,为什么协变量和协变量不支持值类型,以及装箱如何影响值类型?


3
又见Eric的回答我类似的问题:stackoverflow.com/questions/4096299/...
索恩

Answers:


126

基本上,当CLR可以确保不需要对值进行任何表示性更改时,就会应用差异。引用看起来都是一样的-因此您可以IEnumerable<string>IEnumerable<object>不改变表示形式的情况下将用作。本机代码本身根本不需要知道您在使用这些值做什么,只要基础结构保证它肯定是有效的即可。

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

通常,您可能想阅读Eric Lippert 关于表示和身份博客文章,以获取更多有关该主题的信息。

编辑:我自己重读了Eric的博客文章,尽管两者是相互联系的,但至少与身份和表示同样重要。特别是:

这就是为什么接口和委托类型的协变和协变转换要求所有不同类型的参数都是引用类型的原因。为了确保变体引用转换始终保留身份,所有涉及类型实参的转换也必须保留身份。确保类型参数上的所有非平凡转换都保留身份的最简单方法是将它们限制为引用转换。


5
@CuongLe:嗯,从某种意义上讲,这是一个实现细节,但我认为,这是限制的根本原因。
乔恩·斯基特

2
@AndréCaron:Eric的博客文章在这里很重要-它不仅是表示形式,而且还是身份保存。但是,表示形式保留意味着生成的代码根本不需要关心这一点。
乔恩·斯基特

1
确切地说,身份不能保留,因为int它不是的子类型object。要求进行代表更改的事实仅仅是此的结果。
安德烈·卡隆

3
int如何不是对象的子类型?Int32继承自System.ValueType,后者继承自System.Object。
David Klempfner,

1
@DavidKlempfner我认为@AndréCaron的措辞很差。任何值类型,例如Int32具有两种表示形式,即“装箱”和“未装箱”。编译器必须插入代码才能从一种形式转换为另一种形式,即使该形式在源代码级别通常不可见。实际上,底层系统仅将“装箱”形式视为其子类型object,但是只要将值类型分配给兼容接口或某种类型的东西,编译器就会自动处理object
史蒂夫

10

如果考虑底层表示,可能会更容易理解(即使这确实是实现细节)。这是字符串的集合:

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]:整数= 1
[1]:int = 2
[2]:int = 3

要将其转换为IEnumerable<object>,必须通过将整数装箱来转换数据:

[0]:对象参考-> 1
[1]:对象引用-> 2
[2]:对象引用-> 3

这种转换不仅仅需要强制转换。


2
装箱不仅仅是一个“实施细节”。装箱的值类型的存储方式与类对象相同,并且在行为方面,就像类对象一样,在外界看来。唯一的区别是,在盒装值类型的定义中,this是指结构的字段与存储它的堆对象的字段重叠,而不是引用包含它们的对象。装箱的值类型实例没有干净的方法来获取对封闭堆对象的引用。
2012年

7

我认为一切都始于LSP(Liskov替代原理)的定义,其定义如下:

如果q(x)是关于类型T的对象x的可证明性质,则q(y)对于类型S的对象y应该为true,其中S是T的子类型。

但是,例如,值类型int不能代替objectin C#。证明很简单:

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);),这是该语言的子类型。
安德烈·卡隆

该语言负责正确地在类型之间进行类型转换,但是int行为与我们从对象的子类型中期望的行为不对应。
Tigran 2012年

我的意思是,这int并不是的子类型object。此外,LSP并不适用,因为myIntobj1obj2指三种不同的对象:一个int和两个(隐藏)Integer秒。
安德烈·卡隆

22
@André:C#不是Java。C#的int关键字是BCL的别名System.Int32,实际上是BCL的子类型object(的别名System.Object)。实际上,int的基类是System.ValueTypeSystem.Object。尝试评估以下表达式,然后查看:typeof(int).BaseType.BaseTypeReferenceEquals此处返回false 的原因是将int装箱到两个单独的盒子中,并且每个盒子的标识对于其他任何盒子都是不同的。因此,无论装箱值如何,两次装箱操作始终会产生两个永远不相同的对象。
Allon Guralnek 2012年

@AllonGuralnek:每个值类型(例如System.Int32List<String>.Enumerator)实际上代表两种东西:一个存储位置类型和一个堆对象类型(有时称为“盒装值类型”)。类型来自的存储位置System.ValueType将容纳前者;类型相同的堆对象将容纳后者。在大多数语言中,从前者到后者都有扩大的范围,从后者到前者都有缩小的范围。请注意,虽然装箱的值类型与值类型存储位置具有相同的类型描述符,...
supercat 2012年

3

它确实涉及实现细节:值类型的实现与引用类型不同。

如果您将值类型强制为引用类型(即,将它们装箱,例如通过通过接口引用它们),则可以得到差异。

观察差异的最简单方法是简单地考虑Array:将Value类型的数组连续(直接)放置在内存中,其中,作为Reference类型的数组仅在内存中连续放置引用(指针);指向的对象是单独分配的。

另一个(相关)问题(*)是(几乎)所有引用类型出于方差目的具有相同的表示形式,并且很多代码不需要知道类型之间的差异,因此协方差和对方差是可能的(并且很容易实施-通常只是通过省略额外的类型检查)。

(*)可能被视为同一问题...

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.