︿
Top

2015年6月26日 星期五

C#: Passing Parameters


緣起

其實這篇文章在 草稿 裡已有一段時間, 但因為有些細節沒有確切的答案, 所以一直沒有沒有發佈.

直到最近參加了 SkillTree 主辦, Bill叔 主講的 物件導向實作課程(使用C#)第四梯 , 對於細節部份, 得到了一些解答, 也有了比較大的信心, 故重新整理後, 進行發佈.

完整範例, 請由此下載.


導火線

日前接獲一位朋友告知, 表示 C# String 是 Reference Type, 按理作為參數, 應該跟傳物件一樣, 在子程序修改字串內容, 應該反映到主程序才對, 但實測結果並非如此 ...

問題呈現

以下是簡化後的程式碼:

 
class Program
 {

  private static void Change(string inStr)
  {
   inStr = "DEF";
   Console.WriteLine("[Change] inStr={0}", inStr);
  }

  private static void ChangeByRef(ref string inStr)
  {
   inStr = "DEF";
   Console.WriteLine("[ChangeByRef] inStr={0}", inStr);
  }

  static void Main(string[] args)
  {
    //=======================================
    //字串參數傳遞問題重現 測試: 
    //=======================================
    string strX = "ABC";
    
    //呼叫 Change
    Console.ForegroundColor = ConsoleColor.Gray;
    Console.WriteLine("[Main](before call Change()) strX={0}", strX);
    Change(strX);
    Console.WriteLine("[Main](after call Change()) strX={0}", strX);
    
    //呼叫 ChangeByRef
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine("[Main](before call ChangeByRef()) strX={0}", strX);
    ChangeByRef(ref strX);
    Console.WriteLine("[Main](after call ChangeByRef()) strX={0}", strX);
  }
 }

其結果如下圖, 可以發現在沒有加 ref 的狀況下, 回到主程序, 不會改變原來主程序裡的字串內容.

原始問題重現


探索原因 (1)

確認有上述問題後, 在網路上查了幾篇文章, 其中 How are strings passed in .NET? 這篇文章對於字串參數的傳遞, 有詳細的說明,

探索原因 (2)

綜合網路上的文章, 重新架構, 撰寫如下的測試程式, 包括了 值型別(ValueType), 參考型別(ReferenceType, 包括: string, 物件) 的參數傳遞.

 
/// 
/// 用以測試傳遞物件的狀況
/// 
public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }

    public string GetString()
    {
        return String.Concat("Id: ", this.Id, "  Name: ", this.Name);
    }
}

/// 
/// Console 主程式
/// 
class Program
{
    #region 字串參數傳遞問題重現
    private static void Change(string inStr)
    {
        inStr = "DEF";
        Console.WriteLine("[Change] inStr={0}", inStr);
    }

    private static void ChangeByRef(ref string inStr)
    {
        inStr = "DEF";
        Console.WriteLine("[ChangeByRef] inStr={0}", inStr);
    }
    #endregion

    #region ValueType 呼叫
    private static void ValueTypeCall(int inId)   //##1.2##
    {
        inId = 10;  //##1.3##
        Console.WriteLine("[ValueTypeCall] id={0}", inId);
    }

    private static void ValueTypeCallByRef(ref int inId)  //##2.2##
    {
        inId = 10; //##2.3##
        Console.WriteLine("[ValueTypeCallByRef] id={0}", inId);
    }
    #endregion

    #region StringType 呼叫
    private static void StringTypeCall(string inStr)  //##3.2##
    {
        inStr = "DEF";  //##3.3##
        Console.WriteLine("[StringTypeCall] inStr={0}", inStr);
    }

    private static void StringTypeCallByRef(ref string inStr)  //##4.2##
    {
        inStr = "DEF";  //##4.3##
        Console.WriteLine("[StringTypeCallByRef] inStr={0}", inStr);
    }
    #endregion

    #region ObjectType 呼叫
    private static void ObjectTypeCall(Customer inCust) //##5.2##
    {
        inCust.Name = "Jasper";  //##5.3##
        Console.WriteLine("[ObjectTypeCall] Id={0}, Name={1}", inCust.Id, inCust.Name);
    }

