前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >[C#] ref知多少

[C#] ref知多少

作者头像
科控物联
发布2022-03-29 16:26:35
1.2K0
发布2022-03-29 16:26:35
举报
文章被收录于专栏:科控自动化

要理解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参数使用同一个变量

代码语言:javascript
复制
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局部变量修改数组元素

代码语言:javascript
复制
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局部变量为一个对象的字段取别名

代码语言:javascript
复制
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中,可以在循环中声明元素变量:

代码语言:javascript
复制
for (int i = 0; i < array.Length; i++)
{
    ref var element = ref array[i];
    ...
}

每一次循环迭代中,element都会成为不同数组元素的别名,因为每次迭代都是一个新变量。

用于初始化ref局部变量的变量也必须是已经赋值的。读者可能认为变量应当共享“确定赋值”的状态,但C#语言设计团队并不想把“确定赋值”的规则变得更复杂,因此只需要确保ref局部变量总是确定赋值的即可,例如:

代码语言:javascript
复制
int x;
ref int y = ref x; <------ 非法,因为x并不是确定赋值的
x = 10;
Console.WriteLine(y);

虽然这段代码在所有变量都确定赋值后才去读取变量的内容,但依然是非法的。

C# 7.3取消了重新赋值这项限制,但是ref局部变量必须在声明时赋值的限制仍然存在,例如:

代码语言:javascript
复制
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修饰符的代码:

代码语言:javascript
复制
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局部变量声明中的一致性转换

代码语言:javascript
复制
(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的简单示例

代码语言:javascript
复制
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字段的限制的原因相同,知晓其一,便能把相同的逻辑应用于另外一个。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2021-10-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 科控物联 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档