C#與CLR學習筆記(4)—— 值類型與引用類型

目錄

值類型與引用類型的關係

值類型與引用類型的使用區別

裝箱與拆箱

造成裝箱的其他情況

參考文獻


值類型與引用類型的關係

在CLR(或者說CTS)中,引用類型包括以下幾種:

  • 類類型(Class)
  • 接口類型(Interface)
  • 委託類型(Delegate)

所有的引用類型都直接或間接地繼承自 System.Object 類。我們自己定義的類,如果沒有明確基類,也是直接繼承自 System.Object 的。

值類型包括兩種:

  • 結構類型(Structure)
  • 枚舉類型(Enumeration)

值類型的繼承關係如下圖所示:

上圖中,實線箭頭表示直接的繼承關係,虛線並不是嚴格的繼承關係,可以認爲是一種歸類關係。

值類型存在的意義在於,將小型的類型放到線程棧(而不是堆棧)上,利用值傳遞、無需垃圾回收等特點,起到提高執行效率的作用。所有的值類型都是一種結構體(Structure)或者枚舉(Enumeration)。例如,我們來看內置的值類型 Sytem.Int32 的源碼:

所有的值類型都是繼承自 System.ValueType 類。由於與引用類型的行爲不同,ValueType 類重寫了 Object 類的 Equals 方法和 GetHashCode 方法。

值類型與引用類型的使用區別

這裏主要以結構體爲例討論值類型的特點。對於枚舉(Enumeration)類型,它比較特殊,本文不深入探討它。

(1)聲明與初始化

值類型在聲明時,就會在線程棧中分配一個實例,並將字段初始化爲0(引用類型的字段被初始化爲 null)。但是對於引用類型,聲明時僅在線程棧上創建一個 null 引用,不會在託管堆上創建一個實例。

因此,對於結構體,以下代碼是合法的:

MyStruct  myStruct;

myStruct.A = 1;

myStruct.B = 2;

值類型 和 引用類型 都可以通過 new 關鍵字來聲明並初始化一個對應的類型。

對於值類型的直接聲明例如 int i = 0 ,實際上被編譯器當做 System.Int32 i = new System.Int32() 來處理

但是,對於值類型,new 操作符做的工作與引用類型是不同的。對於值類型,會在線程棧上分配一個值類型的實例,並將所有字段都初始化爲0。

對於值類型,使用 new聲明並實例化 與直接聲明,都會將字段初始化爲0。兩者區別是:編譯器認爲前者已經實例化;編譯器認爲後者沒有實例化,若使用其字段會拋出異常。

(2)複製

引用類型的變量的複製,不會在託管堆上重新創建一個類型對象,而值類型變量的複製,會真正地在線程棧上覆制一個值類型的實例。因此,對於比較複雜的類型,不適合定義爲值類型,因爲在 變量複製、作爲參數傳遞 等情景中會造成很大的性能和內存開銷。這時可以將值類型作爲 ref 參數來傳遞,這樣就與引用類型一樣快了,因爲傳遞的是值類型在線程棧中的地址。

(3)使用 值類型 還是 引用類型?

上文中已經提過,值類型並不是一定能提供更好的性能。在設計自己的類型時,滿足下面的所有條件時,才能考慮使用值類型:

  • 該類型具有基元類型(primitive type)的行爲,即像 int, char 一樣,十分簡單。沒有成員會修改類型的任何實例字段(即 類型是 不可變 immutable 的)。對於值類型,建議將全部字段標記爲 readonly。
  • 該類型不需要繼承自其他類型。
  • 該類型不派生出任何類型。
  • 類型的實例較小(推薦 < 16 Byte),或者雖然 > 16 Byte,但不會作爲方法參數和返回值進行傳遞。

需要說明的是,值類型雖然不允許繼承和派生,但它本身最終是派生於 System.Object 的,因此值類型可以調用 Object 類的所有方法。例如在結構體中可以重寫 ToString() 方法。

(4)成員變量

值類型的字段可以都是public的,而對於類,推薦採用私有字段+共有屬性的方式。

值類型中的方法不能是虛方法或者抽象方法,因爲值類型不允許繼承。

(5)構造函數

對於 Structure,編譯器總是提供一個無參的默認構造函數,而且不允許替換(即,不允許自己定義一個無參的構造函數)。

此外,對於Structure,在定義其內部字段時,不允許提供默認值,因爲默認的無參構造函數是一定會發生作用的,它把值類型的字段初始化爲0,把引用類型的字段初始化爲 null,提供默認值是沒有意義的。

值類型的上述特點是與編譯器和 CLR 有關的。與引用類型不同,爲了提高性能,在實例化一個值類型時,CLR不會爲值類型調用構造函數,除非顯式地使用 new 調用自定義的有參的構造函數。

