ref关键字用在什么地方?

ref关键字用在什么地方呢?有5个地方:一、参数二、数组索引三、方法四、ref 结构体五、ref 结构体字段。

一、参数

如果在方法的参数(不论是值类型和引用类型)添加了ref关键字,意味着将变量的地址作为参数传递到方法中。目标方法利用ref参数不仅可以直接操作原始的变量,还能直接替换整个变量的值。如下的代码片段定义了一个基于结构体的Record类型Foobar,并定义了Update和Replace方法,它们具有的唯一参数类型为Foobar,并且前置了ref关键字。

static void Update(ref Foobar foobar)
{
    foobar.Foo = 0;
}

static void Replace(ref Foobar foobar)
{
    foobar = new Foobar(0, 0);
}

public record struct Foobar(int Foo, int Bar);

基于ref参数针对原始变量的修改和替换体现在如下所示的演示代码中。

var foobar = new Foobar(1, 2);
Update(ref foobar);
Debug.Assert(foobar.Foo == 0);
Debug.Assert(foobar.Bar == 2);

Replace(ref foobar);
Debug.Assert(foobar.Foo == 0);
Debug.Assert(foobar.Bar == 0);

C#中的ref + Type(ref Foobar)在IL中会转换成一种特殊的引用类型Type&。如下所示的是上述两个方法针对IL的声明,可以看出它们的参数类型均为Foobar&。

.method assembly hidebysig static
	void '<<Main>$>g__Update|0_0' (
		valuetype Foobar& foobar
	) cil managed

.method assembly hidebysig static
	void '<<Main>$>g__Replace|0_1' (
		valuetype Foobar& foobar
	) cil managed

二、数组索引

我们知道数组映射一段连续的内存空间,具有相同字节长度的元素“平铺”在这段内存上。我们可以利用索引提取数组的某个元素,如果索引操作符前置了ref关键值,那么返回的就是索引自身的引用/地址。与ref参数类似,我们利用ref array[index]不仅可以修改索引指向的数组元素,还可以直接将该数组元素替换掉。

var array = new Foobar[] { new Foobar(1, 1), new Foobar(2, 2), new Foobar(3, 3) };

Update(ref array[1]);
Debug.Assert(array[1].Foo == 0);
Debug.Assert(array[1].Bar == 2);

Replace(ref array[1]);
Debug.Assert(array[1].Foo == 0);
Debug.Assert(array[1].Bar == 0);

由于ref关键字在IL中被被转换成“引用类型”,所以对应的“值”也只能存储在对应引用类型的变量上,引用变量同样通过ref关键字来声明。下面的代码演示了两种不同的变量赋值,前者将Foobar数组的第一个元素的“值”赋给变量foobar(类型为Foobar),后者则将第一个元素在数组中的地址赋值给变量foobarRef(类型为Foobar&)。

var array = new Foobar[] { new Foobar(1, 1), new Foobar(2, 2), new Foobar(3, 3) };
Foobar foobar = array[0];
ref Foobar foobarRef = ref array[0];

或者

var foobar = array[0];
ref var foobarRef = ref array[0];

上边这段C#代码将会转换成如下这段IL代码。我们不仅可以看出foobar和foobarRef声明的类型的不同(Foobar和Foobar&),还可以看到array[0]和ref array[0]使用的IL指令的差异,前者使用的是ldelem(Load Element)后者使用的是ldelema(Load Element Addess)。

.method private hidebysig static
	void '<Main>$' (
		string[] args
	) cil managed
{
	// Method begins at RVA 0x209c
	// Header size: 12
	// Code size: 68 (0x44)
	.maxstack 5
	.entrypoint
	.locals init (
		[0] valuetype Foobar[] 'array',
		[1] valuetype Foobar foobar,
		[2] valuetype Foobar& foobarRef
	)

	// {
	IL_0000: ldc.i4.3
	// (no C# code)
	IL_0001: newarr Foobar
	IL_0006: dup
	IL_0007: ldc.i4.0
	// 	Foobar[] array = new Foobar[3]
	// 	{
	// 		new Foobar(1, 1),
	// 		new Foobar(2, 2),
	// 		new Foobar(3, 3)
	// 	};
	IL_0008: ldc.i4.1
	IL_0009: ldc.i4.1
	IL_000a: newobj instance void Foobar::.ctor(int32, int32)
	IL_000f: stelem Foobar
	IL_0014: dup
	IL_0015: ldc.i4.1
	IL_0016: ldc.i4.2
	IL_0017: ldc.i4.2
	IL_0018: newobj instance void Foobar::.ctor(int32, int32)
	IL_001d: stelem Foobar
	IL_0022: dup
	IL_0023: ldc.i4.2
	IL_0024: ldc.i4.3
	IL_0025: ldc.i4.3
	IL_0026: newobj instance void Foobar::.ctor(int32, int32)
	IL_002b: stelem Foobar
	IL_0030: stloc.0
	// Foobar foobar = array[0];
	IL_0031: ldloc.0
	IL_0032: ldc.i4.0
	IL_0033: ldelem Foobar
	IL_0038: stloc.1
	// ref Foobar reference = ref array[0];
	IL_0039: ldloc.0
	IL_003a: ldc.i4.0
	IL_003b: ldelema Foobar
	IL_0040: stloc.2
	// (no C# code)
	IL_0041: nop
	// }
	IL_0042: nop
	IL_0043: ret
} // end of method Program::'<Main>$'

三、方法

方法可以通过前置的ref关键字返回引用/地址,比如变量或者数组元素的引用/地址。如下面的代码片段所示,方法ElementAt返回指定Foobar数组中指定索引的地址。由于该方法返回的是数组元素的地址,所以我们利用返回值直接修改对应数组元素(调用Update方法),也可以直接将整个元素替换掉(调用Replace方法)。如果我们查看ElementAt基于IL的声明,同样会发现它的返回值为Foobar&

var array = new Foobar[] { new Foobar(1, 1), new Foobar(2, 2), new Foobar(3, 3) };

var copy = ElementAt(array, 1);
Update(ref copy);
Debug.Assert(array[1].Foo == 2);
Debug.Assert(array[1].Bar == 2);
Replace(ref copy);
Debug.Assert(array[1].Foo == 2);
Debug.Assert(array[1].Bar == 2);

ref var self = ref ElementAt(array, 1);
Update(ref self);
Debug.Assert(array[1].Foo == 0);
Debug.Assert(array[1].Bar == 2);
Replace(ref self);
Debug.Assert(array[1].Foo == 0);
Debug.Assert(array[1].Bar == 0);


static ref Foobar ElementAt(Foobar[] array, int index) => ref array[index];

四、ref 结构体

如果在定义结构体时添加了前置的ref关键字,那么它就转变成一个ref结构体。ref结构体和常规结构最根本的区别是它不能被分配到堆上,并且总是以引用的方式使用它,永远不会出现“拷贝”的情况,最重要的ref 结构体莫过于Span<T>了。如下这个Foobar结构体就是一个包含两个数据成员的ref结构体。

public ref struct Foobar{
    public int Foo { get; }
    public int Bar { get; }
    public Foobar(int foo, int bar)
    {
        Foo = foo;
        Bar = bar;
    }
}

ref结构体具有很多的使用约束。对于这些约束,很多人不是很理解,其实我们只需要知道这些约束最终都是为了确保:ref结构体只能存在于当前线程堆栈,而不能转移到堆上。基于这个原则,我们来具体来看看ref结构究竟有哪些使用上的限制。

1. 不能作为泛型参数

除非我们能够显式将泛型参数约束为ref结构体,对应的方法严格按照ref结构的标准来操作对应的参数或者变量,我们才能够能够将ref结构体作为泛型参数。否则对于泛型结构体,涉及的方法肯定会将其当成一个常规结构体看待,若将ref结构体指定为泛型参数类型自然是有问题。但是针对ref结构体的泛型约束目前还没有,所以我们就不能将ref结构体作为泛型参数,所以按照如下的方式创建一个Wrapper<Foobar>(Foobar为上面定义的ref结构体,下面不再单独说明)的代码是不能编译的。

// Error	CS0306	The type 'Foobar' may not be used as a type argument
var wrapper = new Wrapper<Foobar>(new Foobar(1, 2));

public class Wrapper<T>
{
    public Wrapper(T value) => Value = value;
    public T Value { get; }
}

2. 不能作为数组元素类型

数组是分配在堆上的,我们自然不能将ref结构体作为数组的元素类型,所以如下的代码也会遇到编译错误。

