5.3 值类型的装箱和拆箱
装箱
值类型比引用类型“轻”,原因是他们不作为对象在托管堆中分配,不被垃圾回收,也不通过指针进行引用。
但许多时候都需要获取对值类型实例的引用。例如,假定要创建ArrayList对象来容纳一组Point结构,代码如下
struct Point { public Int32 x,y; } public sealed class Program { public static coid Main() { ArrayList a=new ArrayList(); Point p;//分配一个Point,不再堆中分配 for(Int32 i=0;i<10;i++) { p.x=p.y=i;//初始化值类型中的成员 a.Add(p);//对值类型装箱,将引用添加到ArrayList中 } ... } }
每次循环迭代都初始化一个Point的值类型字段,并将该Point存储到ArrayList中。
ArrayList中存储的时Point结构还是Point结构的地址呢?
必须研究ArrayList的Add方法,了解它的参数被定义成什么类型。本例的Add方法原型如下。
public virtual Int32 Add(Object value);
可以看出Add获取的是一个Object参数,也就是说Add获取对托管堆上的一个对象的引用来作为参数。
但代码传递的是p,Point是值类型。为了使代码正确工作,Point值类型必须转换成真正的、在堆中托管的对象,而且必须获取对该对象的引用。
将之类型转换成引用类型要使用装箱机制。下面总结了对值类型的实例进行装箱时发生的事情。
- 在托管堆中分配内存。分配的内存量是值类型各字段所需的内存量,还要加上托管堆所有对象都有的两个额外成员(类型对象指针和同步块索引)所需的内存量。
- 值类型的字段复制到新分配的堆内存。
- 返回对象地址。现在该地址是对象引用;值类型成了引用类型。
C#编译器自动生成对值类型实例进行装箱所需的IL代码,但仍需要理解其中内部的工作机制。
在运行时,当前存在于Point值类型实例p中的字段复制到新分配的Point对象中。
已装箱Point对象的地址返回并传给Add方法。
Point对象一直存在于堆中,直到被垃圾回收。
Point值类型变量可被重用,因为ArrayList不知道关于他的任何事情。
在这种情况,已装箱值类型的生存期超过了未装箱值类型的生存期。
注意,FCL现在包含一组新的泛型集合类,非泛型类已经被淘汰。
例如,应该使用System.Collections.Generic.List<T>类而不是System.Collections.ArrayList类。
最大的改进就是泛型集合类允许开发人员在操作值类型时不需要对集合中的项进行装箱拆箱。
此外,开发人员还获得了编译时的类型安全性。
拆箱
假定要用以下代码获取ArrayList的第一个元素
Point p=(Point) a[0];
他获取ArrayList的元素0包含的引用或指针,试图将其放到Point值类型的实例p中。
为此已装箱Point对象中的所有字段都必须赋值到值类型变量p中,后者在线程栈上。
CLR分两步完成复制
- 第一步获取已装箱Point对象中的各个Point字段的地址。这个过程称为拆箱。
- 第二步将字段包含的值从堆复制到基于栈的值类型实例中。
拆箱不是直接将装箱过程倒过来。拆箱的代价比装箱低得多。
拆箱其实就是获取指针的过程,该指针指向包含在一个对象中的原始值类型(数据字段)。
其实,指针指向的是已装箱实例中的未装箱部分。
所以和装箱不同,拆箱不要求在内存中复制任何字节。往往紧接着拆箱发生一次字段复制。
已装箱值类型实例在拆箱时,内部发生这些事。
- 如果包含“对已装箱值类型实例的引用”的变量变为null,抛出NullReferenceException异常。
- 如果引用的对象不是所需值类型的已装箱实例,抛出InvalidCastException异常。
第二条意味着以下代码的工作方式和你想的可能不太一样。
public static void main() { Int32 x=5; Object o=x;//对x装箱,o引用已装箱对象 Int16 y=(Int16)o;//抛出InvalidCastExcrption异常 }
从逻辑上说,完全能获取o引用的已装箱Int32,将其强制转型为Int16。但在对象进行拆箱时,只能转型为最初未装箱的值类型——本例为Int32。以下是上述代码的正确写法。
public static void main() { Int32 x=5; Object o=x;//对x装箱,o引用已装箱对象 Int16 y=(Int16)(Int32)o;//先拆箱为争取类型,再转型 }
前面说过,一次拆箱操作常紧接着一次字段复制,以下c#代码演示了拆箱和复制。
public static void main() { Point p; p.x=p.y=1; Object o=p;//对p装箱,o引用已装箱实例 p=(Point)o;//对o拆箱,将字段从已装箱实例复制到栈变量中 }
最后一行,C#编译器生成一条IL指令对o拆箱(获取已装箱实例中的字段的地址),并生成另一条IL指令将这些字段从堆赋值到基于栈的变量p中。
前面说过,未装箱值类型比引用类型更轻。归结于以下两个原因。
- 不再托管堆上分配。
- 没有堆上的每个对象都有的额外成员:“类型对象指针”和“同步块索引”。
由于未装箱值类型没有同步块索引,所以不能使用System.Threading.Monitor类型的方法或C#lock语句让躲着线程同步对实例的访问。
虽然未装箱值类型没有类型对象指针,但仍可调用由类型继承或重写的虚方法(比如Equals,GetHashCode或ToString)。
如果值类型重写了其中任何虚方法,那么CLR可以非虚地调用该方法,因为值类型隐式密封,不可能由类型从他们派生。而且调用虚方法的值类型实例没有装箱。
然而,如果重写的虚方法要调用方法在基类中的实现,那么在调用基类的实现时,值类型实例会被装箱,以便能通过this指针将对一个堆对象的引用传给基方法。
但在调用非虚的继承的方法时,无论如何都要对值类型进行装箱。因为这些方法由System.Object定义,要求this实参时指向堆对象的指针。
此外将值类型的未装箱实例转型为类型的某个接口时要对实例进行装箱。这是因为接口变量必须包含对堆对象的引用。
装箱拆箱代码演示
internal struct Point : IComparable { private Int32 m_x, m_y; //构造器负责初始化字段 public Point(Int32 x,Int32 y) { m_x = x; m_y = y; } //重写从System.ValueType继承的ToString方法 public override string ToString() { //将point作为字符串返回,注意调用ToString以避免装箱 return String.Format($"({m_x.ToString()},{m_y.ToString()})"); } //实现类型安全的ComparableTo方法 public Int32 CompareTo(Point other) { //利用勾股定理计算那个point距离原点(0,0)更远 return Math.Sign(Math.Sqrt(m_x*m_x+m_y*m_y)
-Math.Sqrt(other.m_x*other.m_x+other.m_y*other.m_y)); } //实现IComparable的ComparableTo方法 public Int32 CompareTo(object o) { if (GetType() != o.GetType()) { throw new ArgumentException("o is not a Point"); } //调用类型安全的ComparableTo方法 return CompareTo((Point) o); } } public static class Program { static void Main(string[] args) { //在栈上创建两个Point实例 Point p1 = new Point(10,10); Point p2 = new Point(20, 20); //调用ToString(虚方法)不装箱p1 Console.WriteLine(p1.ToString());//显示“(10,10)” //调用GetType(非虚方法)装箱p1 Console.WriteLine(p1.GetType());//显示"Point" //调用CompareTo不装箱p1 //由于调用的是CompareTo(Point),所以p2不装箱 Console.WriteLine(p1.CompareTo(p2));//显示"-1" //p1要装箱,引用放在c中 IComparable c = p1; Console.WriteLine(c.GetType()); //显示"Point" //调用CompareTo不装箱p1 //由于向CompareTo传递的不是Point变量 //所以调用的是CompareTo(Object),他要求获取对已装箱Point的引用 //c不装箱是因为他本来就引用已装箱Point Console.WriteLine(p1.CompareTo(c)); //显示"0" //c不装箱,因为他本来就是引用已装箱Point //p2要装箱,因为调用的是CompareTo(Object) Console.WriteLine(c.CompareTo(p2));//显示"-1" //对c拆箱,字段复制到p2中 p2 = (Point)c; //证明字段已复制到p2中 Console.WriteLine(p2.ToString()); //显示"(10,10)" } }
上述代码演示了涉及装箱和拆箱的几种情形。
调用ToString
调用ToString时p1不必装箱。表面看p1似乎必须装箱,因为ToString是从基类System.ValueType继承的虚方法。
通常,为了调用虚方法,CLR需要判断对象的类型来定位类型的方法表。
由于p1是未装箱的值类型,所以不存在类型对象指针。
但JIT编译器发现Point重写了ToString,所以会生成代码来直接非虚地调用ToString方法。
而不必进行任何装箱操作。编译器知道这里不存在多态性问题。
因为Point是值类型,没有类型能从他派生以提供虚方法的另一个实现。
但假如Point的ToStringd方法在内部调用base.ToString(),那么在调用System.ValueType的ToString方法时,值类型的实例会被装箱。
调用GetType
调用非虚方法GetType时p1必须装箱。Point的GetType方法是从System.Object继承的。
所以为了调用GetType,CLR必须使用指向类型对象的指针,这个指针只能通过装箱p1来获得。
调用CompareTo(第一次)
第一次调用CompareTo时,p1不必装箱,因为Poinr实现了CompareTo方法。编译器能直接调用他。
注意向CompareTo传递的是一个Point变量p2,所以编译器调用的是获取一个Point参数的CompareTo重载版本。
这意味着p2以传值方式传给CompareTo,无需装箱。
转型为IComparable
p1转型为接口类型的变量c时必须装箱,因为借口被定义为引用类型。
装箱p1后,指向已装箱对象的指针存储到变量c中。
后面对GetType的调用证明c确实引用堆上的已装箱Point。
调用CompareTo(第二次)
第二次调用CompareTo时p1不装箱,因为Point实现了CompareTo方法,编译器能直接调用。
注意向CompareTo传递的是IComparable类型的变量c,所以编译器调用的是获取一个Object参数的CompareTo重载版本。
这意味着传递的实参必须是指针,必须引用堆上一个对象。c确实引用一个已装箱Point,无需装箱。
调用CompareTo(第三次)
第三次调用CompareTo时,c本来就引用堆上的已装箱Point,所以不装箱。
由于c是IComparable接口类型,所以只能调用接口的获取一个Object参数的CompareTo方法。
这意味着传递的实参必须是引用了堆上对象的指针,所以p2要装箱,指向这个已装箱对象的指针将传给CompareTo。
转型为Point
将c转型为Point时,c引用的堆上对象被拆箱,其字段从堆复制到p2。p2是栈上的Point类型实例。