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