要理解C# 7的ref特性,需要认真回顾C# 6以前版本中ref参数的工作原理,首先是变量和值之间的区别。
对于变量这个概念的理解因人而异。可以把变量想象成一张纸,如图13-1所示。这张纸上共有3项信息:
变量的名称;
编译时类型;
当前值。
图13-1 把变量想象成一张纸
给变量赋新值,就相当于擦掉当前值然后写上一个新值。当变量类型是引用类型时,纸上所写的值就不再是对象本身,而是对象的引用。对象的引用,就是通过地址找到对象,就像通过街道地址找到某个建筑一样。如果两张纸上写着相同的地址,那么这两个地址指向同一个建筑;两个引用值相同的变量,指向的是同一个对象。提示 ref关键字和对象引用是不同的概念。虽然二者有相似性,但需要加以区分。通过值传递对象引用和通过引用传递变量是不同的。下面过使用对象引用而不是引用来重点区分这两个概念。
当把某个变量值复制给另外一个变量时,只是这个值本身发生了复制。这两张纸依然是独立的两张纸,之后任何一个变量的值改变都不会影响另外一个变量,见图13-2。
图13-2 把值赋给一个新变量
这种方式的值复制,和调用方法时对值参数的操作是相同的:方法实参的值被复制到了另一张新纸上——形参中,如图13-3所示。实参可以是变量,也可以是任何适当类型的表达式。图13-3
使用值参数调用方法:方法形参是新变量,其初始值是实参的值
但ref参数的行为与此不同,见图13-4。
使用ref参数,不会创建一张新纸,而是由调用方提供一张现有的、包含初始值的纸。可以将其看作一张纸上写着两个名字:一个是调用方使用的该变量的标识,另一个是形参名称。
图13-4 ref参数使用同一张纸,而不是创建一张新纸并复制值
如果在方法中修改了ref参数的值,即修改了纸上的现有值。当方法返回时,修改的结果就会反应给调用方,因为修改的是同一张纸上的值。说明 看待形参和变量的方式有多种。某些作者提出了不同的理解方式:把ref参数看作完全独立的变量,它有一个自动的中间层,任何关于ref参数的访问都会先访问中间层。这种解释更接近IL的工作原理,但对我来说帮助不大。
此外,并不是每个ref参数都会使用不同的纸。下面这个例子有些极端,但有助于我们理解ref参数,以及接下来要讲的ref局部变量。代码清单13-1 多个ref参数使用同一个变量
static void Main()
{
int x = 5;
IncrementAndDouble(ref x, ref x);
Console.WriteLine(x);
}
static void IncrementAndDouble(ref int p1, ref int p2)
{
p1++;
p2 *= 2;
}
这段代码的执行结果是12,x、p1、p2表示的是同一张纸。这张纸上的初始值是5,p1++把它变成6,然后p2 *= 2把6翻倍变成12。图13-5展示了上述过程。
图13-5 两个ref参数指向同一张纸
一种常见的做法是把它们看作别名:变量x、p1和p2都是同一个存储位置的别名,它们只是通往同一块内存的不同方式而已。
上述内容可能略显陈旧、烦琐,但这是在为接下来C# 7真正的新特性做知识铺垫。以纸张作为思维模型来理解变量,便于学习新特性。
2 ref局部变量和ref return
C# 7中ref的很多相关特性是相互关联的。如果逐个介绍,很难体现出这些特性的优势。在描述这些特性时,给出的代码示例也会比一般例子看起来更刻意,旨在一次只展示一个特性点。下面介绍C# 7.0引入的两个特性,二者在C# 7.2中有所增强。首先介绍ref局部变量。
13.2.1 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返回的变量。
13.2.2 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字段的限制的原因相同,知晓其一,便能把相同的逻辑应用于另外一个。