.Net常见面试题整理(2)——装箱和拆箱

为了防止不提供原网址的转载,特在这里加上原文链接:http://www.cnblogs.com/zhangkai2237/archive/2013/03/21/2974570.html
 
        上一节我们讨论的是值类型和引用类型, 我们知道值类型是一种轻量级的数据结构, 对于一些简单的类型定义为值类型会减少定义成引用类型造成的系统开销以及GC的压力。但是值类型有一个缺点,就是缺少对象指针,我们不能用一个新的变量来引用栈上的值类型(Note:即未装箱的值类型)。也就是说很多引用类型为参数的方法不能传入值类型。为了解决这个问题,CLR提供了装箱和拆箱的机制。
 
一、装箱和拆箱的概念和原理
        在面试中, 面试官提到装箱和拆箱的问题时,可能很多人想到的第一句话是“装箱是将值类型转化为引用类型的过程;拆箱是将引用类型转化为值类型的过程”。这句话没有问题,但是仅仅只说出这句话而没有下文的话那就不是一个中级.Net程序员的水平。
        实际上装箱和拆箱这个名字就很形象,“箱”指的就是托管堆,装箱即指在托管堆中将在栈上的值类型对象封装,生成一份该值类型对象的副本,并返回该副本的地址。而拆箱即是指返回已装箱值类型在托管堆中的地址(注意:严格意义来说拆箱是不包括值类型字段的拷贝的)。
 
        如果上面一段你仍然看的不是很明白的话,那么我们来看看装箱和拆箱过程中内部发生的事情。
            #region 装箱和拆箱
            int i = 10;
            object o = i;            //装箱
            int j = (int)o;          //拆箱
            #endregion
 
        上面这段代码有一次拆箱和一次装箱。
装箱的过程为:
        1. 分配内存: 在托管堆中分配好内存,内存的大小是值类型的各个字段需要的内存量加上托管堆的所有对象都有的两个额外成员—类型对象指针和同步块索引—所需要的内存量之和。
        2. 复制对象: 将值类型的字段复制到新分配的内存中。
        3. 返回地址: 将已装箱的值类型对象的地址返回给引用类型的变量。
 
        我们来看看装箱的IL代码:
        装箱操作有一个非常明显的标志,就是“box”,它的执行就是完成了我们刚才所说的三步。
 
拆箱的过程为:
        1. 检查实例:首先检查变量的值是否为null,如果是则抛出NullReferenceException异常;再检查变量的引用指向的对象是不是给定值类型的已装箱对象,如果不是,则抛出InvalidCastException异常。
        2. 返回地址:返回已装箱实例中属于原值类型字段的地址,而两个额外成员(类型对象指针和同步块索引)则不会返回。
        到此,拆箱过程已经结束,但是伴随着拆箱,“往往”(《CLR via C#》中的描述,用的是”往往“,而并没有说一定,但是我带目前为止也不知道有没有一种拆箱没有伴随字段复制)会紧接着发生一次字段的复制操作。实际上就是讲已装箱对象中的实例字段拷贝到内存栈上。
        下图为拆箱的IL代码:
        同样,拆箱操作也有一个明显的标志:unbox。
 
注意:
        1. 装箱和拆箱都是针对值类型而言的,而引用类型一致都是在托管堆中的,即总是以”装箱“的形式存在。
        2. 装箱和拆箱并不是互逆的过程,实际上装箱的性能开销远比拆箱的性能开销大,并且伴随着拆箱的字段复制步骤实际上不属于拆箱。
        3. 只有是值类型装箱之后的引用类型才能被拆箱,而并不是所有的引用类型都能被拆箱,将非装箱实例强制转化为值类型或者转化为非原装箱的值类型,会抛出InvalidCastException异常。
        4. 拆箱的IL代码中有unbox和unbox.any两条指令,他们的区别是unbox指令不包含伴随着拆箱的字段复制操作,但是unbox.any则包含伴随着拆箱的字段复制操作。我到目前为止没有发现C#中有没有字段复制操作的拆箱,所以有时候也把这部操作放在拆箱的步骤里。
        5. 在我们拆箱前怎么知道这个引用类型是否是期望的那个值类型的装箱形式呢。我们有两种方法,一种是用is/as操作符来判断(详情请移步:http://www.cnblogs.com/zhangkai2237/archive/2012/12/15/2820057.html);还有一种方法是object类的GetType方法。
 
二、常见的拆箱和装箱场合
        先看看下面这段代码,看看其中出现了多少次装箱:
static void Main(string[] args)
{
    #region 装箱场合
    int i = 2;
    i.GetType();
    object o = i;
    ArrayList al = new ArrayList();
    al.Add(i);
    Hashtable ht = new Hashtable();
    ht.Add(3, i);
    Console.WriteLine(i + ", " + (int)o);
    Console.ReadKey();
    #endregion
}
 
        如果我说上面这段代码装箱了7次,你会不会觉得很意外?让我们来具体的分析下这段代码。
 
        第一行就是一个简单的赋值语句,没有问题。
        第二行是调用GetType()方法,我们想到GetType方法是在object类型中的非虚方法,子类中不可重写。所以调用时一定是调用的Object类型的GetType方法,所以这里发生了一次装箱。
        第三行将值类型变量i赋值给引用类型Object的变量o,也发生了一次装箱。这个较明显,基本都可以看出来。
        第四行实例化了一个ArrayList的对象,第五行将变量 i 添加到ArrayList中。我们首先查看下ArrayList.Add方法需要传入的参数类型:
public virtual int Add(object value);
        他需要接收的是Object类型,所以这里也需要对变量 i 进行装箱。
        同样的,第六行和第七行也是实例化一个Hashtable对象,并且将 i 添加进去。Hashtable.Add方法同样需要两个Object类型的参数,所以第七行会将3 和 i 分别装箱。
public virtual void Add(object key, object value);
        至此,以上代码已经装箱了5次。
        第八行中调用了Console.WriteLine方法,他接收的是一个string值,但是在累加中遇到int类型,会隐式转换为string类型。该方法参数的第一部分 i 需要装箱,而第三部分中是先将object类型强制转化为int类型,再将int型装箱为string类型。所以这一步经过了两次装箱。
 
        综上,我们看到了简简单单的这几行代码进行了多达8次的装箱,如果有兴趣,可以自己写写然后看IL代码数“box”的数目。
 
        在我们的日常工作中,常见的隐形装箱主要集中在方法需要
            1. 传入的是引用类型,但是我们传的值是值类型,这就会造成装箱。比较典型的例子就是ArrayList和Hashtable。还有另外两个特殊的方法就是Console.WriteLine方法和String.Format方法。
            2. 值类型调用父类的方法。若调用的是基类的非虚方法,无论如何都会装箱;若调用的是虚方法,如果在值类型中重写了,那么就不会装箱,若没有重写,调用的仍然是基类的方法,那么这个值类型仍然会长相。类似于上例中的GetType方法。
 
三、如何避免装箱
        我们之所以研究装箱和拆箱,是因为装箱和拆箱会造成相当大的性能损耗(相比之下,装箱要比拆箱性能损耗大),性能问题主要体现在执行速度和字段复制上。因此我们在编写代码时要尽量避免装箱和拆箱,常用的手段为:
        1. 使用重载方法。为了避免装箱,很多FCL中的方法都提供了很多重载的方法。比如我们之前讨论过的Console.WriteLine方法,提供了多达19个重载方法,目的就是为了减少值类型装箱的次数。比如看下面的这段代码:
Console.WriteLine(3);
        刚开始你可能绝的3会装箱为string类型,但是实际上这条语句不会进行装箱操作,是因为Console.WriteLine方法有一个重载的方法,参数就是一个int的值。
public static void WriteLine(int value);
 
        类似Console.WriteLine方法,还有System.IO.BinaryWriter的Write 方法,System.IO.TextWriter 的Write和WriteLine方法,System.Text.StringBuilder的Append和Insert方法等都提供了大量的重载方法,以减少装箱次数。
        所以我们在实际的项目中,应该时刻注意装箱的情况,并且选用合适的重载方法避免装箱。
 
        2. 使用泛型。因为装箱和拆箱的性能问题,所以在.NET 2.0中引用了泛型,他的主要目的就是避免值类型和引用类型之间的装箱和拆箱。我们常用的集合类都有泛型的版本,比如ArrayList对应着泛型的List<T>,Hashtable对应着Dictionary<TKey, Tvalue>。
        关于泛型的知识不是本篇文章的重点,以后有机会再专门总结整理。
 
        3. 如果在项目中一个值类型变量需要多次拆装箱,那么可以将这个变量提出来在前面显式装箱。比如下面这段代码:
int j = 3;
ArrayList a = new ArrayList();
for (int i = 0; i < 100; i++)
{
    a.Add(j);
}
        可以修改为:
int j = 3;
object ob = j;
ArrayList a = new ArrayList();
for (int i = 0; i < 100; i++)
{
    a.Add(ob);
}
 
        4. ToString。这点单独列出来是因为虽然小,但是很实用。虽然表面上看值类型调用ToString方法是要进行装箱的,因为ToString是从基类继承的方法。但是ToString方法是一个虚方法,值类型一般都重写了这个方法,所以调用ToString方法不会装箱。
        之前说过String.Format方法容易造成装箱,避免的最佳方法就是在调用这个方法前将所有的值类型参数都调用一次ToString方法。
 
        上一篇的值类型与引用类型(原文地址:http://www.cnblogs.com/zhangkai2237/archive/2013/03/17/2964528.html),这一篇的装箱与拆箱是类型的两个重点,在切实的理解了这些概念之后,会对你以后的工作有非常大的帮助。
        当然类型中还有一些其他的需要说明的内容,包括一些基础知识(参见http://www.cnblogs.com/zhangkai2237/archive/2012/12/15/2820057.html),以及基元类型、string类型、对象的相等性、dynamic类型等内容不准备放在这个面试的系列中,有时间会另外写出来。
 
        下一篇准备讲解下接口与类的联系和区别,对于面试中常考的抽象类和接口的区别也有所涉及。希望本系列的文章对大家有所帮助,有不对的地方或者需要和我讨论的可以直接在下面给我留言,我基本每天都会上博客园看看的。
posted @ 2013-03-21 23:29  zhangkai2237  阅读(9419)  评论(13编辑  收藏  举报