在學習.NET的時候,因為一些疑問,讓我打算把.NET的類型篇做一個總結。總結以三篇博文的形式呈現。
這篇博文,作為三篇博文的第一篇,主要探討了.NET Framework中的基本類型,以及這些類型一些重要的特性。
第二篇中,我會探討.NET 是如何實現兩個對象的比較的,其中會用到第一篇中的基礎和結論。
第三篇,我從CLR中的常用容器出發,來探討泛型以及它們背后的數據結構。
下面,我們從類型說起。
Primitive, Reference and Value
首先將這三種類型放在一起是不科學的。
Primitive Type(基元類型)只是針對一種語言的,C#的基元類型有int,string等等,VB的基元類型有Integer等等。我們知道C#只是基於FCL的一種語言,基於C#寫出的程序會被支持IL的編譯器編譯為CIL語言。
Reference Type(引用類型)和 Value Type(值類型)才是.NET Framework Class Library(FCL)中的兩種類型,FCL中的任何實例,值,都是這兩種類型的一種。
下面具體介紹下這些類型:
基元類型 Primitive Type:
C#中的基元類型有十幾個:int, string, short, byte 等等。每一個基元類型,必然對應着FCL中的一種類型,比如int對應System.Int32,string對應System.String。因此,基元類型可以看作FCL中對應類型的一個別名。
int a = 0; // Most convenient syntax System.Int32 a = 0; // Convenient syntax int a = new int(); // Inconvenient syntax System.Int32 a = new System.Int32(); // Most inconvenient syntax
以上四句代碼都正確,而且本質是一樣的。
下面列舉了C#中基元類型,被編譯成CIL后的類型,以及對應的.NET Framework type類型。
C# type CIL type .NET Framework type
============================================
short int16 System.Int16
int int32 System.Int32
long int64 System.Int64
float float32 System.Single
double float64 System.Double
因此,那些由編譯器直接支持,將語言本身的關鍵字類型轉換為.NET Framework類型的,就叫做基元類型。
http://www.cnblogs.com/JimmyZhang/archive/2012/11/27/2790759.html#!comments
因此基元類型本質上就是特定語言中的一些別名,它們在.NET Framework中都有對應的類型。
如果查閱VB的資料的話,可以發現Int32在VB中的別名是Integer。Integer是VB的基元類型之一。
因為只有System.Int32, System.String等這些比較基本的類型才在各個語言中有別名,因此這些別名被稱為基元類型。
基元類型的作用,在我看來,應該是為了程序書寫的方便。C#中我們可以使用int 而不是冗長的 System.Int32來定義一個整數,雖然編譯后的IL代碼都一樣。
但是Jeffrey Richter在他的《CL via C#》中,推薦我們使用FCL類型,最主要的原因在於避免混淆,比如:
在C#中,int映射到System.32Int,表示一個32位整數。但是在64位機器上,它表示多少位的整數呢?如果我們知道int實際上是Int32的別名,我們就會知道int在64位機器上依然被編譯起認作32位整數。但如果沒有了解這一點的人會誤認為C# 的int 在64位機器上會表示64位整型。
為了避免引起誤會,Jeffrey Richter建議我們取消使用別名的習慣。
引用類型Reference Type 和 值類型 Value Type
在FCL中,所有類型都派生自System.Object,包括抽象類System.ValueType。
這些類型雖然都派生自System.Object,但是被分為兩類:引用類型和值類型。
Value和Reference這兩種類型的區別:
1. 最顯著的區別:Value類型的實例分配在棧空間上,Reference類型的實例被分配在堆空間上。
這個區別后果就是:值類型的實例不受垃圾回收機制的控制,而垃圾回收是需要消耗時間的,從而節省了時間;而且由於直接申明在棧區,比起在堆區的申明要節省空間和時間(Reference類型的實例話需要先在棧區定義指針,然后再堆區分配內存,費時費空間,想想如果一個程序中所有的Int32都需要到堆區分配空間,所帶來的時間消耗將顯著提高)。
2. 值類型不能被繼承,也不能繼承自其他類型。比如說,我們定義一個struct Car,這個Car類型在定義時就不能包含virtual方法,並且所有方法都被隱式地指明為sealed。
3. 值類型的賦值,本質上是逐個field的拷貝,而引用類型的賦值,則僅僅是棧上指針地址的拷貝,結果是兩個指針指向同一塊托管堆內存。
對於這一點,常常會被放到面試題或筆試題中,比如如下代碼,常常會問r1.x, v1.x, r2.x, v2.x的輸出結果是什么。
// Reference type (because of 'class') private class SomeRef { public Int32 x; } // Value type (because of 'struct') private struct SomeVal { public Int32 x; } public static void Go() { SomeRef r1 = new SomeRef(); // Allocated in heap SomeVal v1 = new SomeVal(); // Allocated on stack r1.x = 5; // Pointer dereference v1.x = 5; // Changed on stack Console.WriteLine(r1.x); // Displays "5" Console.WriteLine(v1.x); // Also displays "5" // The left side of Figure 5-2 reflects the situation // after the lines above have executed. SomeRef r2 = r1; // Copies reference (pointer) only SomeVal v2 = v1; // Allocate on stack & copies members r1.x = 8; // Changes r1.x and r2.x v1.x = 9; // Changes v1.x, not v2.x Console.WriteLine(r1.x); // Displays "8" Console.WriteLine(r2.x); // Displays "8" Console.WriteLine(v1.x); // Displays "9" Console.WriteLine(v2.x); // Displays "5" // The right side of Figure 5-2 reflects the situation // after ALL the lines above have executed.
除此以外,還有一些比較細節的區別:
4. 值類型重寫了Equal 方法和GetHashCode 方法
具體來講,繼承自System.ValueType,而System.ValueType繼承自System.Object並重寫了System.Object的Equal()函數。
5. 值類型在申明時就帶有初值,即使不賦值也不會報異常,而引用類型的變量申明后如果不用new來實例化的話,使用這個變量會報NullReferenceException
FCL中,值類型有:Boolean, Char, Date, 各種數字類型, Enumerations, 和struct 申明的類型。
我們如果查看下關於Int32, Boolean這些值類型的定義:
namespace System { // Summary: // Represents a 32-bit signed integer. [Serializable] [ComVisible(true)] public struct Int32 : IComparable, IFormattable, IConvertible, IComparable<int>, IEquatable<int> { ...... } }
namespace System { // Summary: // Represents a Boolean value. [Serializable] [ComVisible(true)] public struct Boolean : IComparable, IConvertible, IComparable<bool>, IEquatable<bool> { ..... } }
通過上面兩段代碼,我們可以發現:
(1) System.Int32, System.Boolean這些值類型其實也是通過struct申明的
(2) 值類型可以繼承實現借口,但是不能從別的類繼承
值類型的優勢在於:
減少了托管堆的壓力,並且由於垃圾回收次數減少,提升系統性能。
值類型使用條件:
1. 類型不需要被繼承,也不需要從其他類型繼承。
2. 這個類型所包含的任何Field不會被修改。
等等,大家有沒有發現矛盾之處?
值類型既然不該被繼承,也不能繼承自其他類型。但是我們在開篇就說,.NET中不論是值類型還是引用類型,都繼承自System.Object類,這個類是引用類型。具體點,實際上是 Int32,Enum等這些值類型,都是繼承自System.ValueType,而System.ValueType繼承自System.Object。
矛盾嗎?不矛盾,因為我們即將介紹下一節:類型的裝箱狀態和不裝箱狀態。
Boxing and Unboxing(裝箱和拆箱)
值類型對象有兩種形態:unboxed 和 boxed,而引用類型只有boxed的形態。因此, 裝箱和拆箱是針對值類型特有的操作。
我們接着上一節的疑問討論,值類型不能繼承或被繼承,怎么可以說所有值類型也繼承自System.Object?
因為當值類型的數據不得不被當作引用類型對待 (比如說一個函數只接受System.Object作為參數,又比如容器ArrayList只能存放System.Object類型),這個時候,我們的值類型會被“裝箱”。裝箱后的值類型,和引用類型沒有區別,也是在托管堆中有自己的內存,棧中存放的是引用地址。這時候的值類型,繼承自萬物之源System.Object。
裝箱的過程可以被顯示的觸發,當我們使用如下代碼:
Int32 i = 5; Object o = i;
就會觸發Int32類型的 i 被裝箱。而下面的代碼
Int32 i2 = (Int32)o;
則觸發了拆箱。
public static void Main() { Int32 v = 5; // Create an unboxed value type variable. Object o = v; // o refers to a boxed Int32 containing 5. v = 123; // Changes the unboxed value to 123 Console.WriteLine(v + ", " + (Int32)o); // Displays "123, 5" }
上面的代碼中會觸發三次裝箱:
(1) o = v 一次裝箱
(2)Console.WriteLine的輸入要求是String,如果不是String,C#編譯器會自動調用Concat來創建String,而Concat方法的輸入參數是Object類型,因此本身為Int32的v會被裝箱。
(3) 而o雖然本身已經被裝箱,但是它又被轉化成了Int32再被作為Console.WriteLine的參數,也就是說,先被拆箱再被裝箱。
再將代碼做一些變動,如下代碼會觸發v的裝箱嗎?
Int32 v = 123;
Console.WriteLine(v.ToString());
對於這個問題,我們再次查看Int32的定義:
我們發現Int32提供了ToString()方法,v.ToString()不會造成裝箱。CLA via C#中關於這個問題有詳細的解答,當時的場景是定義了Point struct,里面重寫了ToString()方法,然后定義Point p1,調用p1.ToString(),詢問會不會裝箱。
Calling ToString In the call to ToString, p1 doesn’t have to be boxed. At first, you’d think that p1 would have to be boxed because ToStringis a virtual method that is inherited from the base type, System.ValueType. Normally, to call a virtual method, the CLR needs to determine the object’s type in order to locate the type’s method table. Since p1 is an unboxed value type, there’s no type object pointer. However, the just-in-time (JIT) compiler sees that Point overrides the ToString method, and it emits code that calls ToStringdirectly (nonvirtually) without having to do any boxing .The compiler knows that polymorphism can’t come into play here since Pointis a value type, and no type can derive from it to provide another implementation of this virtual method .Note that if Point's ToString method internally calls base.ToString(), then the value type instance would be boxed when calling System.ValueType's ToString method.
ToString()是一個virtual方法,可能我我們的第一反應是:v必須被裝箱來調用其父類System.ValueType的ToString()。
然而,根據書中的說法,因為Int32顯示地通過override關鍵字重寫了ToString()方法(注意,雖然Int32沒有繼承自任何類),編譯器識別出了Override,因此直接調用了ToString()方法而沒有去裝箱。
CLR via C#(3rd version)還給出了別的實例,比如stuct Point實現了接口IComparable,如果把Point 類型的變量轉換為Icomparable,會不會引起裝箱,結果是會的,因為IComparable接口也是Reference Type。
下面還有一段代碼,最后一行的結果是關鍵:
private struct Point : IChangeBoxedPoint { private Int32 m_x, m_y; public Point(Int32 x, Int32 y) { m_x = x; m_y = y; } public void Change(Int32 x, Int32 y) { m_x = x; m_y = y; } public override String ToString() { return String.Format("({0}, {1})", m_x, m_y); } } public static void Go() { Point p = new Point(1, 1); Console.WriteLine(p); //注意點1,p會裝箱 p.Change(2, 2); Console.WriteLine(p); Object o = p; Console.WriteLine(o); ((Point)o).Change(3, 3); Console.WriteLine(o); //注意點2,輸出結果依然為2, 2 }
注釋中的第一個注意點,p定義了ToString(),Console.WriteLine(p)依然會導致裝箱的原因,我們上面已經提到了,Console.WriteLine()不會自動調用ToString(),而是自動調用Concat生成String,Concat接受Object作為參數。
第二個注意點,((Point)o).Change(3, 3)所造成的效果是: o被拆箱后,被賦值到了棧上的另一個臨時Point類型的變量上。因此,是這個臨時變量調用了Change(3, 3), o的內容沒有改變。
相關閱讀: