C#與CLR學習筆記(5)—— 值傳遞與引用傳遞


上一篇文章介紹了值類型和引用類型使用上的區別。這裏,我們來研究一下值類型和引用類型作爲參數進行傳遞時不同的表現,以及原因。

1. 值傳遞與引用傳遞

CLR 對方法的參數默認是隻進行值傳遞的,不管參數是類型的參數還是類型(即引用類型)的參數。所謂值傳遞,就是將參數實例拷貝一份放到被調用方法的棧幀中。你可能會疑問了,引用類型的參數不是進行引用傳遞的嗎?
實際上,傳遞引用類型的對象時,對象的引用(指針)被傳遞給方法,但是這個引用(指針)本身是存儲在棧幀中的,它本身是與值類型一樣,進行值傳遞的。
空說不容易理解,我們來看一下內存結構。假設現在方法 M(int a, MyClass b) 有一個 int 型的參數 a 和一個類 MyClass 類型的參數 b

    void Main(args[])
    {
        ...//代碼片段
        int a = 0;
        MyClass b = new MyClass();
        M(a, b);
    }
    void M(int a, MyClass b)
    {
        // do something interesting
    }

那麼在執行 M() 方法時,棧幀與堆的結構如下:

從上圖看,對於值類型a,CLR 將其一個副本壓入M()的棧幀中,對於引用類型b,它在堆棧上存儲的是指向託管堆中的地址,與值類型一樣,也被複制一份壓入棧幀中。由於它們指向的是同一個對象(0x2001),因此,在M()方法內部,如果對變量b(指向的對象)進行修改,調用者也會看到修改結果。然而,對於值類型a,在M()內部對其修改並不影響調用者中的變量a
因此,方法的參數傳遞,對於在堆中的對象來說,是相當於“引用傳遞”的,而實際上對於在堆棧上的值類型和引用類型變量,都是“值傳遞”。

2. 使用 ref 傳遞值類型

對於值類型,傳遞給 被調用者 的參數是值類型實例的一個副本,在 被調用者 內部對該參數修改,不影響 調用者 中的實例。
爲了能夠影響 調用者 對應的實例,CLR 允許以傳遞引用而非傳值的方式來傳遞參數。C# 用關鍵字 outref 支持這個功能。outref 本質是一回事,具體見下文。這裏以 ref 爲例討論。
如果將方法值類型的參數標記爲 ref,那麼該類型實例在堆棧上的地址將被傳遞給 被調用者。在 被調用者 內部對該參數進行修改,CLR 將根據該引用獲取該參數在棧幀上的實際位置並對參數實例進行修改,自然就能影響 調用者 的相關類型實例。這個比較好理解,不再贅述。
對值類型使用 ref 傳參主要有兩個目的:

  • 實現在一個方法中同時修改多個外部值類型變量,避免返回一個元組或者組合類型;
  • 對於比較大的值類型實例,使用 ref 傳遞可避免複製它,從而提高運行效率。

3. 使用 ref 傳遞引用類型

對於引用類型,參數傳遞時本身傳遞的就是對象的引用,因此常規的參數傳遞無需對引用類型參數使用 ref
當然,也可以將引用類型的參數標記爲 ref,那麼它的行爲方式將不同以往。如果對引用類型使用 ref,那麼傳遞給 被調用者 的是引用類型指針的指針。被調用者 修改這個變量時,將會將外部(調用者)引用變量的指針指向另外一個對象,因此,需要格外注意,如果變量被修改後指向的類型變了,代碼將無法通過編譯。
我們來看一個具體的例子。假設我們需要寫一個方法,來交換引用類型MyClass的兩個變量(當然是相同類型的)。

static void Main(string[] args)
{
    Console.WriteLine("-----------測試ref------------");
    MyClass a = new MyClass() { Id = 1 };
    MyClass b = new MyClass() { Id = 2 };
    Swap(a, b); //待實現
    Console.WriteLine($"a:{a.ToString()}");
    Console.WriteLine($"b:{b.ToString()}");
}

public class MyClass
{
    public int Id { get; set; }
    public override string ToString() => Id.ToString();
}

錯誤操作
先看錯誤的 Swap 實現代碼:

public static void Swap(MyClass a, MyClass b)
{
    MyClass c = a;
    a = b;
    b = c;
}

上述代碼咋一看是沒問題的。然而經過運行我們就能發現,它並不能交換變量a, b對象。Main()在調用完 Swap(a, b)之後,a指向的依然是 id=1 的對象,b 指向的依然是 id=2 的對象:

造成未能交換a,b對象的原因,就是因爲a,b指向堆中對象的地址是以 值傳遞 的方式傳給 Swap() 的。雖然在 Swap()內部,參數 ab 確實交換了指向的對象,然而Swap() 中的ab參數的值(指針)的改變並沒有影響的 Main()ab中存儲的指針。
我們畫圖跟蹤一下Swap() 的執行過程就清楚了:
(1)首先,a,b參數被拷貝到了Swap()的棧幀中,它們與Main()中的狀態一樣,指向對應的堆對象。
在這裏插入圖片描述
(2)然後,通過一箇中間變量c實現指向對象的交換:
在這裏插入圖片描述
(3)最後,Swap()執行完畢返回,其棧幀被清除。可以看到,Main()中的a,b變量並未受到影響,依然指向原來的堆對象(藍色箭頭)。

正確實現
正確的解決方法就是使用 ref 傳遞引用類型:

static void Main(string[] args)
{
    ......
    Swap(ref a, ref b); 
    ......
}
public static void Swap(ref MyClass a, ref MyClass b)
{
    MyClass c = a;
    a = b;
    b = c;
}

讀者可自己分析上述代碼的執行過程。
通過以上我們可以總結出使用 ref 傳遞引用類型的情景與特點:

  • 一般情況下修改引用類型對象,不須使用ref;當涉及到引用類型變量的指針變更時,才需使用 ref 傳遞參數;
  • 使用 ref 傳遞引用類型的變量,實際上是將變量的棧幀地址傳遞給被調用者;
  • 被調用者改變變量的指針時需要確保類型安全。

4. ref 與 out 的區別

refout 兩個關鍵字都是表明採用引用來傳遞參數。對於 CLR,二者是一樣的,因爲都生成了相同的 IL 代碼。不太一樣的是生成的元數據中,有一位數據記錄聲明方法時參數是 out 還是 ref。也就是說,編譯器會區別對待二者。具體在於參數使用時的初始化方面:

  • 如果參數被標記爲 out,那麼調用者無需在調用方法之前初始化該參數。被調用者 不能讀取該參數的值,但在返回前 必須 爲該參數賦值。
  • 如果參數被標記爲 ref,那麼調用者 必須 在調用方法之前初始化該參數。被調用者 可以直接讀取該參數的值。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章