因此,當一個引用類型 R 擁有一個值類型 V 的字段時,如果我們在 R 的構造函數裏不爲 V 進行初始化,那麼編譯器並不會(像對待引用類型字段那樣)生成代碼來調用 V 的默認無參構造函數, CLR 僅僅是開闢內存並把 V 的字段設置爲0。因此,爲了不讓開發者弄混值類型的無參構造函數在什麼時候被調用,CLR 乾脆直接禁止開發者爲值類型定義無參構造函數。

(6)重寫 Equals 方法

上文中提過,System.ValueType 類重寫了 Equals 和 GetHashCode 方法。但是對於用戶自定義的值類型來說,這倆方法的默認實現存在着性能問題。因此,微軟官方推薦,在我們定義了一個值類型後,需要重寫這兩個方法,以及 == 和 != 等運算符。

裝箱與拆箱

有時候,我們需要把值類型當做引用類型來使用,獲取值類型實例的引用。例如:若一個方法,它需要一個 Object 對象(的引用)作爲其參數,值類型要是想使用該方法,就必須將值類型轉換成在託管堆中的對象,並獲取該對象的引用。這就是裝箱。

裝箱,就是將值類型拷貝到託管堆中,使其變爲一個引用類型。需要說明的是,裝箱操作是自動進行的。編譯器檢測到需要進行裝箱時,會自動生成裝箱的IL代碼。

拆箱是裝箱的逆過程,它將託管堆中的對象拷貝到線程棧中。包含兩個步驟:

(1)獲取對象的各個字段的地址,這一步叫做 “拆箱”;

(2)將字段的值從堆中複製到棧的實例中。

拆箱需要我們顯式地指明拆箱後的值類型。若類型不符合會拋出 InvalidCastException 異常。

裝箱和拆箱會造成性能損失。由於裝箱一般是隱式進行的,難以察覺,因此我們應該在編碼中留意潛在的裝箱操作,並儘量避免之。

例如,考察如下代碼:

public static void Main() 
{
    Int32 v = 5;
    Object o = v;
    v = 123;
    Console.WriteLine(v + ", " + (Int32) o);
}

上面的代碼會進行3次裝箱,因爲 Console.WriteLine() 方法的參數是 String引用類型,括號中的三部分需進行字符串拼接,將v和拆箱後的o裝箱,所以最後一行造成了2次裝箱。

可將最後一行修改爲如下:

Console.WriteLine(v + ", " + o);

便可避免1次拆箱和1次裝箱。這個案例的性能和內存的優化不是很明顯,但是如果在大量循環中減少一些裝箱和拆箱操作,將顯著提升運行效率。

上述代碼可以進一步優化:

Console.WriteLine(v.ToString() + ", " + o);

可以避免變量 v 的裝箱操作。

還有一點需要說明。對比另一個案例:

public static void Main() 
{
    Int32 v = 5; 
    Object o = v;

    v = 123;
    Console.WriteLine(v);

    v = (Int32) o; 
    Console.WriteLine(v); 
}

注意,這段代碼只進行了1次裝箱。因爲 Console.WriteLine(v) 中,v 沒有進行裝箱,因爲 Console.WriteLine() 提供了參數爲 Int32 型的重載(注意與上個案例的不同,上個案例的參數是個 String)。雖然這個重載內部可能會對 Int32 進行裝箱,但這不是我們需要關心的,我們已經把 自己的 代碼中的裝箱次數降到了最少。

其實,很多類似的方法都實現了採用值類型作爲參數的重載,目的就是爲了減少 常用 值類型的裝箱次數(若是你自己定義的值類型,則起不到這種效果,因爲沒有這樣的重載啊)。

我們在定義自己的方法時,可將方法定義爲泛型方法,這樣就可以獲取任何值類型而不必裝箱。

造成裝箱的其他情況

(1)當值類型重寫了 ToString, Equals 等虛方法,且在其中調用了基類的實現,那麼在調用基類實現時,就會進行裝箱,因爲基類方法要求 this 指針指向堆上的一個對象。

(2)當值類型調用非虛的繼承的方法時(例如 GetType 和 MemberwiseClone ),無論如何都會造成裝箱,因爲這些方法由 Object 定義,方法要求 this 實參是指向堆對象的一個指針。 

(3)若值類型實現了某個接口,當值類型的實例要轉換成這個接口類型時,這個實例也會進行裝箱,因爲 接口類型的變量 必須指向一個堆上的對象。可參考接口的相關內容,這裏不作詳細說明。

參考文獻

[1] Common Type System

[2] 《C#高級編程》

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