    private static void ObjectTypeCall(ref Customer inCust) //##6.2##
    {
        inCust.Name = "Jasper";  //##6.3##
        Console.WriteLine("[ObjectTypeCall] Id={0}, Name={1}", inCust.Id, inCust.Name);
    }
    #endregion

    static void Main(string[] args)
    {

        //=======================================
        //字串參數傳遞問題重現 測試: 
        //=======================================
        string strX = "ABC";

        //呼叫 Change
        Console.ForegroundColor = ConsoleColor.Gray;
        Console.WriteLine("[Main](before call Change()) strX={0}", strX);
        Change(strX);
        Console.WriteLine("[Main](after call Change()) strX={0}", strX);

        //呼叫 ChangeByRef
        Console.ForegroundColor = ConsoleColor.DarkGray;
        Console.WriteLine("[Main](before call ChangeByRef()) strX={0}", strX);
        ChangeByRef(ref strX);
        Console.WriteLine("[Main](after call ChangeByRef()) strX={0}", strX);

        //=======================================
        //Value Type 測試: 
        //=======================================
        int id = 1;  //##1.1##
        //呼叫 ValueTypeCall
        Console.ForegroundColor = ConsoleColor.Green;
        Console.WriteLine("[Main](before call ValueTypeCall()) id={0}", id);
        ValueTypeCall(id);
        Console.WriteLine("[Main](after call ValueTypeCall()) id={0}", id);  //##1.4##

        //呼叫 ValueTypeCallByRef
        Console.ForegroundColor = ConsoleColor.DarkGreen;
        Console.WriteLine("[Main](before call ValueTypeCallByRef()) id={0}", id);
        ValueTypeCallByRef(ref id);
        Console.WriteLine("[Main](after call ValueTypeCallByRef()) id={0}", id); //##2.4##

        //=======================================
        //Reference Type 測試:  (String)
        //=======================================
        string str = "ABC";   //##3.1##
        //呼叫 StringTypeCall
        Console.ForegroundColor = ConsoleColor.Blue;
        Console.WriteLine("[Main](before call StringTypeCall()) str={0}", str);
        StringTypeCall(str);
        Console.WriteLine("[Main](after call StringTypeCall()) str={0}", str);  //##3.4##

        //呼叫 StringTypeCallByRef
        Console.ForegroundColor = ConsoleColor.DarkBlue;
        Console.WriteLine("[Main](before call StringTypeCallByRef()) str={0}", str);
        StringTypeCallByRef(ref str);
        Console.WriteLine("[Main](after call StringTypeCallByRef()) str={0}", str); //##4.4##

        //=======================================
        //Reference Type 測試:  (Object)
        //=======================================
        //呼叫 ObjectTypeCall
        Console.ForegroundColor = ConsoleColor.Yellow;
        Customer cust = new Customer { Id = 1, Name = "Tester" };  //##5.1##
        Console.WriteLine("[Main](before call ObjectTypeCall()) Id={0}, Name={1}", cust.Id, cust.Name);
        ObjectTypeCall(cust);
        Console.WriteLine("[Main](after call ObjectTypeCall()) Id={0}, Name={1}", cust.Id, cust.Name); //##5.4##

        //呼叫 ObjectTypeCallByRef
        Console.ForegroundColor = ConsoleColor.DarkYellow;
        cust = new Customer { Id = 1, Name = "Tester" };  //##6.1##
        Console.WriteLine("[Main](before call ObjectTypeCallByRef()) Id={0}, Name={1}", cust.Id, cust.Name);
        ObjectTypeCall(ref cust);
        Console.WriteLine("[Main](after call ObjectTypeCallByRef()) Id={0}, Name={1}", cust.Id, cust.Name); //##6.4##

        Console.ReadLine();
    }
}

其結果如下圖:

範例程式執行結果

探索原因 (3)

 C# 的參數傳遞, 主要有 2 種:
  • 在不加 ref 修飾字的狀況下, 是 Call By Value 
  • 在加 ref 修飾字的狀況下, 是 Call By Reference

