C# ref 局部变量和 ref return

C# 7中ref的很多相关特性是相互关联的。如果逐个介绍,很难体现出这些特性的优势。在描述这些特性时,给出的代码示例也会比一般例子看起来更刻意,旨在一次只展示一个特性点。下面介绍C# 7.0引入的两个特性,二者在C# 7.2中有所增强。首先介绍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.valuetmp只是在初始化时充当obj.value的别名。图13-6是Main方法结束时变量和对象的一个快照。


图13-6 在代码清单13-4末尾,tmp变量指向第一个实例创建后的字段,而obj指向另外一个实例



最终结果是,tmp变量将阻止第一个实例被垃圾回收,直到tmp不再被当前方法使用。类似地,对数组元素使用ref局部变量也会阻止该数组被垃圾回收。

说明 使用ref变量指向对象字段或者数组元素,会让垃圾回收器的工作变得更加复杂。垃圾回收器需要辨别该变量对应的对象,然后保留该对象。一般的对象引用比较简单,因为它们能直接判断出所引用的对象。对于对象而言,每增加一个指向其字段的ref变量,垃圾回收器所维护的数据结构就会增加一个内部指针。如果同时出现很多这种变量,代价就会随之高涨。好在ref变量只会出现在栈内存中,不大可能造成性能问题。

使用ref局部变量时有一些限制条件,其中大部分比较明显,没有太大影响,但还是有必要了解一下,免得浪费时间想迂回办法。

  1. 初始化:只在声明时初始化一次(在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变量来指代不同的变量,重构一下方法会更好,使之更简单。
     

  2. 没有ref字段,也没有超出方法调用范围的ref局部变量

    虽然ref局部变量可以使用字段来进行初始化,但是不能把字段声明为ref字段。这也是为了防止用于初始化ref变量的变量的生命周期比ref变量短。假设创建了一个对象,该对象的某个字段是当前方法局部变量的别名,那么如果方法返回了,这个字段该怎么处理呢?

    在以下3个场景中同样需要关注局部变量的声明周期问题:

    • 迭代器块中不能有ref局部变量;
    • async方法不能有ref局部变量;
    • ref局部变量不能被匿名方法或者局部方法捕获。(第14章将讨论局部方法的概念。)

    以上几种情况都是局部变量生命周期长于原始方法调用的情况。虽然有时可以让编译器来做判断,但是语言规则还是选择简单优先。(一个简单的例子:一个局部方法只会被定义它的方法调用,而不会用于方法组转换中。)
     

  3. 只读变量不能有引用

    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节)。
     

  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,因为tuple1tuple2共享同一个内存位置。tuple1.xtuple2.a是等价的,tuple1.ytuple2.b也是等价的。

    前面讲了局部变量、字段和数组元素都可以用于初始化ref局部变量。在C# 7中,有一种新的表达式可以归类到变量:方法通过ref返回的变量。

套用前面的思维模型来理解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,因为xy在同一张纸上。因此上述方法等价于:

ref int y = ref x;

本可以把这个方法写成表达式主体方法,但这里还是保留方法原貌,旨在清晰展示返回部分。

目前看还算简单,但后面还有很多细节需要讨论:编译器必须确保方法在结束之后,它所返回的纸依然存在,因此这张纸不能是在方法内部创建的。

用实现层面的术语表述就是,方法不能返回在栈内存上创建的位置,因为当栈内存弹出后,这个内存位置就不再有效了。在描述C#语言的工作原理时,Eric Lippert喜欢把栈看作实现细节(参考“The Stack Is An Implementation Detail, Part One”)。这个例子所体现的就是一个实现细节在语言当中的渗透。这项限制和不能有ref字段的限制的原因相同,知晓其一,便能把相同的逻辑应用于另外一个。

这里不会给出可以/不可以使用ref return 语句的变量类型的完整列表,仅给出一些常见的例子。

  1. 可以
    • ref或者out参数。
    • 引用类型的字段。
    • 结构体的字段(当结构体变量是ref或者out参数时)。
    • 数组元素。
       
  2. 不可以
    • 在方法内部声明的局部变量(包括值类型的参数)。
    • 在方法中声明的结构体的字段。

除了上述规则,在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相关特性。其中第一个特性让我在编写本章初稿时感觉十分不快:缺少条件运算符?:的支持。

条件运算符?:从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局部变量的一个限制问题:如何获取一个只读变量的引用?

前面提到的可以取别名的变量都是可写变量。在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)

评论

此博客中的热门博文

in 参数(C# 7.2)

类 ref 结构体(C# 7.2)