C# ref 局部变量和 ref return
C# 7中ref
的很多相关特性是相互关联的。如果逐个介绍,很难体现出这些特性的优势。在描述这些特性时,给出的代码示例也会比一般例子看起来更刻意,旨在一次只展示一个特性点。下面介绍C# 7.0引入的两个特性,二者在C# 7.2中有所增强。首先介绍ref
局部变量。
一、 ref
局部变量
沿用前文中的模型:ref
参数可以让两个方法中的变量共享同一张纸,即调用方和被调用方参数所使用的是同一张纸。ref
局部变量则进一步扩展了上述特性:可以声明一个新的局部变量,该局部变量和一个已有变量共享同一张纸。
代码清单13-2给出了简单的例子,其中两个变量分别自增1,然后打印结果。请注意,在变量声明和变量初始化时都需要使用ref
关键字。
代码清单13-2 通过两个变量自增两次
int x = 10;
ref int y = ref x;
x++;
y++;
Console.WriteLine(x);
执行结果是12,就像x
自增了两次。
任何具有合适类型的表达式,如果可以被看作变量,就可用于初始化ref
局部变量,例如数组元组。假设有一个可变的大型数组,需要批量修改元素,那么使用ref
局部变量可以避免不必要的复制操作。代码清单13-3创建了一个元组数组,然后针对每个数组元素都修改其中的元组元素。该过程不涉及任何复制。
代码清单13-3 使用
ref
局部变量修改数组元素
var array = new (int x, int y)[10];
for (int i = 0; i < array.Length; i++) (本行及以下3行) 使用(0, 0), (1, 1)...初始化数组
{
array[i] = (i, i);
}
for (int i = 0; i < array.Length; i++) (本行及以下4行) 对于数组中的每个元素,x自增,y乘以2
{
ref var element = ref array[i];
element.x++;
element.y *= 2;
}
在ref
局部变量出现之前,修改数组有两种方式。一种是使用多个数组访问表达式:
for (int i = 0; i < array.Length; i++)
{
array[i].x++;
array[i].y *= 2;
}
另一种是先把数组中的每个元组复制出来,修改完成后再复制回去:
for (int i = 0; i < array.Length; i++)
{
var tuple = array[i];
tuple.x++;
tuple.y *= 2;
array[i] = tuple;
}
这两种方式都不太好。使用ref
局部变量,即可在循环体内部把数组元素用作普通变量。
ref
局部变量也可以用于字段。静态字段的行为可预知,实例字段的行为则不一定。代码清单13-4创建了一个ref
局部变量,该变量通过变量obj
成了某个字段的别名,然后把obj
的值改成指向另一个实例。
代码清单13-4 使用
ref
局部变量为一个对象的字段取别名
class RefLocalField
{
private int value;
static void Main()
{
var obj = new RefLocalField(); <------ 创建RefLocalField的实例
ref int tmp = ref obj.value; <------ 声明一个ref局部变量,指向第1个对象的字段
tmp = 10; <------ 为ref局部变量赋新值
Console.WriteLine(obj.value); <------ 显示obj字段的值被修改了
obj = new RefLocalField(); <------ obj变量重新指向RefLocalField的新实例
Console.WriteLine(tmp); <------ 显示tmp依然指向第1个实例的字段
Console.WriteLine(obj.value); <------ 显示第2个实例的字段值是0
}
}
执行结果如下:
10
10
0
中间这行结果可能出人意料,它显示使用tmp
并非每次都等价于使用obj.value
。tmp
只是在初始化时充当obj.value
的别名。图13-6是Main
方法结束时变量和对象的一个快照。
图13-6 在代码清单13-4末尾,tmp变量指向第一个实例创建后的字段,而obj指向另外一个实例 |
最终结果是,tmp
变量将阻止第一个实例被垃圾回收,直到tmp
不再被当前方法使用。类似地,对数组元素使用ref
局部变量也会阻止该数组被垃圾回收。
说明 使用
ref
变量指向对象字段或者数组元素,会让垃圾回收器的工作变得更加复杂。垃圾回收器需要辨别该变量对应的对象,然后保留该对象。一般的对象引用比较简单,因为它们能直接判断出所引用的对象。对于对象而言,每增加一个指向其字段的ref
变量,垃圾回收器所维护的数据结构就会增加一个内部指针。如果同时出现很多这种变量,代价就会随之高涨。好在ref
变量只会出现在栈内存中,不大可能造成性能问题。
使用ref
局部变量时有一些限制条件,其中大部分比较明显,没有太大影响,但还是有必要了解一下,免得浪费时间想迂回办法。
初始化:只在声明时初始化一次(在C# 7.3之前)
ref
局部变量必须在声明时完成初始化,例如以下代码非法:int x = 10; ref int invalid; invalid = ref int x;
同样,也不能把某个
ref
局部变量变成其他变量的别名(以前面的模型为例:不能把当前纸上的名字擦掉,然后把名字写在另一张纸上)。当然,同一个变量可以多次声明。例如在代码清单13-3中,可以在循环中声明元素变量:for (int i = 0; i < array.Length; i++) { ref var element = ref array[i]; ... }
每一次循环迭代中,
element
都会成为不同数组元素的别名,因为每次迭代都是一个新变量。用于初始化
ref
局部变量的变量也必须是已经赋值的。读者可能认为变量应当共享“确定赋值”的状态,但C#语言设计团队并不想把“确定赋值”的规则变得更复杂,因此只需要确保ref
局部变量总是确定赋值的即可,例如:int x; ref int y = ref x; <------ 非法,因为x并不是确定赋值的 x = 10; Console.WriteLine(y);
虽然这段代码在所有变量都确定赋值后才去读取变量的内容,但依然是非法的。
C# 7.3取消了重新赋值这项限制,但是
ref
局部变量必须在声明时赋值的限制仍然存在,例如:int x = 10; int y = 20; ref int r = ref x; r++; r = ref y; <------ 只在C# 7.3中合法 r++; Console.WriteLine($"x={x}; y={y}"); <------ 打印:x=11; y=21
使用该特性当慎之又慎。如果需要在某个方法中使用同一个
ref
变量来指代不同的变量,重构一下方法会更好,使之更简单。
没有
ref
字段,也没有超出方法调用范围的ref
局部变量虽然
ref
局部变量可以使用字段来进行初始化,但是不能把字段声明为ref
字段。这也是为了防止用于初始化ref
变量的变量的生命周期比ref
变量短。假设创建了一个对象,该对象的某个字段是当前方法局部变量的别名,那么如果方法返回了,这个字段该怎么处理呢?在以下3个场景中同样需要关注局部变量的声明周期问题:
- 迭代器块中不能有
ref
局部变量; - async方法不能有
ref
局部变量; ref
局部变量不能被匿名方法或者局部方法捕获。(第14章将讨论局部方法的概念。)
以上几种情况都是局部变量生命周期长于原始方法调用的情况。虽然有时可以让编译器来做判断,但是语言规则还是选择简单优先。(一个简单的例子:一个局部方法只会被定义它的方法调用,而不会用于方法组转换中。)
- 迭代器块中不能有
只读变量不能有引用
C# 7.0中的
ref
局部变量都必须是可写的:可以在这张纸上写新的值。如果用一张不可写的纸来初始化某个ref
局部变量,就会导致问题。考虑以下违反readonly
修饰符的代码:class MixedVariables { private int writableField; private readonly int readonlyField; public void TryIncrementBoth() { ref int x = ref writableField; <------ 为一个可写字段取别名 ref int y = ref readonlyField; <------ 为一个只读字段取别名 x++; (本行及以下1行) 对两个变量分别做自增 y++; } }
如果以上代码可行,那么这些年建立起来的关于只读字段的所有基础都将崩塌。幸好编译器会像阻止任何对
readonlyField
变量的直接修改一样,阻止上面的赋值操作。如果这段代码位于MixedVariables
类的构造器中,就是合法的了,因为在构造器中可以向readonlyField
直接写入。简而言之,创建一个变量的ref
局部变量的前提是:该变量在其他情况下可以正常写入。该规则与C# 1.0中的ref
参数相同。如果只想利用
ref
局部变量共享方面的特性而不需要写入,这项限制会比较棘手。不过C# 7.2针对这一问题提供了一个解决方案(参见13.2.4节)。
类型:只允许一致性转换
ref
局部变量的类型,必须和用于初始化它的变量的类型一致,或者这两个类型之间必须存在一致性转换,任何其他类型的转换都不行,包括引用转换这种其他场景中允许的转换。代码清单13-5展示了一个ref
局部变量声明,使用了基于元组的一致性转换。说明 关于一致性转换,参见11.3.3节。
代码清单13-5
ref
局部变量声明中的一致性转换(int x, int y) tuple1 = (10, 20); ref (int a, int b) tuple2 = ref tuple1; tuple2.a = 30; Console.WriteLine(tuple1.x);
这段代码的执行结果是30,因为
tuple1
和tuple2
共享同一个内存位置。tuple1.x
和tuple2.a
是等价的,tuple1.y
和tuple2.b
也是等价的。前面讲了局部变量、字段和数组元素都可以用于初始化
ref
局部变量。在C# 7中,有一种新的表达式可以归类到变量:方法通过ref
返回的变量。
二、 ref
return
套用前面的思维模型来理解ref
return会比较容易:方法除了可以返回值,还可以返回一张纸。需要在返回类型和返回语句前添加ref
关键字,调用方也需要声明一个ref
局部变量来接收返回值。这意味着需要在代码中显式呈现ref
关键字,才能明确表达意图。代码清单13-6展示了ref
return的一个简单用途。RefReturn
方法将传入的值返回。
代码清单13-6
ref
return的简单示例
static void Main()
{
int x = 10;
ref int y = ref RefReturn(ref x);
y++;
Console.WriteLine(x);
}
static ref int RefReturn(ref int p)
{
return ref p;
}
结果是11,因为x
和y
在同一张纸上。因此上述方法等价于:
ref int y = ref x;
本可以把这个方法写成表达式主体方法,但这里还是保留方法原貌,旨在清晰展示返回部分。
目前看还算简单,但后面还有很多细节需要讨论:编译器必须确保方法在结束之后,它所返回的纸依然存在,因此这张纸不能是在方法内部创建的。
用实现层面的术语表述就是,方法不能返回在栈内存上创建的位置,因为当栈内存弹出后,这个内存位置就不再有效了。在描述C#语言的工作原理时,Eric Lippert喜欢把栈看作实现细节(参考“The Stack Is An Implementation Detail, Part One”)。这个例子所体现的就是一个实现细节在语言当中的渗透。这项限制和不能有ref
字段的限制的原因相同,知晓其一,便能把相同的逻辑应用于另外一个。
这里不会给出可以/不可以使用ref
return 语句的变量类型的完整列表,仅给出一些常见的例子。
- 可以
ref
或者out
参数。- 引用类型的字段。
- 结构体的字段(当结构体变量是
ref
或者out
参数时)。 - 数组元素。
- 不可以
- 在方法内部声明的局部变量(包括值类型的参数)。
- 在方法中声明的结构体的字段。
除了上述规则,在async方法和迭代器块中也完全不允许使用ref
return。与指针类型相似,不能将ref
修饰符用于类型实参(但ref
可以用于接口和委托声明中),例如以下代码完全合法:
delegate ref int RefFuncInt32();
但Func<ref int>
是非法的。
ref
return并非必须和ref
局部变量搭配使用。如果只需要对返回结果执行简单操作,直接操作即可。代码清单13-7是代码清单13-6的变形,没有使用ref
局部变量。
代码清单13-7 把
ref
return的结果直接进行自增
static void Main()
{
int x = 10;
RefReturn(ref x)++; <------ 直接对返回值做自增
Console.WriteLine(x);
}
static ref int RefReturn(ref int p)
{
return ref p;
}
再次强调,这段代码和直接将x
自增是等价的,因此结果是11。除了可以直接修改结果变量,还可以将其用作另一个方法调用的实参,例如调用RefReturn
方法自身(两次)作为参数:
RefReturn(ref RefReturn(ref RefReturn(ref x)))++;
ref
return也可以用于索引器。常见用法是通过引用方式返回数组元素,见代码清单13-8。
代码清单13-8
ref
return索引器对外暴露数组元素
class ArrayHolder
{
private readonly int[] array = new int[10];
public ref int this[int index] => ref array[index]; <------ 索引器通过引用返回一个元素
}
static void Main()
{
ArrayHolder holder = new ArrayHolder();
ref int x = ref holder[0]; (本行及以下1行) 定义两个ref局部变量指向同一个数组元素
ref int y = ref holder[0];
x = 20; <------ 通过x修改数组元素值
Console.WriteLine(y); <------ 通过y检查元素修改结果
}
C# 7.0的所有新特性已介绍完毕,而之后的定点版本扩展了ref
相关特性。其中第一个特性让我在编写本章初稿时感觉十分不快:缺少条件运算符?:
的支持。
三、 条件运算符?:
和ref
值(C# 7.2)
条件运算符?:
从C# 1.0开始就出现了,其用法和其他语言中的类似:
condition ? expression1 : expression2
该运算符首先计算第1个操作数(条件),然后计算第2个或第3个操作数,并将结果作为整个表达式的最终结果。该运算符支持ref
值似乎是自然而然的,根据条件选择其中一个变量。
在C# 7.0中条件运算符并不支持ref
,直到C# 7.2才开始支持。条件运算符可以在第2个和第3个操作数中使用ref
值,条件操作的结果整个也必须是使用ref
修饰的变量。示例见代码清单13-9,其中的CountEvenAndOdd
方法会计算某个序列中奇数和偶数的个数,然后以元组形式返回结果。
代码清单13-9 计算序列中奇数和偶数的个数
static (int even, int odd) CountEvenAndOdd(IEnumerable<int> values)
{
var result = (even: 0, odd: 0);
foreach (var value in values)
{
ref int counter = ref (value & 1) == 0 ? (本行及以下1行) 选择合适的变量做自增
ref result.even : ref result.odd;
counter++; <------ 自增操作
}
return result;
}
这里采用元组作为返回值实属偶然,不过展示了可变元组的好处。这一修正让C#语言的逻辑更统一了。条件运算符的结果可以用作ref
实参,可以赋值给ref
局部变量,也可以用于ref
return。所有衔接都很顺畅。接下来介绍C# 7.2的新特性,它们解决了13.2.1节关于ref
局部变量的一个限制问题:如何获取一个只读变量的引用?
四、 ref readonly
(C# 7.2)
前面提到的可以取别名的变量都是可写变量。在C#7.0中,仅此一种可能;但是在以下两个独立的场景中,只允许ref
可写变量就显得有些捉襟见肘了。
- 可能需要给某个只读字段取别名,避免复制以提升效率。
- 可能需要只允许通过
ref
变量进行只读访问。
C# 7.2引入ref readonly
解决了上述需求。ref
局部变量和ref
return都可以使用readonly
进行修饰,得到的结果自然是只读的,就像只读字段一样。不能为只读变量赋新值,如果它是结构体类型,则不能修改任何字段或者调用属性的setter方法。
提示 虽然使用
ref readonly
可以避免复制,但有时该特性会起到反作用,13.4节会探讨。在此之前,请勿在产品代码中使用ref readonly
。
使用该修饰符的两处需要协作:如果调用一个带有ref readonly
返回的方法或者索引器,并且需要将结果保存到一个局部变量中,那么这个局部变量必须由ref readonly
修饰。代码清单13-10展示了这两者如何配合使用。
代码清单13-10
ref readonly
return和ref readonly
局部变量
static readonly int field = DateTime.UtcNow.Second; <------ 使用一个任意值初始化只读字段
static ref readonly int GetFieldAlias() => ref field; <------ 返回字段的只读别名
static void Main()
{
ref readonly int local = ref GetFieldAlias(); <------ 调用方法来初始化只读ref局部变量
Console.WriteLine(local);
}
这种方式也适用于索引器。这种方式可以让不可变集合直接对外暴露其数据,而无须复制,也不存在内存被篡改的风险。需要注意,可以使用ref readonly
返回的变量本身并不一定是只读的,这样就可以为某个数组提供只读视图了。这一点很像ReadonlyCollection
,但前者在读取时无须复制。代码清单13-11是该思路的一个简单实现。
代码清单13-11 一个数组的只读视图,该数组允许自由复制
class ReadOnlyArrayView<T>
{
private readonly T[] values;
public ReadOnlyArrayView(T[] values) => (本行及以下1行) 复制数组引用,但不需要复制数组内容
this.values = values;
public ref readonly T this[int index] => (本行及以下1行) 返回数组元素的一个只读别名
ref values[index];
}
...
static void Main()
{
var array = new int[] { 10, 20, 30 };
var view = new ReadOnlyArrayView<int>(array);
ref readonly int element = ref view[0];
Console.WriteLine(element); (本行及以下2行) 数组元素的修改对局部变量可见
array[0] = 100;
Console.WriteLine(element);
}
这个例子在性能提升上表现平平,因为int
类型本身属于轻量级;但是如果处理的是大型结构体,采用这种方式就可以避免额外的堆内存分配和垃圾回收,从而显著提升性能。
实现细节
在IL代码中,
ref readonly
方法是以普通ref
返回的方法实现的(返回类型是ref
类型),但是应用了System.Runtime.InteropServices
中的[InAttribute]
特性。该attribute由IL中的modreq
修饰:如果编译器不能识别InAttribute
,那么它应当拒绝任何对该方法的调用。设想C# 7.0编译器(能够识别ref
return,但不能识别ref readonly
return)试图从另一个程序集中调用一个ref readonly
return的方法,那么它可能会允许该方法的返回值存储在一个可写的ref
局部变量中,之后修改这个值,这样就违背了ref readonly
return的设计意图。除非编译器可以识别
InAttribute
,否则无法声明ref readonly
return的方法。该限制很少会成为制约因素,因为从.NET 1.1和.NET Standard 1.1开始,桌面framework中就包含该特性了。假如该attribute不可用,那么可以在合适的命名空间中自行声明该attribute,这样编译器就可以正常应用它了。
如前所述,readonly
修饰符既可以用于局部变量,也可以用于返回值,那么可以用于参数吗?如果有一个ref readonly
局部变量,需要传递给一个方法,同时不希望发生数据复制,有什么方法吗?读者可能会认为参数也需要使用readonly
修饰符,但实际略有不同,稍后探讨。
(完)
相关阅读:
C# 回顾:ref 知多少
C# ref 局部变量和 ref return
in 参数(C# 7.2)
将结构体声明为只读(C# 7.2)
使用 ref 参数或者in参数的扩展方法(C# 7.2)
类 ref 结构体(C# 7.2)
评论
发表评论