C#中的值傳遞與引用傳遞(in、out、ref)
在C#中,方法、建構函式可以擁有引數,當呼叫方法或者建構函式時,需要提供引數,而引數的傳遞方式有兩種(以方法為例):
值傳遞
值型別物件傳遞給方法時,傳遞的是值型別物件的副本而不是值型別物件本身。常用的一個例子:
public struct MyStruct { public int Value { get; set; } } static void Invoke(MyStruct myStruct, int i) { //MyStruct和int都是值型別 myStruct.Value = 1; i = 2; Console.WriteLine($"Modify myStruct.Value = {myStruct.Value}"); Console.WriteLine($"Modify i = {i}"); } static void Main(string[] args) { var myStruct = new MyStruct();//Value=0 var i = 0; Invoke(myStruct, i); Console.WriteLine($"Main myStruct.Value = {myStruct.Value}"); Console.WriteLine($"Main i = {i}"); //輸出: //Modify myStruct.Value = 1 //Modify i = 2 //Main myStruct.Value = 0 //Main i = 0 }
對於引用型別物件,很多人認為它是引用傳遞,其實不對,它也是按值傳遞的,但是不像值型別傳遞的是一個副本,引用型別傳遞的是一個地址(可以認為是一個整型資料),在方法中使用這個地址去修改物件的成員,自然就會影響到原來的物件,這也是很多人認為它是引用傳遞的原因,一個簡單的例子:
public class MyClass { public int Value { get; set; } } static void Invoke(MyClass myClass) { myClass.Value = 1; Console.WriteLine($"Modify myClass.Value = {myClass.Value}"); } static void Main(string[] args) { var myClass = new MyClass();//Value=0 Invoke(myClass); Console.WriteLine($"Main myClass.Value = {myClass.Value}"); //輸出: //Modify myClass.Value = 1 //Main myClass.Value = 1 }
需要注意的是,如果值型別物件中含有引用型別的成員,那麼當值型別物件在傳遞給方法時,副本中克隆的是引用型別成員的地址,而不是引用型別物件的副本,所以在方法中修改此引用型別物件成員中的成員等也會影響到原來的引用型別物件。
引用傳遞
引用傳遞可以理解為就是物件本身傳遞,而非一個副本或者地址,一般使用 in、out、ref 關鍵字宣告引數是引用傳遞。
在說 in、out、ref 之前,先看看引用傳遞與值傳遞的區別,以更好的理解引用傳遞。
對於值型別物件,看一個最簡單的變數值交換的例子:
static void Swap(int i,int j) { var temp = i; i = j; j = temp; } static void Main(string[] args) { int i = 1; int j = 2; Swap(i, j);//交換i,j Console.WriteLine($"i={i}, j={j}"); //輸出:i=1, j=2 }
可以看到,i,j的值沒有交換,因為值型別值傳遞傳的是一個副本,這就好比,值物件的資料儲存在一個房間中,比如桌子凳子椅子,作為方法引數傳遞時,會將這個房間包括裡面的桌子凳子椅子全部克隆一份得到一個新房間,然後將這個新房間搬走使用,對新房間的裝修揮霍自然對原房間沒有影響。
上面的程式碼可以翻譯為:
static void Main(string[] args) { int i = 1; int j = 2; //這是Swap方法執行過程 //先建立兩個臨時變數,賦值為i,j //在方法中使用的是這兩個臨時變數 int m = i, n = j; { var temp = m; m = n; n = temp; } Console.WriteLine($"i={i}, j={j}");//輸出:i=1, j=2 }
再看看引用傳遞的例子:
static void Swap(ref int i,ref int j) { var temp = i; i = j; j = temp; } static void Main(string[] args) { int i = 1; int j = 2; Swap(ref i, ref j); Console.WriteLine($"i={i}, j={j}");//輸出:i=2, j=1 }
可以看到,i,j的值交換成功,因為這裡搬走使用的不再是克隆出來的新房間,而是原房間!
這裡的程式碼可以翻譯為:
static void Main(string[] args) { int i = 1; int j = 2; //這是Swap方法執行過程 //沒有建立臨時變數,在方法中直接使用i,j //注:這裡是有建立臨時變數,只是變數是引用,等價於原物件的一個別名 { var temp = i; i = j; j = temp; } Console.WriteLine($"i={i}, j={j}");//輸出:i=2, j=1 }
再看看引用型別物件,在值傳遞中,引用型別傳遞的是地址,在方法中可以通過這個地址去修改物件成員而影響到原物件的成員,但是無法影響到整個物件,看下面的例子:
public class MyClass { public int Value { get; set; } } static void Invoke(MyClass myClass) { myClass = new MyClass() { Value = 1 }; } static void Main(string[] args) { MyClass myClass = new MyClass();//Value=0 Invoke(myClass); Console.WriteLine($"myClass.Value={myClass.Value}");//輸出:myClass.Value=0 }
可以看到,Main方法中將myClass物件傳入Invoke方法,在Invoke方法中給Invoke方法賦值,但是這並沒有影響到Main方法中的myClass物件,這就好比,引用型別物件的資料儲存在房間A中,作為方法引數傳遞時,會新建一個房間B,房間B儲存的是房間A的地址,對房間B的任何修改會轉向這個地址去修改,也就是房間A的修改,現在將房間B儲存的地址換成房間C的地址,對房間B的操作自然跟房間A沒有關係了。
可以將上面的Main方法大致翻譯成這樣子:
static void Main(string[] args) { MyClass myClass = new MyClass();//Value=0 //這是Invke方法執行過程 //建立臨時變數,在方法中使用臨時變數 MyClass temp = myClass; { temp = new MyClass() { Value = 1 }; } Console.WriteLine($"myClass.Value={myClass.Value}");//輸出:myClass.Value=0 }
但如果是引用傳遞,結果就不一樣了:
static void Invoke(ref MyClass myClass) { myClass = new MyClass() { Value = 1 }; } static void Main(string[] args) { MyClass myClass = new MyClass();//Value=0 Invoke(ref myClass); Console.WriteLine($"myClass.Value={myClass.Value}");//輸出:myClass.Value=1 }
這是因為引用傳遞傳的是物件本身,而不是地址,這就是說,在傳遞時,沒有建立一個房間B,而是直接使用的房間A!(準確說,是給房間A取了一個別名)
上面的Main方法可以翻譯為:
static void Main(string[] args) { MyClass myClass = new MyClass();//Value=0 //這是Invke方法執行過程 //沒有建立臨時變數,在方法中直接使用myClass //注:這裡是有建立臨時變數,只是變數是引用,等價於原物件的一個別名 { myClass = new MyClass() { Value = 1 }; } Console.WriteLine($"myClass.Value={myClass.Value}");//輸出:myClass.Value=1 }
同樣,這就好比,引用
到這裡,應該能對值傳遞和引用傳遞區分開了,接下來看看引用傳遞的 in、out、ref 的用法。
in
在C#中,可以在下面這些地方使用in關鍵字:
1、在泛型介面和委託的泛型引數中使用in關鍵字作為逆變引數,如:Action<in T> 2、作為引數修飾符,這是接下來要說的 3、在foreach中使用in迭代 4、在Linq表示式中的join、from子句中使用in關鍵字
作為引數修飾符,in修飾的引數表示引數通過引用傳遞,但是引數是隻讀的,所以in修飾的引數在呼叫方法時必須先初始化!
public struct MyStruct { public int Value { get; set; } } public class MyClass { public int Value { get; set; } } static void Invoke(in MyClass myClass, in MyStruct myStruct, in int i) { //in引數是隻讀的,下面的賦值將會報錯 //myClass = new MyClass(); //myStruct = new MyClass(); //i = 1; //類成員可以直接讀寫 myClass.Value = myClass.Value + 2; //結構體成員只能讀,直接寫會報錯 var value = myStruct.Value + 1; //結構體成員在不安全程式碼中可以使用指標實現寫操作 unsafe { fixed (MyStruct* p = &myStruct) { (*p).Value = myStruct.Value + 1;//可以寫 } } }
在呼叫時,我們需要滿足下面的條件:
1、傳遞之前變數必須進行初始化 2、多數情況下呼叫in關鍵字可以省略,當使用in關鍵字時,變數型別應與引數型別一致 3、可以使用常量作為引數,但是要求常量可以隱式轉換成引數型別,編譯器會生成一個臨時變數來接收這個常量,然後使用這個臨時變數呼叫方法
如:
MyClass myClass = new MyClass(); MyStruct myStruct = new MyStruct(); int i = 1; Invoke(in myClass, in myStruct, in i); Invoke(myClass, myStruct, i); Invoke(in myClass, in myStruct, 2);
out
在C#中,out引數可以用作:
1、在泛型介面和委託的泛型引數中使用out關鍵字作為協變引數,如:Func<out T> 2、作為引數修飾符,這是接下來要說的
作為引數修飾符,out修飾的引數表示引數通過引用傳遞,但是引數是必須是一個變數,且在方法中必須給這個變數賦值,但是在呼叫方法時無需初始化:
public struct MyStruct { public int Value { get; set; } } public class MyClass { public int Value { get; set; } } static void Invoke(out MyClass myClass, out MyStruct myStruct, out int i) { //out引數必須在返回之前賦一個值 myClass = new MyClass() { Value = 1 }; myStruct = new MyStruct() { Value = 2 }; i = 1; //賦值之後,類成員、結構體成員都可以直接讀寫 }
在呼叫時:
1、必須宣告out關鍵字,且變數型別應與引數型別一致 2、變數無需初始化,只需宣告即可 3、如果不關注out引數的返回值,我們常使用棄元
例如:
//引數需要初始化 MyClass myClass; MyStruct myStruct; int i; Invoke(out myClass, out myStruct, out i); //等價寫法 Invoke(out MyClass myClass, out MyStruct myStruct, out int i); bool isInt = long.TryParse("1", out _);//判斷字串是否是整型而不需要結果 bool isBool = bool.TryParse("true", out _);//判斷字串是否是布林型而不關注結果
ref
ref關鍵字的用法有很多,具體可見:C#中ref關鍵字的用法
作為引數修飾符,ref修飾的引數表示引數通過引用傳遞,但是引數是必須是一個變數。
ref 可以看做是 in 和 out 的結合體,但是與 in 和 out 又有些區別:
1、ref和in都是引用傳遞,而且要求呼叫方法前需要提前初始化,但是與in不同的是,呼叫時ref關鍵字不能省略,且引數必須是變數,不能是常量 2、ref和out都是引用傳遞,且在呼叫是,ref和out關鍵字不能省略,且引數必須是變數,不能是常量,但是ref要求呼叫方法前需要提前初始化,且無需在呼叫方法結束前賦值 3、與in和out不同的是,在呼叫方法中時,可以讀寫整個ref引數物件及它的成員
看看上面變數值交換的例子應該就清晰了。
in、out、ref的限制
C#中規定,引用傳遞(即in、out、ref)使用時有下面的限制:
1、非同步方法,即使用async修飾的方法中,引數不能使用in、out、ref關鍵字,但是可以在那些沒有使用async關鍵字且返回Task或者Task<T>型別的同步方法中使用 2、迭代器方法,即使用yield return和yield break返回迭代物件的方法中,,引數不能使用in、out、ref關鍵字 3、如果拓展方法的第一個引數(this)是結構體,且非泛型引數,則可使用in關鍵字,否則不能使用in關鍵字 4、拓展方法的第一個引數(this)不能使用out關鍵字 5、如果拓展方法的第一個引數(this)非結構體,也非約束為結構體的泛型引數,則不能使用ref關鍵字
此外,in、out、ref不能作為過載的標識,也就是說,如果兩個方法,除了這三個關鍵字修飾的不同,其他如方法名,引數個數、型別等都相同,但是不能算過載:
//下面的三個方法,除了in、out、ref,其他都一樣,但是不能算過載,編譯不通過 public void Method1(in string str) { } public void Method1(out string str) { str = ""; } public void Method1(ref string str) { } //下面的三個方法,除了in、out、ref,其他都一樣,但是不能算過載,編譯不通過 public void Method2(in string str, out int i) { i = 0; } public void Method2(out string str, in int i) { str = ""; } public void Method2(ref string str, ref int i) { }
但是,一個不使用in、out、ref使用的方法,和一個使用in、out、ref引數的方法可以構成過載:
//下面的兩個方法算過載,呼叫這樣的過載,需要在呼叫是指定in、out、ref來區分呼叫 public static void Method1(string str) { } public static void Method1(in string str) { }//可以使用in、out、ref //下面的三個方法算過載,呼叫這樣的過載,需要在呼叫是指定in、out、ref來區分呼叫 public static void Method2(string str, int i) { i = 0; } public static void Method2(string str, out int i) { i = 0; } public static void Method2(in string str, out int i) { i = 0; }
參考文件:
https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/in-parameter-modifier
https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/out-parameter-modifier
https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/ref
一個專注於.NetCore的技術小白