C#中的基元類型、值類型和引用類型


C# 中的基元類型、值類型和引用類型

1. 基元類型(Primitive Type)

  編譯器直接支持的類型稱為基元類型。基元類型可以直接映射到 FCL 中存在的類型。例如,int a = 10 中的 int 就是基元類型,其對應着 FCL 中的 System.Int32,上面的代碼你完全可以寫作System.Int32 a = 10,編譯器將生成完全形同的 IL,也可以理解為 C# 編譯器為源代碼文件中添加了 using int = System.Int32

1.1 基元類型的算術運算的溢出檢測

  對基元類型的多數算術運算都可能發生溢出,例如

byte a = 200;
byte b = (Byte)(a + 100);//b 現在為 4

  上面代碼生成的 IL 如下

  從中我們可以看出,在計算之前兩個運算數都被擴展稱為了32位,然后加在一起是一個32位的值(十進制300),該值在存到b之前又被轉換為了Byte。C# 中的溢出檢查默認是關閉的,所以上面的運算並不會拋出異常或產生錯誤,也就是說編譯器生成 IL 時,默認選擇加、減、乘以及轉換操作的無溢出檢查版本(如上圖中的 add 命令以及conv.u1都是沒有進行溢出檢查的命令,其對應的溢出檢查版本分別為add.ovf和conv.ovf),這樣可以使得代碼快速的運行,但前提是開發人員必須保證不發生溢出,或者代碼能夠預見溢出。
  C#中控制溢出,可以通過兩種方式來實現,一種全局設置,一種是局部控制。全局設置可以通過編譯器的 /checked 開關來設置,局部檢查可以使用 checked/unchecked 運算符來對某一代碼塊來進行設置。進行溢出檢查后如果發生溢出,會拋出 System.OverflowException 異常。通過上述設置后編譯器編譯代碼時會使用加、減、乘和轉換指令的溢出檢查版本。這樣生成的代碼在執行時要稍慢一些,因為 CLR 要檢查這些運算是否發生溢出。
  使用溢出檢查

checked{
             byte a = 200;
             byte b = (Byte)(a + 100);
        }
        //亦可以通過下面的方式來實現
        // byte b = checked((Byte)(a + 100));

最佳實踐: 在開發程序時打開 /checked+ 開關進行調試性生成,這樣系統會對沒有顯式標記為 checkedunchecked 的代碼進行溢出檢查,此時發生異常便可以輕松捕捉到,及時修正代碼中的錯誤 ,正式發布時使用編譯器的 /checked- 開關,確保代碼能夠快速運行,不會產生溢出異常。

2. 值類型和引用類型

  CLR 支持兩種類型:值類型和引用類型,下面引用 MSDN 對兩者的定義:
  

2.1 值類型

  值類型直接包含它的數據,值類型的實例要么在堆棧上,要么在內聯結構中。與引用類型相比,值類型更為"輕",因為它們不需要在托管堆上分配內存,亦不受垃圾回收器的控制,無需進行垃圾回收,C#中的值類型都派生自System.ValueType ,值類型主要包括兩種類型:結構枚舉, 結構可以分為以下幾類:

  1. 數值類型
  2. bool 類型
  3. char 類型
  4. 用戶自定義的結構

  值類型的特點:

  1. 所有的值類型都直接或間接的派生自 System.ValueType
  2. 值類型都是隱式密封的,即不能從其它任何類型繼承,也不能派生出任何的類型,目的是防止將值類型用作其它引用類型的基類型。
  3. 將值類型賦值給另外一個值類型的變量時,會逐字段進行復制。
  4. 每種值類型都有一個默認的構造函數來初始化該類型的默認值。

  自定義類型時,什么情況下適合將類型定義為值類型?

  1. 類型具有基元類型的特點,即該類型十分簡單,沒有成員會修改類型的任何實例字段
  2. 類型不需要從其它類型繼承,亦不派生出任何的類型
  3. 類型的實例字段較小(16字節或更小)
  4. 類型的實例較大(大於16字節),但不作為方法的實參傳遞,也不從方法返回。

  對於后兩點是因為實參默認以傳值的方式進行傳遞,造成對值類型中的字段進行復制,造成性能上的損害。被定義為返回一個值類型的方法返回時,實例中的字段會復制到調用者的分配的內存中,對性能造成損害。

值類型的裝箱和拆箱

  裝箱:將值類型轉換為引用類型的過程稱為 裝箱(Box).
  對值類型實例進行裝箱時所發生的事情如下所示:
  1. 在托管堆中分配內存。分配的內存量是值類型各字段所需的內存量,還要加上托管堆所有對象都有的兩個額外成員(類型對象指針和同步塊索引)所需的內處量。
  2. 值類型的字段復制到新分配的堆內存中
  3. 返回對象的地址。現在該地址是對象的引用;值類型變成了引用類型。

注意
  由於值類型的裝箱需要在托管堆上分配內存,因此是較為耗費性能的,應盡量避免進行過多的裝箱操作。因此許多的方法會有多個重載,目的就是減少常用值類型的發生裝箱的次數;如果知道自己的代碼造成編譯器對一個值類型進行多次重復的裝箱,可以采用手動方式進行裝箱,這樣的代碼會更小、更快;在定義自己的類型時,可以將類型中的方法定義為泛型,這樣方法便可以獲取所有的類型,從而不必對值類型進行裝箱。

  下面通過例子對裝箱進行說明

    int v = 20;//創建未裝箱值類型變量
    object o = v;//v 引用已裝箱、包含值20的int32
    v = 123;//將未裝箱的值修改為123
    Console.WriteLine(v + "," + (int)o);//輸出 "123,20"
    正常情況下這里不應該這么寫,因為會導致編譯器發生一次多余的拆箱和裝箱操作,而應該
    Console.WriteLine(v+","+o);

