《老生常談:值型別 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 結構體莫過於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關鍵字使之返回一個參照。除此之外,我們還可以進一步新增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;