C#筆記 – 類型基礎(五) 裝箱拆箱

int i = 5;
object o = i; //裝箱
int j = (int)o; //拆箱
裝箱
  • 把值類型轉換成一個引用類型時,運行時會在堆上根據值類型的值創建一個副本,然後將副本的引用地址賦給對應的引用類型變量,此時改變值變量的值時,不會對引用變量指向的對象造成影響
    • 裝箱發生時機:
      • 要獲取對值類型實例的引用,將值類型實例傳給需要獲取引用類型的方法時:

        • //ValueType
          struct Point
          {
             public int x;
             public int y;
          }
          public sealed class Program
          {
             public static void Main()
            {
                 ArrayList a = new ArrayList();
                 Point p;
                 for(int i = 0; i < 10; i++)
                {
                     p.x = i;
                     p.y = i;
                     a.Add(p); //ArrayList.Add(Object obj)接收的是一個Object類型的參數
                }
            }
          }
        • 上例中的Array.Add(Object obj),Add獲取的是對托管堆上的一個對象引用作為參數;但代碼傳遞的是值類型的一個p。因此,Point類型的p需要被轉換成「在托管堆中的對象,然後被Add(Object obj)方法獲取其引用。

        • 還有ToString()/Equals()/GetHashCode()等方法也會獲取值類型實例的引用
      • 將值作為接口表達式使用時(賦給一個接口類型的變量)/把它作為接口類型的參數傳遞,也會發生裝箱
        • 將值類型的未裝箱實例轉型為類型的某個接口時,要對實例進行裝箱。因為接口變量必須包含對堆對象的引用

      • 另外 類型基礎(四) 中提過,未裝箱值類型較引用類型輕是因為值不在托管堆上分配以及沒有堆上每個對象都有的兩個額外成員(類型對象指針和同步塊索引)
          • 而沒有同步塊索引代碼就不能讓多個線程同步對實例的訪問(使用System.Threading.Monitor類型的方法)

            • 因此,要讓多個線程同步對實例的訪問時就要裝箱

          • 雖然未裝箱值類型沒有類型對象指針,但仍可以調用從System.Object繼承下來的虛方法。(如Equals、GetHashCode、ToString)

            • 如果值類型重寫了這些方法並進行調用,那麼CLR可以非虛地調用這些方法且不會使值類型裝箱

              • 為了調用虛方法,CLR需要判斷對象的類型來定位類型的方法表

                • JIT編譯時發現了未裝箱值類型所重寫的方法,就會生成代碼直接調用該方法,不必進行裝箱操作

            • 但如果值類型要調用基類的實現時,就會發生裝箱,以便通過this指針將一個堆對象的引用傳給基方法

              • public override string ToString()
                {
                   return base.ToString();
                }
            • 而如果調用的是System.Object的非虛方法(GetType、MemberwiseClone),就肯定會裝箱,因為是System.Object定義的方法

              • 為了調用這些方法,CLR必須使用指向類型對象的指針,而這個指針只能通過裝箱來獲得 

  • 裝箱具體運作機制
    • 在托管堆中分配內存,分配的內存量是值類型各字段所需 + 堆對象的額外成員所需(類型對象指針和同步塊索引)
    • 將值類型的字段的值複製到新分配的堆內存中
    • 把堆內存新建的對象的地址返回出去
    • 該對象會一直存在於堆中,直至被GC回收
      • 因此,已裝箱的值類型的生存期會超過未裝箱值類型的生存期
  • 對值類型實例進行裝箱的IL代碼也由C#編譯器自動生成(指令為box)
    • C#編譯器會檢測到代碼是向要求引用類型的方法傳遞值類型,並自動生成代碼實現裝箱
拆箱
  • 把引用類型指向的對象轉成值類型時,運行時會獲取已裝箱對象的各個字段的地址,拆箱之後,會緊接著發生一次字段複製,再把具體的值賦到值類型的變量中

    • 新的值類型變量與引用指向的對象沒有任何關係
  • public static void Main() {
    ArrayList a = new ArrayList();
    a.Add(new Point());
    Point p = (Point)a[0];
    }
  • 本例獲取ArrayList中的引用類型Object實例,並將其轉型為值類型Point的實例p中

      • 第一步為獲取a[0](已裝箱的Point對象)中的各個Point字段的地址(拆箱)

      • 第二步是將字段包含的值複製到基於棧的值類型實例中

  • 相比裝箱而言,拆箱的代價要低的多

    • 拆箱實際是獲取指針的過程,該指針指向包含一個對象中的原始值類型(數據字段)

    • 另外,指針指向的是已裝箱實例中未裝箱的部分

    • 所以,拆箱本身不要求在內存進行字節的複製,但是,緊接著拆箱會發生一次字段複製

  • 拆箱一般發生在強制轉換中,如: int j = (int)o
裝拆箱操作的問題
    • 降低性能,提高操作開銷
    • 創建大量多餘對象,加重GC壓力
關於裝箱和拆箱的其他注意點
  • FCL往往為一些方法提供了各種類型的重載,如Console.WriteLine()

    • public static void WriteLine(Boolean);
      public static void WriteLine(Char);
      public static void WriteLine(String);
      public static void WriteLine(Object);
      ...
    • 大多數方法提供重載的唯一目的就是減少常用值類型的裝箱次數

    • 如果這些方法不存在與值類型對應的參數的重載,那麼調用的肯定是一個Object參數的版本,從而導致裝箱。尤其是使用自定義的值類型時。

      • 因此,定義自己的類時,可將類中的方法定義為泛型,再約束為值類型,這樣方法就可以獲取任何值類型而不必裝箱

  • 另外,如果知道自己的代碼會使編譯器反覆對一個值進行裝箱,就應該改用手動方式先對值類型進行一次裝箱,並在後續直接使用這個已裝箱的值

    • int a = 5;
      Console.WriteLine("{0}, {1}, {2}", a, a, a); //裝箱3次

      Object o = a; //手動裝箱1次
      Console.WriteLine("{0}, {1}, {2}", o, o, o); //不需裝箱

參考書目

  • 《CLR via C#》(第4版) Jeffrey Richter
  • 《深入理解C#》(第3版) Jon Skeet