装箱和拆箱是一种抽象的概念
装箱和拆箱是值类型和引用类型之间相互转换是要执行的操作。
1. 装箱在值类型向引用类型转换时发生;
2. 拆箱在引用类型向值类型转换时发生;
例如:
// 装箱
object obj = 1;
这行语句将整型常量1赋给object类型的变量obj; 众所周知常量1是值类型,值类型是要放在栈上的,而object是引用类型,它需要放在堆上;要把值类型放在堆上就需要执行一次装箱操作。
这行语句的IL代码如下,请注意注释部分说明:
.locals init (
[0] object objValue
) //以上三行IL表示声明object类型的名称为objValue的局部变量
IL_0000: nop
IL_0001: ldc.i4.s 9 //表示将整型数9放到栈顶
IL_0003: box [mscorlib]System.Int32 //执行IL box指令,在内存堆中申请System.Int32类型需要的堆空间
IL_0008: stloc.0 //弹出堆栈上的变量,将它存储到索引为0的局部变量中
以上就是装箱所要执行的操作了,执行装箱操作时不可避免的要在堆上申请内存空间,并将堆栈上的值类型数据复制到申请的堆内存空间上,这肯定是要消耗内存和cpu资源的。
// 拆箱
object objValue = 4;
int value = (int)objValue;
上面的两行代码会执行一次装箱操作将整形数字常量4装箱成引用类型object变量objValue;然后又执行一次拆箱操作,将存储到堆上的引用变量objValue存储到局部整形值类型变量value中。
同样我们需要看下IL代码:
.locals init (
[0] object objValue,
[1] int32 'value'
) //上面IL声明两个局部变量object类型的objValue和int32类型的value变量
IL_0000: nop
IL_0001: ldc.i4.4 //将整型数字4压入栈
IL_0002: box [mscorlib]System.Int32 //执行IL box指令,在内存堆中申请System.Int32类型需要的堆空间
IL_0007: stloc.0 //弹出堆栈上的变量,将它存储到索引为0的局部变量中
IL_0008: ldloc.0//将索引为0的局部变量(即objValue变量)压入栈
IL_0009: unbox.any [mscorlib]System.Int32 //执行IL 拆箱指令unbox.any 将引用类型object转换成System.Int32类型
IL_000e: stloc.1 //将栈上的数据存储到索引为1的局部变量即value
拆箱操作的执行过程和装箱操作过程正好相反,是将存储在堆上的引用类型值转换为值类型并给值类型变量。
1. 为何需要装箱?又如何避免装箱?
(1) 最普通的场景是,调用一个含类型为Object的参数的方法,该Object可支持任意为型,以便通用。当你需要将一个值类型(如Int32)传入时,需要装箱。
例如:
namespace ConsoleApplicationTest
{
class Program
{
static void Output(Object o)
{
Console.WriteLine(o.ToString());
}
static void Main(string[] args)
{
Int32 a = 10;
double b = 20.13;
short c = 100;
Output(a);
Output(b);
Output(c);
Console.ReadLine();
}
}
}
解决方法:可以通过重载函数来避免
namespace ConsoleApplicationTest
{
class Program
{
static void Output(int val)
{
Console.WriteLine(val);
}
static void Output(double val)
{
Console.WriteLine(val);
}
static void Output(short val)
{
Console.WriteLine(val);
}
static void Main(string[] args)
{
Int32 a = 10;
double b = 20.13;
short c = 100;
Output(a);
Output(b);
Output(c);
Console.ReadLine();
}
}
}
(2) 一个非泛型的容器,同样是为了保证通用,而将元素类型定义为Object。于是,要将值类型数据加入容器时,需要装箱。
例如:
using System.Collections; // ArrayList
namespace ConsoleApplicationTest
{
class Program
{
static void Main(string[] args)
{
var array = new ArrayList();
array.Add(1);
array.Add(2);
foreach (int value in array)
{
Console.WriteLine("value is {0}", value);
}
Console.ReadLine();
}
}
}
在这个过程中会发生两次装箱操作和两次拆箱操作,在向ArrayList中添加int类型元素时会发生装箱,在使用foreach枚举ArrayList中的int类型元素时会发生拆箱操作,将object类型转换成int类型,在执行到Console.WriteLine时,还会执行两次的装箱操作;这一段代码执行了6次的装箱和拆箱操作;如果ArrayList的元素个数很多,执行装箱拆箱的操作会更多。
解决方法:可以通过泛型来避免
using System.Collections; // ArrayList
namespace ConsoleApplicationTest
{
class Program
{
static void Main(string[] args)
{
var list = new List<int>();
list.Add(1);
list.Add(2);
foreach (int value in list)
{
Console.WriteLine("value is {0}", value);
}
Console.ReadLine();
}
}
}
代码和1中的代码的差别在于集合的类型使用了泛型的List,而非ArrayList;我们同样可以通过查看IL代码查看装箱拆箱的情况,上述代码只会在Console.WriteLine()方法时执行2次装箱操作,不需要拆箱操作。
可以看出泛型可以避免装箱拆箱带来的不必要的性能消耗;当然泛型的好处不止于此,泛型还可以增加程序的可读性,使程序更容易被复用等等。
**当然,凡事并不能绝对,假设你想改造的代码为第三方程序集,你无法更改,那你只能是装箱了。
对于装箱/拆箱代码的优化,由于C#中对装箱和拆箱都是隐式的,所以,根本的方法是对代码进行分析,而分析最直接的方式是了解原理结何查看反编译的IL代码。比如:在循环体中可能存在多余的装箱,你可以简单采用提前装箱方式进行优化。**
2. 装箱/拆箱的内部操作
将值类型转换为引用类型,需要进行装箱操作(boxing):
(1) 首先从托管堆中为新生成的引用对象分配内存。
(2) 然后将值类型的数据拷贝到刚刚分配的内存中。
(3) 返回托管堆中新分配对象的地址。
可以看出,进行一次装箱要进行分配内存和拷贝数据这两项比较影响性能的操作。
将引用内型转换为值内型,需要进行拆箱操作(unboxing):
(1) 首先获取托管堆中属于值类型那部分字段的地址,这一步是严格意义上的拆箱。
(2) 将引用对象中的值拷贝到位于线程堆栈上的值类型实例中。
经过这2步,可以认为是同boxing是互反操作。严格意义上的拆箱,并不影响性能,但伴随这之后的拷贝数据的操作就会同boxing操作中一样影响性能。
3. 对装箱/拆箱更进一步的了解
装箱/拆箱并不如上面所讲那么简单明了,比如:装箱时,变为引用对象,会多出一个方法表指针,这会有何用处呢?
我们可以通过示例来进一步探讨。
举个例子:
namespace ConsoleApplicationTest
{
struct A : ICloneable
{
public Int32 x;
public override String ToString() {
return String.Format("{0}",x);
}
public object Clone() {
return MemberwiseClone();
}
}
class Program
{
static void Main(string[] args)
{
A a;
a.x = 100;
// 编译器发现A重写了ToString方法,会直接调用ToString的指令。
// 因为A是值类型,编译器不会出现多态行为。因此,直接调用,不装箱。
Console.WriteLine(a.ToString());
// GetType是继承于System.ValueType的方法,要调用它,需要一个方法表指针。
// 于是a将被装箱,从而生成方法表指针,调用基类的System.ValueType。
Console.WriteLine(a.GetType());
// 因为A实现了Clone方法,所以无需装箱。
A a2 = (A)a.Clone();
// 当a2为转为接口类型时,必须装箱,因为接口是一种引用类型。
ICloneable c = a2;
// 无需装箱,在托管堆中对上一步已装箱的对象进行调用。
Object o = c.Clone();
Console.ReadLine();
}
}
}
如何更改已装箱的对象呢?
对于已装箱的对象,因为无法直接调用其指定方法,所以必须先拆箱,再调用方法,但再次拆箱,会生成新的栈实例,而无法修改装箱对象。
namespace ConsoleApplicationTest
{
struct A : ICloneable
{
public Int32 x;
public override String ToString() {
return String.Format("{0}",x);
}
public object Clone() {
return MemberwiseClone();
}
public void Change(Int32 x)
{
this.x = x;
}
}
class Program
{
static void Main(string[] args)
{
A a = new A();
a.x = 100;
Object o = a; // 装箱成o,下面,想改变o的值。
((A)o).Change(200);
Console.WriteLine(o.ToString()); // 输出还是为100,没改掉
Console.ReadLine();
}
}
}
正确修改方法:
namespace ConsoleApplicationTest
{
interface IChange // 添加一个接口
{
void Change(Int32 x);
}
struct A : ICloneable, IChange
{
public Int32 x;
public override String ToString() {
return String.Format("{0}",x);
}
public object Clone() {
return MemberwiseClone();
}
public void Change(Int32 x)
{
this.x = x;
}
}
class Program
{
static void Main(string[] args)
{
A a = new A();
a.x = 100;
Object o = a; // 装箱成o,下面,想改变o的值。
((IChange)o).Change(200); // 改为IChange
Console.WriteLine(o.ToString()); // 输出为200,已改掉
// 在将o转型为IChange时,这里不会进行再次装箱,当然更不会拆箱,因为o已经是引用类型,
// 再因为它是IChange类型,所以可以直接调用Change,于是,更改的也就是已装箱对象中的字段了
Console.ReadLine();
}
}
}