//Error	CS0611	Array elements cannot be of type 'Foobar'
var array = new Foobar[16];

3. 不能作为类型和非ref结构体数据成员

由于类的实例分配在堆上,常规结构体也并没有纯栈分配的约束,ref结构体自然不能作为它们的数据成员,所以如下所示的类和结构体的定义都是不合法的。

public class Foobarbaz
{
    //Error	CS8345	Field or auto-implemented property cannot be of type 'Foobar' unless it is an instance member of a ref struct.
    public Foobar Foobar { get; }
    public int Baz { get; }
    public Foobarbaz(Foobar foobar, int baz)
    {
        Foobar = foobar;
        Baz = baz;
    }
}

或者

public structure Foobarbaz
{
    //Error	CS8345	Field or auto-implemented property cannot be of type 'Foobar' unless it is an instance member of a ref struct.
    public Foobar Foobar { get; }
    public int Baz { get; }
    public Foobarbaz(Foobar foobar, int baz)
    {
        Foobar = foobar;
        Baz = baz;
    }
}

4. 不能实现接口

当我们以接口的方式使用某个结构体时会导致装箱,并最终导致堆分配,所以ref结构体不能实现任意接口。

//Error    CS8343    'Foobar': ref structs cannot implement interfaces
public ref struct Foobar : IEquatable<Foobar>
{
    public int Foo { get; }
    public int Bar { get; }
    public Foobar(int foo, int bar)
    {
        Foo = foo;
        Bar = bar;
    }

    public bool Equals(Foobar other) => Foo == other.Foo && Bar == other.Bar;
}

5. 不能导致装箱

所有类型都默认派生自object,所有值类型派生自ValueType类型,但是这两个类型都是引用类型(ValueType自身是引用类型),所以将ref结构体转换成object或者ValueType类型会导致装箱,是无法通过编译的。

//Error	CS0029	Cannot implicitly convert type 'Foobar' to 'object'
Object obj = new Foobar(1, 2);

//Error	CS0029	Cannot implicitly convert type 'Foobar' to 'System.ValueType'
ValueType value = new Foobar(1, 2);

6. 不能在委托中(或者Lambda表达式)使用

ref结构体的变量总是引用存储结构体的栈地址,所以它们只有在创建该ref结构体的方法中才有意义。一旦方法返回,堆栈帧被回收,它们自然就“消失”了。委托被认为是一个待执行的操作,我们无法约束它们必须在某方法中执行,所以委托执行的操作中不能引用ref结构体。从另一个角度来讲,一旦委托中涉及针对现有变量的引用,必然会导致“闭包”的创建,也就是会创建一个类型来对引用的变量进行封装,这自然也就违背了“不能将ref结构体作为类成员”的约束。这个约束同样应用到Lambda表达式和本地方法上。

public class Program
{
    static void Main()
    {
        var foobar = new Foobar(1, 2);
        //Error CS8175  Cannot use ref local 'foobar' inside an anonymous method, lambda expression, or query expression
        Action action1 = () => Console.WriteLine(foobar);

        //Error CS8175  Cannot use ref local 'foobar' inside an anonymous method, lambda expression, or query expression
        void Print() => Console.WriteLine(foobar);
    }
}

7. 不能在async/await异步方法中

这个约束与上一个约束类似。一般来说,一个异步方法执行过程中遇到await语句就会字节返回,后续针对操作具有针对ref结构体引用,自然是不合法的。从另一方面来讲,async/await最终会转换成基于状态机的类型,依然会出现利用自动生成的类型封装引用变量的情况,同样违背了“不能将ref结构体作为类成员”的约束。

async Task InvokeAsync()
{
    await Task.Yield();
    //Error	CS4012	Parameters or locals of type 'Foobar' cannot be declared in async methods or async lambda 
    var foobar = new Foobar(1, 2);
}

值得一提的是,对于返回类型为Task的异步方法,如果没有使用async关键字,由于它就是一个普通的方法,编译器并不会执行基于状态机的代码生成,所以可以自由地使用ref结构体。

public Task InvokeAsync()
{
    var foobar = new Foobar(1, 2);
    ...
    return Task.CompletedTask;
}

8. 不能在迭代器中使用

猜你喜欢

转载自blog.csdn.net/yetaodiao/article/details/131554595
今日推荐