除了引數,ref關鍵字還可以用在什麼地方?

2023-07-03 09:00:27

老生常談:值型別 V.S. 參照型別》中花了很大的篇幅介紹ref引數針對值型別和參照型別變數的傳遞。在C#中,除了方法的ref引數,我們還有很多使用ref關鍵字傳遞參照/地址的場景,本篇文章作一個簡單的總結。

一、引數
二、陣列索引
三、方法
四、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. 不能在迭代器中使用

如果在一個返回IEnumerable<T>的方法中使用了yield return語句作為集合元素迭代器(interator),意味著涉及的操作執行會「延遲」到作為返回物件的集合被真正迭代(比如執行foreach語句)的時候,這個時候原始方法的堆疊幀已經被回收。

IEnumerable<(int Foo, int Bar)> Deconstruct(Foobar foobar1, Foobar foobar2)
{
    //Error	CS4013	Instance of type 'Foobar' cannot be used inside a nested function, query expression, iterator block or async method
    yield return (foobar1.Foo, foobar1.Bar);
    //Error	CS4013	Instance of type 'Foobar' cannot be used inside a nested function, query expression, iterator block or async method
    yield return (foobar2.Foo, foobar2.Bar);
}

9. readonly ref 結構體

順表補充一下,我們可以按照如下的方式新增前置的readonly關鍵字定義一個唯讀的ref結構體。對於這樣的結構體,其資料成員只能在被構造或者被初始化的時候進行指定,所以只能定義成如下的形式。

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

如果為屬性定義了set方法,或者其欄位沒有設定成「唯讀」,這樣的readonly ref 結構體均是不合法的。

public readonly ref struct Foobar
{
    //Error	CS8341	Auto-implemented instance properties in readonly structs must be readonly.
    public int Foo { get; set; }
    //Error	CS8341	Auto-implemented instance properties in readonly structs must be readonly.
    public int Bar { get; set; }
}
public readonly ref struct Foobar
{
    //Error	CS8340	Instance fields of readonly structs must be readonly.
    public int Foo;
    //Error	CS8340	Instance fields of readonly structs must be readonly.
    public int Bar;
}

五、ref 結構體欄位

我們可以在ref結構體的欄位成員前新增ref關鍵字使之返回一個參照。除此之外,我們還可以進一步新增readonly關鍵字建立「唯讀參照欄位」,並且這個readonly關鍵可以放在ref後面(ref readonly),也可以放在ref前面(readonly ref),還可以前後都放(readonly ref readonly)。如果你之前沒有接觸過ref欄位,是不是會感到很暈?希望一下的內容能夠為你解惑。上面的程式碼片段定義了一個名為RefStruct的ref 結構體,定義其中的四個欄位(Foo、Bar、Baz和Qux)都是返回參照的ref 欄位。除了Foo欄位具有具有可讀寫的特性外,我們採用上述三種不同的形式將其餘三個欄位定義成「自讀」的。

public ref struct RefStruct
{
    public ref KV Foo;
    public ref readonly KV Bar;
    public readonly ref KV Baz;
    public readonly ref readonly KV Qux;
    public RefStruct(ref KV foo, ref KV bar, ref KV baz, ref KV qux)
    {
        Foo = ref foo;
        Bar = ref bar;
        Baz = ref baz;
        Qux = ref qux;
    }
}

public struct KV
{
    public int Key;
    public int Value;
    public KV(int key, int value)
    {
        Key = key;
        Value = value;
    }
}

1. Writable

在如下的演示程式碼中,我們針對同一個KV物件的參照建立了RefStruct。在直接修改Foo欄位返回的KV之後,由於四個欄位參照的都是同一個KV,所以其餘三個欄位都被修改了。由於Foo欄位是可讀可寫的,所以當我們為它指定一個新的KV後,其他三個欄位也被替換了。

KV kv = default;

var value = new RefStruct(ref kv, ref kv, ref kv, ref kv);
value.Foo.Key = 1;
value.Foo.Value = 1;
Debug.Assert(kv.Key == 1);
Debug.Assert(kv.Value == 1);
Debug.Assert(value.Foo.Key == 1);
Debug.Assert(value.Foo.Value == 1);
Debug.Assert(value.Bar.Key == 1);
Debug.Assert(value.Bar.Value == 1);
Debug.Assert(value.Baz.Key == 1);
Debug.Assert(value.Baz.Value == 1);
Debug.Assert(value.Qux.Key == 1);
Debug.Assert(value.Qux.Value == 1);

value.Foo = new KV(2, 2);
Debug.Assert(kv.Key == 2);
Debug.Assert(kv.Value == 2);
Debug.Assert(value.Foo.Key == 2);
Debug.Assert(value.Foo.Value == 2);
Debug.Assert(value.Bar.Key == 2);
Debug.Assert(value.Bar.Value == 2);
Debug.Assert(value.Baz.Key == 2);
Debug.Assert(value.Baz.Value == 2);
Debug.Assert(value.Qux.Key == 2);
Debug.Assert(value.Qux.Value == 2);

2. ref readonly

第一個欄位被定義成「ref readonly」,readonly被置於ref之後,表示readonly並不是用來修飾ref,而是用來修飾參照指向的KV物件,它使我們不能修改KV物件的資料成員。所以如下的程式碼是不能通過編譯的。

KV kv = default;

var value = new RefStruct(ref kv, ref kv, ref kv, ref kv);
//Error	CS8332	Cannot assign to a member of field 'Bar' or use it as the right hand side of a ref assignment because it is a readonly variable
value.Bar.Key = 2;
//Error	CS8332	Cannot assign to a member of field 'Bar' or use it as the right hand side of a ref assignment because it is a readonly variable
value.Bar.Value = 2;

但是這僅僅能夠保證我們不能直接通過欄位進行修改而已,我們依然可以通過將欄位賦值給另一個變數,利用這個變數依然達到更新該欄位的目的。

KV kv = default;
var value = new RefStruct(ref kv, ref kv, ref kv, ref kv);

kv = value.Bar;
kv.Key = 1;
kv.Value = 1;
Debug.Assert(value.Baz.Key == 1);
Debug.Assert(value.Baz.Value == 1);

由於readonly並不是修飾參照本身,所以我們採用如下的方式通過修改參照達到替換欄位的目的。

KV kv = default;
KV another = new KV(1,1);

var value = new RefStruct(ref kv, ref kv, ref kv, ref kv);
value.Bar = ref another;
Debug.Assert(value.Bar.Key == 1);
Debug.Assert(value.Bar.Key == 1);

3. readonly ref

如果readonly被置於ref前面,就意味著參照本身,所以針對Baz欄位的賦值是不合法的。

KV kv = default;

var value = new RefStruct(ref kv, ref kv, ref kv, ref kv);
KV another = new KV(1, 1);
//Error	CS0191	A readonly field cannot be assigned to (except in a constructor or init-only setter of the type in which the field is defined or a variable initializer)
value.Baz = ref another;

但是參照指向的KV物件是可以直接通過欄位進行修改。

KV kv = default;
var value = new RefStruct(ref kv, ref kv, ref kv, ref kv);

value.Baz.Key = 1;
value.Baz.Value = 1;
Debug.Assert(value.Baz.Key == 1);
Debug.Assert(value.Baz.Key == 1);

4. readonly ref readonly

現在我們知道了ref前後的readonly分別修飾的是欄位返回的參照和參照指向的目標物件,所以對於readonly ref readonly修飾的欄位Qux,我們既不能位元組將其替換成指向另一個KV的參照,也不能直接利用它修改該欄位指向的KV物件。

KV kv = default;
var another = new KV(1, 1);
var value = new RefStruct(ref kv, ref kv, ref kv, ref kv);
//Error	CS0191	A readonly field cannot be assigned to (except in a constructor or init-only setter of the type in which the field is defined or a variable initializer)
value.Qux = ref another;
KV kv = default;
var value = new RefStruct(ref kv, ref kv, ref kv, ref kv);
//Error	CS8332	Cannot assign to a member of field 'Qux' or use it as the right hand side of a ref assignment because it is a readonly variable
value.Qux.Key = 1;
//Error	CS8332	Cannot assign to a member of field 'Qux' or use it as the right hand side of a ref assignment because it is a readonly variable
value.Qux.Value = 1;