.Net常見面試題整理(二)——裝箱和拆箱

原文鏈接: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方法。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章