默认情况下,C# 按值传递每个实参:方法拿到的是一份副本,给参数赋值不会影响调用方。ref、out 和 in 修饰符则会传递调用方的变量本身。
输出:
by value: 50
by ref: 60
关键字出现了两次:一次在参数列表中(ref int score),一次在调用处(ref score)。调用处必须写,这是有意为之的:任何读到 AddPoints(ref score) 的人都知道 score 可能会被修改。如果漏写,编译器会报 CS1620:Argument 1 must be passed with the 'ref' keyword。
ref:读写调用方的变量
ref 参数是调用方变量的别名。方法可以读取它当前的值,也可以替换它。经典的例子是交换两个变量,这用普通参数是做不到的:
输出:
Linus Ada
volume 100
ref 的规则:
- 实参必须是一个变量(局部变量、参数、字段或数组元素),而不是字面量或表达式。属性也不行:
Clamp(ref player.Volume, 0, 100)会报错误 CS0206:A non ref-returning property or indexer may not be used as an out or ref value。先把属性复制到一个局部变量中,传入这个局部变量,然后再赋值回去。 - 变量在调用之前必须已经明确赋值。
int n; Clamp(ref n, 0, 10);会报 CS0165:Use of unassigned local variable 'n'。
out:额外的结果
out 用于方法产生的值。调用方不需要初始化这个变量,而方法在返回之前必须在每条执行路径上给它赋值:
static void Split(string fullName, out string first, out string last)
{
// error CS0177: The out parameter 'last' must be assigned to before control leaves the current method
first = fullName.Split(' ')[0];
}
在方法内部,out 参数在你写入之前都被视为未赋值,所以你读不到调用方原来的值。这就是它和 ref 的区别:ref 是双向的,out 是从方法到调用方的单向传递。
TryParse 模式
out 最为人熟知的用法是基础库中的 Try 模式:返回 bool 表示是否成功,把结果放在 out 参数中,这样输入有误时也不会抛出异常:
输出:
"vip25" is a coupon: 25% off
"42" is a quantity: 42
"BOGUS" is not recognized
"" is not recognized
out var quantity(C# 7.0)直接在调用中声明变量,它的类型由参数决定。解析失败时,TryParse 会把它设为 0 并返回 false。如果你只关心解析是否成功,可以用 out _ 丢弃这个值:bool isNumber = int.TryParse(input, out _);。
注意 TryGetDiscount 在两条路径上都给 rate 赋了值:一次是显式赋值,另一次是把它转交给 Dictionary.TryGetValue,而后者本身也是一个 out 调用。
out 与返回元组
在 C# 7 之前,out 是返回多个值的主要方式。现在元组可以更直接地做到这一点:
// with out parameters
static void MinMax(int[] values, out int min, out int max) { ... }
MinMax(temps, out int lo, out int hi);
// with a tuple
static (int Min, int Max) MinMax(int[] values) { ... }
var (lo, hi) = MinMax(temps);
普通的多个返回值,用元组(或一个小类)。out 留给 Try 模式,因为返回 bool 能让调用直接放进 if 条件中。
引用类型:修改对象与替换对象
按值传递一个类的实例,本来就能让方法修改这个对象,因为复制的值是指向同一个对象的引用。ref 额外增加了一种能力:替换调用方变量所指向的对象。
输出:
after AddItem: 2 items
after ResetByValue: 2 items
after ResetByRef: 0 items
ResetByValue 让它自己那份引用副本指向了一个新列表,调用方根本看不到。ResetByRef 修改的则是调用方的变量本身。在日常代码中,你很少需要给类类型的参数加 ref:改为返回新对象即可。
对于结构体,区别就更大了,因为结构体实参会被逐个字段复制。一个必须修改调用方结构体(一个 Point,或一个大型设置结构体)的方法就需要 ref。
in:只读的按引用传递
C# 7.2 新增了 in,它按引用传递,但禁止赋值。它的目的是提升性能:大型结构体不会在每次调用时被复制,同时调用方可以确定它不会被修改。
// C# 7.2 and later
struct Matrix4
{
public double M11, M12, M13, M14, M21, M22, M23, M24,
M31, M32, M33, M34, M41, M42, M43, M44;
}
static double Trace(in Matrix4 m) => m.M11 + m.M22 + m.M33 + m.M44;
static void Reset(in Matrix4 m)
{
m.M11 = 0;
// error CS8332: Cannot assign to a member of variable 'm' or use it as the right hand side of a ref assignment because it is a readonly variable
}
在调用处 in 是可选的:Trace(matrix) 和 Trace(in matrix) 都可以。对于 int、double、DateTime 和其他小类型,in 没有任何收益,甚至可能稍慢一些;请把它用在经常传递、包含多个字段的结构体上。
限制
ref和out参数不能是可选参数(CS1741)。in参数可以:static void Connect(in int retries = 3)是合法的。- 重载之间不能只靠
ref和out(或in)来区分,因为它们在运行时层面是一样的。F(int x)和F(ref int x)可以共存。 async方法和迭代器(使用yield return的方法)不能有ref、out或in参数。这些方法请改为返回元组或结果对象。- lambda 或局部函数不能捕获外层方法的
ref、out或in参数;先把它复制到一个局部变量中。
ref、out 与 in 对比
| 调用方必须先赋值 | 方法必须赋值 | 方法可以读取 | 调用处的关键字 | |
|---|---|---|---|---|
| (无修饰符) | 是 | 否 | 是(副本) | 无 |
ref | 是 | 否 | 是 | 必须写 |
out | 否 | 是,在每条路径上 | 只能在赋值之后 | 必须写 |
in | 是 | 不允许 | 是 | 可选 |
常见问题
C# 中 ref 和 out 有什么区别?
两者传递的都是调用方的变量本身,而不是副本。使用 ref 时,变量必须在调用前赋值,方法可以读取并修改它。使用 out 时,变量不必事先赋值,方法在写入之前不能读取它,并且必须在返回之前给它赋值(否则报 CS0177)。out 的意思是“这是一个额外的结果”。
C# 中的 out var 是什么意思?
从 C# 7.0 起,你可以在调用中直接声明 out 变量:if (int.TryParse(text, out var number))。变量的类型会根据参数推断出来,而且在这条语句之后它仍然在作用域内。out _ 可以丢弃你不需要的结果。
C# 中对象是按引用传递的吗?
不是。除非你写了 ref、out 或 in,否则每个实参都是按值传递的。对于类,被复制的值是一个引用,所以方法可以修改对象的字段,调用方也能看到这些修改。没有 ref 时它做不到的,是让调用方的变量指向另一个对象。
C# 中的 in 参数修饰符是什么?
in(C# 7.2 及以后)按引用传递实参,但是只读的:方法不能给它赋值。它的作用是避免每次调用时都复制大型结构体。对于 int 或 DateTime 这样的小类型,它没有任何好处。
为什么会出现“Argument 1 must be passed with the 'ref' keyword”错误?
方法声明了一个 ref 参数,而调用时漏写了这个关键字。C# 要求在调用处也写上 ref(以及 out),让读代码的人能看出变量可能会被修改:要写 Increment(ref count);,而不是 Increment(count);。