上面代碼編譯出的 IL 如下所示:

     .entrypoint
    .maxstack 3
    .locals init (
        [0] int32 num,
        [1] object obj2)
    L_0000: nop 
    L_0001: ldc.i4.s 20
    L_0003: stloc.0 
    L_0004: ldloc.0 
    L_0005: box [System.Runtime]System.Int32
    L_000a: stloc.1 
    L_000b: ldc.i4.s 0x7b
    L_000d: stloc.0 
    L_000e: ldloc.0 
    L_000f: box [System.Runtime]System.Int32
    L_0014: ldstr ","
    L_0019: ldloc.1 
    L_001a: unbox.any [System.Runtime]System.Int32
    L_001f: box [System.Runtime]System.Int32
    L_0024: call string [System.Runtime]System.String::Concat(object, object, object)
    L_0029: call void [System.Console]System.Console::WriteLine(string)
    L_002e: nop 
    L_002f: ret 

  通過觀察上述 IL 可以看出 box 指令出現了三次,說明上述代碼在編譯過程中發生了三次裝箱。  
  首先在棧上創建一個 Int 32 的未裝箱值類型實例v,將其初始化為20,再創建 object 類型的變量o,讓它指向v,但由於引用類型的變量始終指向堆中的對象,因此 C# 會生成代碼對v進行裝箱,將v裝箱的副本的地址存儲到o中。這里進行了第一次裝箱。
   接着調用 WriteLine 方法,該方法要求一個 string 類型的參數,但這里沒有 string 對象,只有三個數據項:未裝箱的 Int32 值類型的實例v,一個字符串,一個對已裝箱 Int 32 值類型實例的引用o,它要轉換為值類型的 Int32,為了創建一個 string 對象,C#編譯器調用 StringConcat 方法,由於具有三個參數,因此編譯器調用 Concat 方法的如下版本的重載:Concat(Object arg0,Object arg1,Object arg2),為第一個參數傳遞的是v,這是一個未裝箱的值參數,因此必須對v進行裝箱,這是第二次裝箱,第二個參數傳遞的是“,”,作為String 對象引用傳遞,對於第三個參數 arg2,o 會被轉型為 Int 32,這要求進行拆箱操作,從而獲取包含在已裝箱的 Int 32 中未裝箱的 Int 32 的地址,然后這個未裝箱的值類型必須再次被裝箱,這是第三次裝箱。

注意:雖然未裝箱的值類型沒有類型對象指針,但仍然可以調用由類型繼承或重寫的虛方法(如ToString,GetHashCode,Equals),並且此時並不會對值類型進行裝箱操作。但在調用非虛的、繼承的方法(GetType 或 MemberwiseClone) 時,無論如何都會對值類型進行裝箱。因為這些方法由System.Object 定義,要求 this 實參是一個指向堆對象的指針。此外,將值類型轉換為類型的某個接口時要對實例進行裝箱。因為接口變量必須包括對堆對象的引用。

  拆箱: Object 向值類型或接口類型向實現了該接口的值類型的顯式轉換稱為拆箱(UnBox)
  相對裝箱,拆箱的代價要比裝箱低的多。注意,拆箱並不是裝箱的逆過程,拆箱就是獲取指針(地址)的過程,該指針指向對象中的原始值類型(數據字段).拆箱時內部發生了如下的事情:
  1. 如果包含“對已裝箱值類型實例的引用”的變量為 Null 時,拋出 NullReferenceException 的異常。
  2. 如果引用的對象不是值類型的已裝箱實例,拋出 InvalidCaseException 的異常。
  3. 如果前面兩步都沒有問題,那么將該值從實例復制到值類型的變量中。 
  

2.2  引用類型

  C# 中所有的引用類型總是從托管堆分配(初始化新進程時,CLR會為進程保留一個連續的地址空間區域,該區域稱為托管堆),C#的 new 運算符返回對象的內存地址-即指向對象數據的內存地址。使用new運算符創建對象的過程如下:

  1. 計算類型及其所有基類型(直到System.Object)中定義的所有的實例字段所需的字節數。堆上的對象都需要一些額外的成員(OverHead),包括類型對象指針(Type Object Pointer)和同步塊索引(sync block index),CLR 利用這些成員管理對象。額外成員的字節數要記入對象的大小。
  2. 從托管堆中分配對象所需要的字節數。從而分配對象的內存,分配的所有字節都設為0
  3. 初始化對象的類型對象指針和同步塊索引。
  4. 調用類型的實例構造器。傳遞在調用new中指定的實參(如果有的話),大多數編譯器會在構造器中自動生成代碼調用基類的構造器。每個類型的構造器都負責初始化該類型定義的實例字段。最終調用 System.Object 的構造器,該構造器什么都不做,只是簡單的返回。

  執行完上訴的過程后 new 操作符會返回一個新建對象的引用或指針。
  那么創建引用類型的實例,是否必需調用構造函數呢?答案是否定的,在調用對象的 MemberwiseClone 方法進行對象的復制時,並不會調用類型的構造方法。該方法的作用在於分配內存,初始化附加字段,然后將原類型的字節數據復制到新的對象中。另外,在進行反序列化操作生成對象的實例時,也不會調用類型的構造函數。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM