本文參考 《CLR via C#》、搜索引擎
值類型比引用類型要 “輕” 那麼一點,值類型使用的時候也非常的方便,它們
不作爲對象在託管推中分配,沒有被當作垃圾回收掉,也不能通過指針進行引用
。但許多的時候都需要對值類型進行實例的引用,這就是我們所常說的 “裝箱
”,當然,有裝箱就有拆箱,下面就讓我們一起來了解一下,值類型與引用類型之間的那些事兒吧 . . .
.
裝箱是一個非常浪費性能的操作,在學習過程中,我們儘量避免這種操作,養成好的習慣 . . .
文章目錄
.
裝箱與拆箱
—— 裝箱
將 值類型
轉換成 引用類型
就要使用到 裝箱
機制,裝箱的時候會發生如下幾種情況:
- 在託管堆中分配內存
- 值類型的字段複製到新分配的堆內存
- 返回對象地址。 該地址是對象引用,值類型 --> 引用類型
下面這個圖演示了 裝箱 的過程:
C# 編譯器會自動生成對值類型實例進行裝箱所需的 IL 代碼,下面我會進行演示 . . .
.
—— 拆箱
有裝箱就有拆箱,那麼怎麼樣才能完成拆箱呢? 完成拆箱主要有兩步:
- 獲取已裝箱的 值類型在堆中的各個字段的地址(
拆箱
) - 將字段包含的值從堆中複製到基於棧的值類型實例中
.
介紹完裝箱與拆箱的概念原理之後,我們下面就來研究一下他們的代碼實現吧
例如我們對一個 Int32 型
的數據進行裝箱與拆箱:
進行裝箱的時候,我們感覺就像直接複製了一樣,實則 像上面那個裝箱圖一樣做了許多事情,在進行拆箱的時候,感覺就像強制類型轉換一樣,但也不然 . . .
短短的兩行代碼,卻直接演示了 裝箱與拆箱. . .
這裏需要注意的是,我們進行拆箱的時候,如果引用的對象不是所需值類型的已裝箱實例,就會拋出異常,比如下面這種情況:
如果我們一定要使用 Int16 來進行拆箱,那麼我們可以使用下面的這種寫法:
對對象進行拆箱時,只能轉型爲最初未裝箱的值類型 Int32,之後再進行強制轉換爲 Int16 . . .
上面我們提到過,進行拆箱時會進行一次字段複製,那麼我們輸出 newValue的值 應該是 42
,事實也如此 . . .
.
下面我們看一個例子,其中進行了幾個裝箱呢?
它的結果是:123,5
因爲 o 引用的是已經裝箱的 v,不管我們如何的修改未裝箱的 v,都不會影響到它 . . .
那麼它到底進行了幾次裝箱呢? 答案是三次,是不是有點小意外 ^ _ ^,我們來看一看這個程序生成的 IL 代碼,我們可以通過 ILDASM 工具進行查看:
- 直接將.exe 文件插入其中即可:
- 生成的 IL 代碼:
注意我用紅色框起來的部分,我們使用 Console.WriteLine 進行輸出時,它把三個參數都當成 String型數據
連接起來,然後輸出 . . .
那麼三次裝箱是哪三次呢? 下面就是正確的答案,你知道嗎:
- Object o = v; // 第一次裝箱
- Console.WriteLine 中的 v // 第二次裝箱
- Console.WriteLine 中的 (Int32)o // 拆箱後又進行裝箱
之所以三個參數都是 String型,是因爲 Console.WriteLine沒有重載 Console.WriteLine(Int32,
String, Int32) 這個方法,所以Console.WriteLine 直接把它們都當成引用類型(String) 連接起來了 . .
.
大家可以細細的品 . . . ^ _ ^
.
裝箱與拆箱的基本概念與代碼介紹到此,下面我們來實踐一下一個小程序,看看其中有多少的裝箱拆箱,此外,我再次提醒,裝箱很廢性能,儘量避免這樣的操作,但有的時候,我們又不得不去進行裝箱,比如上面的那個有三次裝箱的 Console.WriteLine,我們可以將它改成如下的樣子:
這樣子,這個例子就進行了 一次裝箱, v.ToString 和 ", " 和 o 是一樣的引用類型 . . .
. . .
.
實例講解程序中的裝箱與拆箱機制
當我們把值類型轉化爲接口類型也需要裝箱操作的,下面這個例子就完美的體現出,如果看懂了,我們就真正的理解裝箱與拆箱機制了,每一行 Console.WriteLine 代碼我都加以註釋 . . .
using System;
namespace BoxDemo
{
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());
// 調用 GetType(非虛方法)時,要對 p1 進行裝箱
Console.WriteLine(p1.GetType()); //Object
// 調用 CompareTo 不裝箱 p1
// 調用的是重載過的 CompareTo
Console.WriteLine(p1.CompareTo(p2));
// 裝箱 放到 c中
IComparable c = p1;
Console.WriteLine(c.GetType());
// 調用 CompareTo 不裝箱 p1
// 調用的是IComparable 實現的接口 CompareTo
Console.WriteLine(p1.CompareTo(c));
// c 不裝箱(引用 p1) p2 裝箱
Console.WriteLine(c.CompareTo(p2));
// 對 c 拆箱,字段複製到 p2中
p2 = (Point)c;
Console.WriteLine(p2.ToString());
}
}
internal struct Point : IComparable
{
private Int32 m_x, m_y;
public Point(Int32 x, Int32 y)
{
m_x = x;
m_y = y;
}
public override String ToString()
{
// 返回 Point,避免 ToString 裝箱
return String.Format("({0}, {1})", m_x.ToString(), m_y.ToString());
}
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));
}
// 實現接口中的 CompareTo
public Int32 CompareTo(object obj)
{
if(this.GetType() != obj.GetType())
{
throw new ArgumentException("o is not a Point");
}
// 調用類型安全的 CompareTo方法
return CompareTo((Point)obj);
}
}
}
.
平常注意的兩個操作點
一、重複數據應避免多次裝箱
例如下面,我們需要輸出三個相同數據的值類型,但 Console.WriteLine 對他進行了三次裝箱:
int v = 11;
Console.WriteLine("{0}, {1}, {2}", v, v, v);
解決辦法:手動進行一次裝箱,只進行一次裝箱:
int v = 11;
Object o = v;
Console.WriteLine("{0}, {1}, {2}", o, o, o);
如果不知道這裏的情況,請看上面這個相關的例子:
.
二、使用接口更改已經裝箱值類型中的字段
首先,我們來測試一下沒有使用接口的情況:
定義一個測試類:
測試代碼:
測試的結果是:
這裏,我們可能會想不到爲什麼最後的輸出也是 (2,2)?
原因解析: 因爲對引用類型進行拆箱時,它將已裝箱 Point 中的字段複製到 線程棧上的一個 Point上面!但是已經裝箱的 Point不會受這個 Change調用的影響,所以這就需要我們藉助接口的使用了
.
接口的使用,改變已經裝箱的值類型:
定義一個接口,並定義一個實現接口的類:
測試代碼:
輸出的結果爲:
倒數第二個輸出,造成這樣的原因類似於:
最後一個輸出,o 引用的已裝箱 Point 轉型爲一個 IChangeBoxedPoint。這裏不需要裝箱,因爲 o本來就是引用類型,它直接調用 Change 修改對應的數據 . . . 接口方法 Change 使我們能夠完成我們想要的操作 . . .