天天看點

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

作者:opendotnet

《老生常談:值類型 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&。

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

.method assembly hidebysig static
void '<<Main>gt;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;