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