1.基元類型
有些數據類型我們平常寫代碼經常會用到,例如:int,string等,例如下面我們定義一個整數:
int a =0;
我們也可以用下面的寫法定義:
System.Int32 a = new System.Int32();
以上兩種寫法的結果都是一樣的,為什麼會這樣呢?,大家肯定知道是編譯器做的優化;本人比較喜歡第一種寫法,因為這種語法不僅增強了代碼的可讀性,生成的IL代碼跟System.Int32的IL代碼是完全一樣的。那什麼是基元類型呢?就是編譯器能直接支持的數據類型稱為基元類型(primitive type).基元類型直接映射到.NET框架類庫(FCL)中存在的類型。會不會很難理解?例如:c#中的int直接映射到了System.Int32類型。再看下面的代碼,雖然寫的方式不一樣,但是它們生成的IL代碼是完全一致的。只要是符合公共語言規范(CLS)的類型,不管那種語言都有提供了類似的基元類型。但是,不符合CLS的類型語言就不一定支持。
int a = 0; System.Int32 a = 0; int a = new int(); System.Int32 a =new System.Int32();
但是CLR via C#的作者是不推薦這種簡潔的寫法,下面就把他不推薦的理由照抄過來,大家可以作為參考:
1.許多開發人員糾結於是用String還是string。由於c#的string(一個關鍵字)直接映射到System.String(一個FCL類型),所以兩者沒有區別,都可以使用。類似,一些人開發人員說應用程序在32位操作系統上運行,int代表32位整數;在64位系統上運行,int代表64位整數。這個說法完全錯誤。c#的int始終映射到System.Int32,所以不管在什麼操作系統上運行,代表的都是32位整數。如果程序員習慣在代碼中使用Int32,像這樣的誤解就沒有了。
2.c#的long映射到Sytem.Int64,但在其他編程語言中,Long可能映射到Int16或Int32.例如,c++/CLI就將long視為Int32.習慣於用一種語言寫程序的人在看用另一種語言寫的源代碼時,很容易錯誤理解代碼意圖。事實上,大多數語言甚至不將long當作關鍵字,根本不編譯使用了它的代碼。
3.FCL的許多方法都將類型名作為方法名的一部分。例如,BinaryReader類型的方法包括ReadBoolean,ReadInt32,ReadSingle等;而System.Convert類型的方法包括ToBoolean,ToInt32,ToSingle等。以下代碼雖然語法沒問題,但包含float的那一行顯得很別扭,無法一下子判斷該行的正確性:
BinaryReader br = new BinaryReader(...); float val = br.ReadSingle(); //正確,但感覺別扭 Singleval = br.ReadSingle(); //正確,感覺自然
4.平時只用c#的許多程序員逐漸忘了還可以用其他語言寫面向CLR的代碼,“c#主義”逐漸入侵類庫代碼。例如,Microsoft的FCL幾乎是完全用c#寫的,FCL團隊向庫中引入了像Array的GetLongLength這樣的方法。該方法返回Int64值。這種值在c#確實是long,但在其他語言中不是。另一個例子是System.Linq.Enumerable的LongCount方法。
以上就是作者不推薦基元類型的原因,以至於CLR via c#裡面所描述的類型都是FCL的類型名稱。不過我自己倒覺得使用基元類型看起來比用FCL類型順眼,所以還是自己的習慣吧,至少兩種類型生成的IL代碼都是一樣的,也沒有說用這種性能好,用那種性能不好的說法。在這裡引用了博友的圖片,圖代表了C#的基元類型與對應的FCL類型
//引用類型 class Ref { public int x; } //值類型 struct Val { public int x; } static void Demo() { Ref r1 = new Ref(); //托管堆分配 Val v1 = new Val(); //棧上分配 r1.x = 5; //提領指針 v1.x = 5; //在棧上修改 Console.WriteLine(r1.x); //顯示5 Console.WriteLine(v1.x); //同樣顯示5 Ref r2 = r1; //只復制指針 Val v2 = v1; //在棧上分配並復制成員 r1.x = 8; //r1.x和r2.x都會改變 v1.x = 9; //v1.x會改變,v2.x不會 Console.WriteLine(r1.x); //顯示8 Console.WriteLine(r2.x); //顯示8 Console.WriteLine(v1.x); //顯示9 Console.WriteLine(v2.x); //顯示5 }
值類型的主要優勢是不作為在托管堆上分配。當然,與引用類型相比,值類型也存在自身的一些局限。下面列出了值類型和引用類型的一些區別。
1.值類型有已裝箱和未裝箱2種表示形式,而引用類型總是處於已裝箱形式。
2.值類型總是從System.ValueType中派生。跟System.Object具有相同的方法。但是ValueType重寫了Equals方法,能在兩個對象的字段值完全匹配前提下返回true,此外也重寫了GetHashCode方法。
3.值類型的所有方法都是不能為抽象的,都是隱式密封的(不可以重寫)。
4.將值類型變量賦給另一個值類型變量,會執行逐字段復制。將引用類型的變量賦給另一個引用類型變量只復制內存地址,所以兩個引用類型的變量都是指向堆同一個對象,所以對一個變量執行操作可能影響到另一個變量引用的對象。
5.由於未裝箱的值類型不在堆中分配,一旦定義了該類型的一個實例方法不在活動,為它們分配的存儲就會被釋放,而不是等著進行垃圾回收。
6.引用類型變量包含堆中對象的引用地址。引用類型的變量創建時默認初始化為null,代表當前不指向任何對象,試圖使用null引用類型變量都會拋出NullReferenceException異常。相反,值類型的所有成員都被初始化為0,訪問值類型不可能拋出NullReferenceException異常。CLR已經為值類型添加了可空標識
3.裝箱和拆箱
講到引用類型和值類型,必定要講下裝箱和拆箱了,下面開始講述:
值類型比引用類型“輕”,原因是它們不作為對象在托管堆中分配,不被垃圾回收,也不通過指針進行引用。但許多時候需要獲取對值類型實例的引用。例如下面這個栗子,創建一個ArrayList(這裡是為了舉例子而用ArrayList來裝值類型,平常最好不要這樣用,因為FCL已經提供了泛型集合類,List<T>在操作值類型不會進行裝箱和拆箱)來容納一組Point結構,上代碼:
//聲明值類型 struct Point { public int x, y; } static void Main() { ArrayList arraylist = new ArrayList(); Point p; //在棧上分配一個point for (int i = 0; i < 10; i++) { p.x = p.y = i; //初始化成員 arraylist.Add(p); //對值類型裝箱,將引用添加到ArrayList中 } Console.ReadLine(); }
上面的代碼大家很容易就能看出來,每次循環迭代都初始化一個Point的字段,並將該對象存儲在arraylist中。但思考下ArrayList中究竟存儲了什麼?是point結構,還是地址,還是其他的東西?要知道答案,我們來看下ArrayList的Add方法,了解它的參數被定義成什麼。代碼如下
// // 摘要: // 將對象添加到 System.Collections.ArrayList 的結尾處。 // // 參數: // value: // 要添加到 System.Collections.ArrayList 的末尾處的 System.Object。 該值可以為 null。 // // 返回結果: // System.Collections.ArrayList 索引,已在此處添加了 value。 // // 異常: // System.NotSupportedException: // System.Collections.ArrayList 為只讀。 - 或 – System.Collections.ArrayList 具有固定大小。 public virtual int Add(object value);
可以看出Add方法獲取的是一個object參數。也就是說,Add獲取對托管堆上的一個對象的引用(或指針)來作為參數。但是point又是值類型的,所以必須轉換成真正的在堆中托管的對象。將值類型轉換成引用類型稱為裝箱。那麼裝箱發生了什麼呢?
1.在托管堆分配內存。分配的內存量是值類型各字段所需的內存量,同時還有兩個額外的成員(類型對象指針和同步塊索引)所需的內存量。
2.值類型的字段復制到新分配的堆內存。
3.返回對象地址。這時值類型就成了引用類型了。
知道了裝箱之後,我們再來看下拆箱是怎麼運行了:
拆箱不是直接將裝箱過程倒過來,它其實就是獲取指針的過程,該指針指向包含在一個對象中的原始值類型,然後再進行字段的復制。所以拆箱的代價比裝箱低得多
那麼已裝箱值類型實例在拆箱時,內部發生下面這些事情:
1.如果包含“對已裝箱值類型實例的引用”的變量為null,拋出NullReferenceException異常
2.如果引用的對象不是所需值類型的已裝箱實例,拋出InvalidCastException異常。
用代碼來看看裝箱和拆箱的例子
static void Main() { int val = 5; //創建未裝箱值類型變量 object obj = val; //val進行裝箱 val = 123; //將val值改為123 Console.WriteLine(val + "," + (int)obj); //顯示123,5 }
大家能從上面代碼看出有多少次裝箱和拆箱嗎?利用ILDasm查看本代碼的IL就能很清楚看出來:
.method private hidebysig static void Main() cil managed { .entrypoint // 代碼大小 47 (0x2f) .maxstack 3 .locals init ([0] int32 val, [1] object obj) IL_0000: nop //將5加載到val中 IL_0001: ldc.i4.5 IL_0002: stloc.0 //將val進行裝箱,將引用指針存儲到obj中 IL_0003: ldloc.0 IL_0004: box [mscorlib]System.Int32 IL_0009: stloc.1 //將123加載到val中 IL_000a: ldc.i4.s 123 IL_000c: stloc.0 //將val進行裝箱,將指針保留在棧上以進行Concat操作 IL_000d: ldloc.0 IL_000e: box [mscorlib]System.Int32 //將字符串加載到棧上 IL_0013: ldstr "," //對obj進行拆箱,獲取一個指針,指向棧上的Int32字段 IL_0018: ldloc.1 IL_0019: unbox.any [mscorlib]System.Int32 IL_001e: box [mscorlib]System.Int32 //調用Concat方法 IL_0023: call string [mscorlib]System.String::Concat(object, object, object) //返回字符串 IL_0028: call void [mscorlib]System.Console::WriteLine(string) IL_002d: nop //從main返回,終止引用程序 IL_002e: ret } // end of method Program::Main
上面的IL顯示出三個box和一個unbox,第一次裝箱很明顯就能看出來,但是第二三次裝箱可能有些同學會無法理解,在調用writeline這個方法時返回一個String對象,所以c#編譯器生成代碼來調用String的靜態方法Concat。該方法有幾個重載版本,而在這裡調用了一下版本:
public static string Concat(object arg0, object arg1, object arg2);
所以在代碼中val和轉成Int32的obj都會裝箱然後傳給Concat中。大家有興趣可以把上面的代碼改下,輸入結構代碼變成Console.WriteLine(val + "," + obj); 然後再看看IL代碼,你會發現減少了一次裝箱和拆箱而且代碼的大小減少了10字節左右。所以證明額外的裝箱拆箱會在托管堆分配一個額外的對象,然後還要進行垃圾回收,可見過多的裝箱操作會影響到程序的性能和內存的消耗。所以我們盡可能地在自己的代碼中減少裝箱。
如果知道自己的代碼在編譯器中會反復發生裝箱,那最好是用手動方式對其進行裝箱,例如:
static void Main() { int val=5; //會進行3次裝箱 Console.WriteLine("{0}{1}{2}",val,val,val); //手動進行裝箱 object obj=val; //不會發生裝箱 Console.WriteLine("{0}{1}{2}",obj,obj,obj); }
以上羅列出了基元類型、引用類型和值類型的區別,最後加了裝箱和拆箱的,文碼並茂,希望能給你帶來比較深刻的印象,雖然不夠深,但願能夠起到拋磚引玉的作用。本文參考了CLR via C#,很推薦大家有空可以閱讀此書。以後我還會在這個系列中多寫些文章,分享給大家。