CLR via C#學習筆記-第五章-值類型的裝箱和拆箱
5.3 值類型的裝箱和拆箱
裝箱
值類型比引用類型“輕”,原因是他們不作為對象在托管堆中分配,不被垃圾回收,也不通過指針進行引用。
但許多時候都需要獲取對值類型實例的引用。例如,假定要創建ArrayList對象來容納一組Point結構,代碼如下
struct Point { public Int32 x,y; } public sealed class Program { public static coid Main() { ArrayList a=new ArrayList(); Point p;//分配一個Point,不再堆中分配 for(Int32 i=0;i<10;i++) { p.x=p.y=i;//初始化值類型中的成員 a.Add(p);//對值類型裝箱,將引用添加到ArrayList中 } ... } }
每次循環叠代都初始化一個Point的值類型字段,並將該Point存儲到ArrayList中。
ArrayList中存儲的時Point結構還是Point結構的地址呢?
必須研究ArrayList的Add方法,了解它的參數被定義成什麽類型。本例的Add方法原型如下。
public virtual Int32 Add(Object value);
可以看出Add獲取的是一個Object參數,也就是說Add獲取對托管堆上的一個對象的引用來作為參數。
但代碼傳遞的是p,Point是值類型。為了使代碼正確工作,Point值類型必須轉換成真正的、在堆中托管的對象,而且必須獲取對該對象的引用。
將之類型轉換成引用類型要使用裝箱機制。下面總結了對值類型的實例進行裝箱時發生的事情。
- 在托管堆中分配內存。分配的內存量是值類型各字段所需的內存量,還要加上托管堆所有對象都有的兩個額外成員(類型對象指針和同步塊索引)所需的內存量。
- 值類型的字段復制到新分配的堆內存。
- 返回對象地址。現在該地址是對象引用;值類型成了引用類型。
C#編譯器自動生成對值類型實例進行裝箱所需的IL代碼,但仍需要理解其中內部的工作機制。
在運行時,當前存在於Point值類型實例p中的字段復制到新分配的Point對象中。
已裝箱Point對象的地址返回並傳給Add方法。
Point對象一直存在於堆中,直到被垃圾回收。
Point值類型變量可被重用,因為ArrayList不知道關於他的任何事情。
在這種情況,已裝箱值類型的生存期超過了未裝箱值類型的生存期。
註意,FCL現在包含一組新的泛型集合類,非泛型類已經被淘汰。
例如,應該使用System.Collections.Generic.List<T>類而不是System.Collections.ArrayList類。
最大的改進就是泛型集合類允許開發人員在操作值類型時不需要對集合中的項進行裝箱拆箱。
此外,開發人員還獲得了編譯時的類型安全性。
拆箱
假定要用以下代碼獲取ArrayList的第一個元素
Point p=(Point) a[0];
他獲取ArrayList的元素0包含的引用或指針,試圖將其放到Point值類型的實例p中。
為此已裝箱Point對象中的所有字段都必須賦值到值類型變量p中,後者在線程棧上。
CLR分兩步完成復制
- 第一步獲取已裝箱Point對象中的各個Point字段的地址。這個過程稱為拆箱。
- 第二步將字段包含的值從堆復制到基於棧的值類型實例中。
拆箱不是直接將裝箱過程倒過來。拆箱的代價比裝箱低得多。
拆箱其實就是獲取指針的過程,該指針指向包含在一個對象中的原始值類型(數據字段)。
其實,指針指向的是已裝箱實例中的未裝箱部分。
所以和裝箱不同,拆箱不要求在內存中復制任何字節。往往緊接著拆箱發生一次字段復制。
已裝箱值類型實例在拆箱時,內部發生這些事。
- 如果包含“對已裝箱值類型實例的引用”的變量變為null,拋出NullReferenceException異常。
- 如果引用的對象不是所需值類型的已裝箱實例,拋出InvalidCastException異常。
第二條意味著以下代碼的工作方式和你想的可能不太一樣。
public static void main() { Int32 x=5; Object o=x;//對x裝箱,o引用已裝箱對象 Int16 y=(Int16)o;//拋出InvalidCastExcrption異常 }
從邏輯上說,完全能獲取o引用的已裝箱Int32,將其強制轉型為Int16。但在對象進行拆箱時,只能轉型為最初未裝箱的值類型——本例為Int32。以下是上述代碼的正確寫法。
public static void main() { Int32 x=5; Object o=x;//對x裝箱,o引用已裝箱對象 Int16 y=(Int16)(Int32)o;//先拆箱為爭取類型,再轉型 }
前面說過,一次拆箱操作常緊接著一次字段復制,以下c#代碼演示了拆箱和復制。
public static void main() { Point p; p.x=p.y=1; Object o=p;//對p裝箱,o引用已裝箱實例 p=(Point)o;//對o拆箱,將字段從已裝箱實例復制到棧變量中 }
最後一行,C#編譯器生成一條IL指令對o拆箱(獲取已裝箱實例中的字段的地址),並生成另一條IL指令將這些字段從堆賦值到基於棧的變量p中。
前面說過,未裝箱值類型比引用類型更輕。歸結於以下兩個原因。
- 不再托管堆上分配。
- 沒有堆上的每個對象都有的額外成員:“類型對象指針”和“同步塊索引”。
由於未裝箱值類型沒有同步塊索引,所以不能使用System.Threading.Monitor類型的方法或C#lock語句讓躲著線程同步對實例的訪問。
雖然未裝箱值類型沒有類型對象指針,但仍可調用由類型繼承或重寫的虛方法(比如Equals,GetHashCode或ToString)。
如果值類型重寫了其中任何虛方法,那麽CLR可以非虛地調用該方法,因為值類型隱式密封,不可能由類型從他們派生。而且調用虛方法的值類型實例沒有裝箱。
然而,如果重寫的虛方法要調用方法在基類中的實現,那麽在調用基類的實現時,值類型實例會被裝箱,以便能通過this指針將對一個堆對象的引用傳給基方法。
但在調用非虛的繼承的方法時,無論如何都要對值類型進行裝箱。因為這些方法由System.Object定義,要求this實參時指向堆對象的指針。
此外將值類型的未裝箱實例轉型為類型的某個接口時要對實例進行裝箱。這是因為接口變量必須包含對堆對象的引用。
裝箱拆箱代碼演示
internal struct Point : IComparable { private Int32 m_x, m_y; //構造器負責初始化字段 public Point(Int32 x,Int32 y) { m_x = x; m_y = y; } //重寫從System.ValueType繼承的ToString方法 public override string ToString() { //將point作為字符串返回,註意調用ToString以避免裝箱 return String.Format($"({m_x.ToString()},{m_y.ToString()})"); } //實現類型安全的ComparableTo方法 public Int32 CompareTo(Point other) { //利用勾股定理計算那個point距離原點(0,0)更遠 return Math.Sign(Math.Sqrt(m_x*m_x+m_y*m_y)
-Math.Sqrt(other.m_x*other.m_x+other.m_y*other.m_y)); } //實現IComparable的ComparableTo方法 public Int32 CompareTo(object o) { if (GetType() != o.GetType()) { throw new ArgumentException("o is not a Point"); } //調用類型安全的ComparableTo方法 return CompareTo((Point) o); } } public static class Program { static void Main(string[] args) { //在棧上創建兩個Point實例 Point p1 = new Point(10,10); Point p2 = new Point(20, 20); //調用ToString(虛方法)不裝箱p1 Console.WriteLine(p1.ToString());//顯示“(10,10)” //調用GetType(非虛方法)裝箱p1 Console.WriteLine(p1.GetType());//顯示"Point" //調用CompareTo不裝箱p1 //由於調用的是CompareTo(Point),所以p2不裝箱 Console.WriteLine(p1.CompareTo(p2));//顯示"-1" //p1要裝箱,引用放在c中 IComparable c = p1; Console.WriteLine(c.GetType()); //顯示"Point" //調用CompareTo不裝箱p1 //由於向CompareTo傳遞的不是Point變量 //所以調用的是CompareTo(Object),他要求獲取對已裝箱Point的引用 //c不裝箱是因為他本來就引用已裝箱Point Console.WriteLine(p1.CompareTo(c)); //顯示"0" //c不裝箱,因為他本來就是引用已裝箱Point //p2要裝箱,因為調用的是CompareTo(Object) Console.WriteLine(c.CompareTo(p2));//顯示"-1" //對c拆箱,字段復制到p2中 p2 = (Point)c; //證明字段已復制到p2中 Console.WriteLine(p2.ToString()); //顯示"(10,10)" } }
上述代碼演示了涉及裝箱和拆箱的幾種情形。
調用ToString
調用ToString時p1不必裝箱。表面看p1似乎必須裝箱,因為ToString是從基類System.ValueType繼承的虛方法。
通常,為了調用虛方法,CLR需要判斷對象的類型來定位類型的方法表。
由於p1是未裝箱的值類型,所以不存在類型對象指針。
但JIT編譯器發現Point重寫了ToString,所以會生成代碼來直接非虛地調用ToString方法。
而不必進行任何裝箱操作。編譯器知道這裏不存在多態性問題。
因為Point是值類型,沒有類型能從他派生以提供虛方法的另一個實現。
但假如Point的ToStringd方法在內部調用base.ToString(),那麽在調用System.ValueType的ToString方法時,值類型的實例會被裝箱。
調用GetType
調用非虛方法GetType時p1必須裝箱。Point的GetType方法是從System.Object繼承的。
所以為了調用GetType,CLR必須使用指向類型對象的指針,這個指針只能通過裝箱p1來獲得。
調用CompareTo(第一次)
第一次調用CompareTo時,p1不必裝箱,因為Poinr實現了CompareTo方法。編譯器能直接調用他。
註意向CompareTo傳遞的是一個Point變量p2,所以編譯器調用的是獲取一個Point參數的CompareTo重載版本。
這意味著p2以傳值方式傳給CompareTo,無需裝箱。
轉型為IComparable
p1轉型為接口類型的變量c時必須裝箱,因為借口被定義為引用類型。
裝箱p1後,指向已裝箱對象的指針存儲到變量c中。
後面對GetType的調用證明c確實引用堆上的已裝箱Point。
調用CompareTo(第二次)
第二次調用CompareTo時p1不裝箱,因為Point實現了CompareTo方法,編譯器能直接調用。
註意向CompareTo傳遞的是IComparable類型的變量c,所以編譯器調用的是獲取一個Object參數的CompareTo重載版本。
這意味著傳遞的實參必須是指針,必須引用堆上一個對象。c確實引用一個已裝箱Point,無需裝箱。
調用CompareTo(第三次)
第三次調用CompareTo時,c本來就引用堆上的已裝箱Point,所以不裝箱。
由於c是IComparable接口類型,所以只能調用接口的獲取一個Object參數的CompareTo方法。
這意味著傳遞的實參必須是引用了堆上對象的指針,所以p2要裝箱,指向這個已裝箱對象的指針將傳給CompareTo。
轉型為Point
將c轉型為Point時,c引用的堆上對象被拆箱,其字段從堆復制到p2。p2是棧上的Point類型實例。
CLR via C#學習筆記-第五章-值類型的裝箱和拆箱