緣起
其實這篇文章在 草稿 裡已有一段時間, 但因為有些細節沒有確切的答案, 所以一直沒有沒有發佈.
直到最近參加了 SkillTree 主辦, Bill叔 主講的 物件導向實作課程(使用C#)第四梯 , 對於細節部份, 得到了一些解答, 也有了比較大的信心, 故重新整理後, 進行發佈.
完整範例, 請由此下載.
C# 的參數傳遞, 主要有 2 種:
這 2 種方式到底有什麼不同呢? 經 Bill叔 的課程解惑, 主要在於:
以下區分為不同資料型別 (值型別, 不可變的參考型別, 可變的參考型別), 在 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 是不同的東西; 前者是資料型別, 後者是參數傳遞方式, 不要混淆了.
直到最近參加了 SkillTree 主辦, Bill叔 主講的 物件導向實作課程(使用C#)第四梯 , 對於細節部份, 得到了一些解答, 也有了比較大的信心, 故重新整理後, 進行發佈.
完整範例, 請由此下載.
導火線
日前接獲一位朋友告知, 表示 C# String 是 Reference Type, 按理作為參數, 應該跟傳物件一樣, 在子程序修改字串內容, 應該反映到主程序才對, 但實測結果並非如此 ...問題呈現
以下是簡化後的程式碼:
其結果如下圖, 可以發現在沒有加 ref 的狀況下, 回到主程序, 不會改變原來主程序裡的字串內容.
綜合網路上的文章, 重新架構, 撰寫如下的測試程式, 包括了 值型別(ValueType), 參考型別(ReferenceType, 包括: string, 物件) 的參數傳遞.
其結果如下圖:
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)
////// 用以測試傳遞物件的狀況 /// 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)
- 在不加 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) 的異動, 會反映到父程序, 如上圖的 "深黃" 字; 故修改其內容, 回到主程序, 也會改掉.
總結
如果真的還是不清楚, 最好畫張圖, 這樣會比較容易了解.
(註: 謝謝 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 是不同的東西; 前者是資料型別, 後者是參數傳遞方式, 不要混淆了.
謝謝您的分享,講解得很清楚
回覆刪除不客氣 ^^
刪除