這 2 種方式到底有什麼不同呢? 經 Bill叔 的課程解惑, 主要在於:
  • Call By Value : 在傳遞參數時, 多配置一份記憶體空間給形式參數 (formal parameter)( 就是子程序定義的參數), 以便將父程序的實際參數 (actual parameter) 的內容複製一份.
  • Call By Reference : 在傳遞參數時, 多配置一份記憶體空間給形式參數 (formal parameter). 可以將之視為原來傳入的實際參數 (actual parameter) 的別名, 共用相同的記憶體空間, 這樣的想法, 會比較單純.

以下區分為不同資料型別 (值型別, 不可變的參考型別, 可變的參考型別), 在 Call By Value 及 Call By Reference 下的狀況, 共有 6 個情境.

1. 值型別 (Value Type), 如: int, float ...
(1) Call By Value: 會在子程序的參數, 配置一個新的記憶體位址, 以存放的是傳入參數的內容, 如上圖的 "綠" 字

(2) Call By Reference: 可以將子程序的參數, 視為是傳入參數的別名; 在子程序對該參數變數 (ex: ref int inId) 的異動, 會反映到父程序. 如上圖的 "深綠" 字



2. 不可變的參考型別 (Immutable Reference Type), 如: string
(1) Call By Value: 會在子程序的參數, 配置一個新的記憶體位址, 以存放的是傳入參數的內容 (此內容是指向實際字串資料的位址), 如上圖的 "藍" 字; 既然如此, 父/子程序的字串變數, 都指向同一個資料位址, 那麼, 按理子程序修改內容, 應該要反映到主程序?
但這個在 C# String 的運作, 並非如此 ...子程序裡的 = 運算子, 其右方的字串, 會在記憶體佔有一塊區域, 其實是將參數 (ex: string inStr) 的內容, 改換為指向該區域的位址; 並非改原來指向位址的資料內容 ("ABC").


(2) Call By Reference: 可以將子程序的參數, 視為是傳入參數的別名; 在子程序對該參數變數 (ex: ref string inStr) 的異動, 會反映到父程序, 如上圖的 "深藍" 字; 故修改其內容, 回到主程序, 也會改掉.



3. 可變的參考型別 (Mutable Reference Type), 如: object
(1) Call By Value: 會在子程序的參數, 配置一個新的記憶體位址, 以存放的是傳入參數的內容, (此內容是指向實際物件資料的位址), 如上圖的 "黃" 字; 既然如此, 父/子程序的物件變數, 都指向同一個資料位址, 那麼, 按理子程序修改內容, 應該要反映到主程序? 這個在 C#  的運作, 確實如此.


(2) Call By Reference: 可以將子程序的參數, 視為是傳入參數的別名; 在子程序對該參數變數 (ex: ref string inCust) 的異動, 會反映到父程序, 如上圖的 "深黃" 字; 故修改其內容, 回到主程序, 也會改掉.



總結



如果真的還是不清楚, 最好畫張圖, 這樣會比較容易了解.

C# String 雖然是 Reference Type, 但作參數傳遞時的實際行為, 卻與物件不同, 所以要特別留意.
(註: 謝謝 Bill叔的指點, 修正文章內容, 以免讓讓者產生誤解)
C# String 是 Reference Type, 所以其參數傳遞時的行為, 與物件是相同的, 只是字串屬於不可變的參考型別 (Immutable Reference Type), 每次指派一個新的字串值時 (ex: inStr="DEF" or inStr=strTemp), 會有2種狀況:
(1) 在等號右方為已知的字串值時, 編譯器會事先配置該字串值至 String Pool; 故字串變數的內容, 會成為指向該 String Pool 某個元素的位址.
(2) 在等號右方為未知的字串值時 (例如: 使用者輸入, 外部檔案讀入 ...), 編譯器無法事先優化, 故會在 Heap 配置一塊新的記憶體, 同時, 更改字串變數的內容為該新配置記憶體的位址.
關於重新指派字串變數時, 會發生的記憶體變化 (.NET 2.0 以後, 會透過 String Pool 進行優化), 有興趣者, 可以參考筆者的另外一篇文章: C# String: String.Empty is more efficient than "" ?

另外, 請注意: Reference Type 與 Call By Reference  是不同的東西; 前者是資料型別, 後者是參數傳遞方式, 不要混淆了.


參考文件




2 則留言: