《CLR via C#》精髓:装箱和拆箱
一、什么类型需要装箱和拆箱?
装箱和拆箱仅针对值类型而言。
二、何时需要装箱?
- 将一个值类型转换成一个引用类型时,值类型需要装箱;
- 值类型重写了基类中的虚方法,并在代码中调用了此虚方法在基类中的实现(例如:值类型重写了ToString虚方法,并在ToString的代码中调用了基类中的ToString方法),此时值类型的实例会被装箱。因为CLR需要向基类中的方法传递一个this指针,此指针只能指向托管堆中的对象;
- 值类型中从基类继承且非虚的方法(例如:GetType、MemberwiseClone)被调用时,值类型需要装箱。因为基类中的这些方法需要一个指向托管堆中对象的this指针;
- 将一个值类型转型为一个其所实现的接口类型时需要装箱。因为接口类型变量总是包含一个位于托管堆的对象引用。
三、如何装箱?
- 在托管堆中分配好内存。分配的内存量是值类型各个字段需要的内存量加上托管堆的所有对象都有的两个额外成员(类型对象指针和同步块索引)需要的内存量。
- 值类型字段复制到新分配的堆内存。
- 返回对象的地址。
四、何时需要拆箱?
试图将一个引用类型实例放入一个值类型实例时,会发生拆箱。
五、如何拆箱?
拆箱的代价比装箱低得多。拆箱其实就是获取一个指针的过程,该指针指向包含在一个对象中的原始值类型(数据字段)。与装箱不同,拆箱不需要在内存中复制任何字节。拆箱完成后,往往会紧接着发生一次字段复制操作。
另外,在对一个对象进行拆箱的时候,只能将其转型为原先未装箱时的值类型。例如,下列代码在拆箱时将抛出异常:
public static void Main() { Int32 x = 5; Object o = x; // 对 x 装箱,o 引用已装箱的对象 Int16 y = (Int16)o; // 此处将抛出 InvalidCastException 异常 }
下面是上述代码的正确写法:
public static void Main() { Int32 x = 5; Object o = x; // 对 x 装箱,o 引用已装箱的对象 Int16 y = (Int16)(Int32)o; // 先拆箱为正确类型,再进行转换 }
六、其他注意事项
- 如果知道自己写的代码会造成编译器反复对一个值类型进行装箱,请改成用手动方式对值类型进行装箱。
- 未装箱的值类型没有同步块索引,所以不能使用 System.Threading.Monitor 类型的各种方法(或使用 C# 的 lock 语句)让多个线程同步地访问这个实例。
- 未装箱的值类型调用由类型继承并重写的虚方法(如:Equals、GetHashCode 或 ToString)时不必装箱。因为值类型是隐式密封的,没有任何类型能够从它们派生,因此,CLR 可以非虚的调用该方法。然而,如果重写的虚方法需要调用基类中的实现,那么在调用基类的实现时,值类型就会装箱。
- 未装箱的值类型调用一个继承的、非虚的方法(如:GetType 或 MemberwiseClone)时必须装箱。因为这些方法都是由 System.Object 定义,这些方法期望 this 实参是指向堆上一个对象的指针。
- 装值类型的一个未装箱的实例转型为类型的某个接口时必须装箱。因为接口变量必须包含一个对象的引用。
七、测试题
试题1:下列代码发生了多少次装箱和多少次拆箱?
public static void Main() { Int32 v = 5; Object o = v; v = 123; Console.WriteLine(v + ", " + (Int32)o); }
答案是三次装箱和一次拆箱。
第一次装箱:
Object o = v;
第二次装箱:
v + ", " + (Int32)o
上述语句将致使编译器生成 String.Concat 静态方法调用,重载版本为:
public static String Concat(Object arg0, Object arg1, Object arg2);
因此也就导致值类型 v 传递给类型为 Object 的引用类型 arg0 时所发生了装箱操作。
一次拆箱:
(Int32)o
第三次装箱:
v + ", " + (Int32)o
此时的“(Int32)o”已经变为一个未装箱的 Int32 实例,即将传递给 Concat 静态方法的第三个参数(arg2),由于此参数仍然是引用类型,于是,o 拆箱完成(仅是获得了一个指向已装箱 Int32 中的未装箱 Int32 的地址,并未进行字节复制操作)后紧接着又进行了一次装箱。
另外,针对上述代码进行了一个简单的更改,就能获得了性能的显著提升:
Console.WriteLine(v + ", " + o);
上述代码去掉了对变量 0 的 Int32 强制转型,由此可减少一次拆箱和一次装箱操作。
试题2:下列代码最终的输出是什么?
using System; internal struct Point { private Int32 m_x, m_y; public Point(Int32 x, Int32 y) { m_x = x; m_y = y; } public void Change(Int32 x, Int32 y) { m_x = x; m_y = y; } public override String ToString() { return String.Format("({0}, {1})", m_x, m_y); } } public sealed class Program { public static void Main() { Point p = new Point(1, 1); p.Change(2, 2); Object o = p; ((Point)o).Change(3, 3); Console.WriteLine(o); } }
答案是:“(2, 2)”。因为语句:
(Point)o
将使得变量 o 拆箱,并将已装箱 Point 中的字段复制到线程栈上的一个临时 Point 中,紧接着的 Change 调用只会影响线程栈上的 Point,变量 o 引用的堆上已装箱的 Point 不受任何影响。
如果确实希望修改 o 引用的已装箱 Point,可将上述代码修改为:
using System; internal interface IChangeBoxedPoint { void Change(Int32 x, Int32 y); } internal struct Point : IChangeBoxedPoint { private Int32 m_x, m_y; public Point(Int32 x, Int32 y) { m_x = x; m_y = y; } public void Change(Int32 x, Int32 y) { m_x = x; m_y = y; } public override String ToString() { return String.Format("({0}, {1})", m_x, m_y); } } public sealed class Program { public static void Main() { Point p = new Point(1, 1); p.Change(2, 2); Object o = p; ((IChangeBoxedPoint)o).Change(3, 3); Console.WriteLine(o); } }
上述代码与第一版的主要区别在于 Change 方法由 IChangeBoxedPoint 接口定义,Point 类型实现了此接口。变量 o 不是通过拆箱转型为 Point,而是直接转型为接口类型,接口方法 Change 允许更改一个已装箱的 Point 对象中的字段,最终程序输出了:“(3, 3)”,这正是我们希望的。