前言
.NET 7 的開發還剩下一個多月就要進入 RC,C# 11 的新特性和改進也即將敲定。在這個時間點上,不少新特性都已經實現完畢并合并入主分支
C# 11 包含的新特性和改進非常多,類型系統相比之前也有了很大的增強,在確保靜態類型安全的同時大幅提升了語言表達力。
那么本文就按照方向從 5 個大類來進行介紹,一起來提前看看 C# 11 的新特性和改進都有什么。
1. 類型系統的改進
抽象和虛靜態方法
C# 11 開始將?abstract
?和?virtual
?引入到靜態方法中,允許開發者在接口中編寫抽象和虛靜態方法。
接口與抽象類不同,接口用來抽象行為,通過不同類型實現接口來實現多態;而抽象類則擁有自己的狀態,通過各子類型繼承父類型來實現多態。這是兩種不同的范式。
在 C# 11 中,虛靜態方法的概念被引入,在接口中可以編寫抽象和虛靜態方法了。
interface IFoo{ // 抽象靜態方法abstract static int Foo1(); // 虛靜態方法virtual static int Foo2(){ return 42;}
}struct Bar : IFoo
{ // 隱式實現接口方法public static int Foo1(){ return 7;}
}Bar.Foo1(); // ok
由于運算符也屬于靜態方法,因此從 C# 11 開始,也可以用接口來對運算符進行抽象了。
interface ICanAdd<T> where T : ICanAdd<T>
{ abstract static T operator +(T left, T right);
}
這樣我們就可以給自己的類型實現該接口了,例如實現一個二維的點?Point
:
record struct Point(int X, int Y) : ICanAdd<Point>
{ // 隱式實現接口方法public static Point operator +(Point left, Point right){ return new Point(left.X + right.X, left.Y + right.Y);}
}
然后我們就可以對兩個?Point
?進行相加了:
var p1 = new Point(1, 2);var p2 = new Point(2, 3);
Console.WriteLine(p1 + p2); // Point { X = 3, Y = 5 }
除了隱式實現接口之外,我們也可以顯式實現接口:
record struct Point(int X, int Y) : ICanAdd<Point>
{ // 顯式實現接口方法static Point ICanAdd<Point>.operator +(Point left, Point right){ return new Point(left.X + right.X, left.Y + right.Y);}
}
不過用顯示實現接口的方式的話,+
?運算符沒有通過?public
?公開暴露到類型?Point
?上,因此我們需要通過接口來調用?+
?運算符,這可以利用泛型約束來做到:
var p1 = new Point(1, 2);var p2 = new Point(2, 3);
Console.WriteLine(Add(p1, p2)); // Point { X = 3, Y = 5 }T Add<T>(T left, T right) where T : ICanAdd<T>{ return left + right;
}
對于不是運算符的情況,則可以利用泛型參數來調用接口上的抽象和靜態方法:
void CallFoo1<T>() where T : IFoo{T.Foo1();
}Bar.Foo1(); // errorCallFoo<Bar>(); // okstruct Bar : IFoo
{ // 顯式實現接口方法static void IFoo.Foo1(){ return 7;}
}
此外,接口可以基于另一個接口擴展,因此對于抽象和虛靜態方法而言,我們可以利用這個特性在接口上實現多態。
CallFoo<Bar1>(); // 5 5CallFoo<Bar2>(); // 6 4CallFoo<Bar3>(); // 3 7CallFooFromIA<Bar4>(); // 1CallFooFromIB<Bar4>(); // 2void CallFoo<T>() where T : IC{CallFooFromIA<T>();CallFooFromIB<T>();
}void CallFooFromIA<T>() where T : IA{Console.WriteLine(T.Foo());
}void CallFooFromIB<T>() where T : IB{Console.WriteLine(T.Foo());
}interface IA{ virtual static int Foo(){ return 1;}
}interface IB{ virtual static int Foo(){ return 2;}
}interface IC : IA, IB{ static int IA.Foo(){ return 3;} static int IB.Foo(){ return 4;}
}struct Bar1 : IC
{ public static int Foo(){ return 5;}
}struct Bar2 : IC
{ static int IA.Foo(){ return 6;}
}struct Bar3 : IC
{ static int IB.Foo(){ return 7;}
}struct Bar4 : IA, IB { }折疊
同時,.NET 7 也利用抽象和虛靜態方法,對基礎庫中的數值類型進行了改進。在?System.Numerics
?中新增了大量的用于數學的泛型接口,允許用戶利用泛型編寫通用的數學計算代碼:
using System.Numerics;V Eval<T, U, V>(T a, U b, V c) where T : IAdditionOperators<T, U, U> where U : IMultiplyOperators<U, V, V>{ return (a + b) * c;
}Console.WriteLine(Eval(3, 4, 5)); // 35Console.WriteLine(Eval(3.5f, 4.5f, 5.5f)); // 44
泛型 attribute
C# 11 正式允許用戶編寫和使用泛型 attribute,因此我們可以不再需要使用?Type
?來在 attribute 中存儲類型信息,這不僅支持了類型推導,還允許用戶通過泛型約束在編譯時就能對類型進行限制。
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]class FooAttribute<T> : Attribute where T : INumber<T>
{ public T Value { get; } public FooAttribute(T v){Value = v;}
}[Foo<int>(3)] // ok[Foo<float>(4.5f)] // ok[Foo<string>("test")] // errorvoid MyFancyMethod() { }
ref 字段和 scoped ref
C# 11 開始,開發者可以在?ref struct
?中編寫?ref
?字段,這允許我們將其他對象的引用存儲在一個?ref struct
?中:
int x = 1;
Foo foo = new(ref x);
foo.X = 2;
Console.WriteLine(x); // 2ref struct Foo
{ public ref int X; public Foo(ref int x){X = ref x;}
}
可以看到,上面的代碼中將?x
?的引用保存在了?Foo
?中,因此對?foo.X
?的修改會反映到?x
?上。
如果用戶沒有對?Foo.X
?進行初始化,則默認是空引用,可以利用?Unsafe.IsNullRef
?來判斷一個?ref
?是否為空:
ref struct Foo
{ public ref int X; public bool IsNull => Unsafe.IsNullRef(ref X); public Foo(ref int x){X = ref x;}
}
這里可以發現一個問題,那就是?ref field
?的存在,可能會使得一個?ref
?指向的對象的生命周期被擴展而導致錯誤,例如:
Foo MyFancyMethod(){ int x = 1;Foo foo = new(ref x); return foo; // error}ref struct Foo
{ public Foo(ref int x) { }
}
上述代碼編譯時會報錯,因為?foo
?引用了局部變量?x
,而局部變量?x
?在函數返回后生命周期就結束了,但是返回?foo
?的操作使得?foo
?的生命周期比?x
?的生命周期更長,這會導致無效引用的問題,因此編譯器檢測到了這一點,不允許代碼通過編譯。
但是上述代碼中,雖然?foo
?確實引用了?x
,但是?foo
?對象本身并沒有長期持有?x
?的引用,因為在構造函數返回后就不再持有對?x
?的引用了,因此這里按理來說不應該報錯。于是 C# 11 引入了?scoped
?的概念,允許開發者顯式標注?ref
?的生命周期,標注了?scoped
?的?ref
?表示這個引用的生命周期不會超過當前函數的生命周期:
Foo MyFancyMethod(){ int x = 1;Foo foo = new(ref x); return foo; // ok}ref struct Foo
{ public Foo(scoped ref int x) { }
}
這樣一來,編譯器就知道?Foo
?的構造函數不會使得?Foo
?在構造函數返回后仍然持有?x
?的引用,因此上述代碼就能安全通過編譯了。如果我們試圖讓一個?scoped ref
?逃逸出當前函數的話,編譯器就會報錯:
ref struct Foo
{ public ref int X; public Foo(scoped ref int x){X = ref x; // error}
}
如此一來,就實現了引用安全。
利用?ref
?字段,我們可以很方便地實現各種零開銷設施,例如提供一個多種方法訪問顏色數據的?ColorView
:
using System.Diagnostics.CodeAnalysis;using System.Runtime.CompilerServices;using System.Runtime.InteropServices;var color = new Color { R = 1, G = 2, B = 3, A = 4 };
color.RawOfU32[0] = 114514;
color.RawOfU16[1] = 19198;
color.RawOfU8[2] = 10;
Console.WriteLine(color.A); // 74[StructLayout(LayoutKind.Explicit)]struct Color
{[FieldOffset(0)] public byte R;[FieldOffset(1)] public byte G;[FieldOffset(2)] public byte B;[FieldOffset(3)] public byte A;[FieldOffset(0)] public uint Rgba; public ColorView<byte> RawOfU8 => new(ref this); public ColorView<ushort> RawOfU16 => new(ref this); public ColorView<uint> RawOfU32 => new(ref this);
}ref struct ColorView<T> where T : unmanaged{ private ref Color color; public ColorView(ref Color color){ this.color = ref color;}[DoesNotReturn] private static ref T Throw() => throw new IndexOutOfRangeException(); public ref T this[uint index]{[MethodImpl(MethodImplOptions.AggressiveInlining)] get{ unsafe{ return ref (sizeof(T) * index >= sizeof(Color) ? ref Throw() : ref Unsafe.Add(ref Unsafe.AsRef<T>(Unsafe.AsPointer(ref color)), (int)index));}}}
}
在字段中,ref
?還可以配合?readonly
?一起使用,用來表示不可修改的?ref
,例如:
ref int
:一個?int
?的引用readonly ref int
:一個?int
?的只讀引用ref readonly int
:一個只讀?int
?的引用readonly ref readonly int
:一個只讀?int
?的只讀引用
這將允許我們確保引用的安全,使得引用到只讀內容的引用不會被意外更改。
當然,C# 11 中的?ref
?字段和?scoped
?支持只是其完全形態的一部分,更多的相關內容仍在設計和討論,并在后續版本中推出。
文件局部類型
C# 11 引入了新的文件局部類型可訪問性符號?file
,利用該可訪問性符號,允許我們編寫只能在當前文件中使用的類型:
// A.csfile class Foo{ // ...}file struct Bar
{ // ...}
如此一來,如果我們在與?Foo
?和?Bar
?的不同文件中使用這兩個類型的話,編譯器就會報錯:
// A.csvar foo = new Foo(); // okvar bar = new Bar(); // ok// B.csvar foo = new Foo(); // errorvar bar = new Bar(); // error
這個特性將可訪問性的粒度精確到了文件,對于代碼生成器等一些要放在同一個項目中,但是又不想被其他人接觸到的代碼而言將會特別有用。
required 成員
C# 11 新增了?required
?成員,標記有?required
?的成員將會被要求使用時必須要進行初始化,例如:
var foo = new Foo(); // errorvar foo = new Foo { X = 1 }; // okstruct Foo
{ public required int X;
}
開發者還可以利用?SetsRequiredMembers
?這個 attribute 來對方法進行標注,表示這個方法會初始化?required
?成員,因此用戶在使用時可以不需要再進行初始化:
using System.Diagnostics.CodeAnalysis;var p = new Point(); // errorvar p = new Point { X = 1, Y = 2 }; // okvar p = new Point(1, 2); // okstruct Point
{ public required int X; public required int Y;[SetsRequiredMembers] public Point(int x, int y){X = x;Y = y;}
}
利用?required
?成員,我們可以要求其他開發者在使用我們編寫的類型時必須初始化一些成員,使其能夠正確地使用我們編寫的類型,而不會忘記初始化一些成員。
2. 運算改進
checked 運算符
C# 自古以來就有?checked
?和?unchecked
?概念,分別表示檢查和不檢查算術溢出:
byte x = 100;byte y = 200;unchecked{ byte z = (byte)(x + y); // ok}checked
{ byte z = (byte)(x + y); // error}
在 C# 11 中,引入了?checked
?運算符概念,允許用戶分別實現用于?checked
?和?unchecked
?的運算符:
struct Foo
{ public static Foo operator +(Foo left, Foo right) { ... } public static Foo operator checked +(Foo left, Foo right) { ... }
}var foo1 = new Foo(...);var foo2 = new Foo(...);var foo3 = unchecked(foo1 + foo2); // 調用 operator +var foo4 = checked(foo1 + foo2); // 調用 operator checked +
對于自定義運算符而言,實現?checked
?的版本是可選的,如果沒有實現?checked
?的版本,則都會調用?unchecked
?的版本。
無符號右移運算符
C# 11 新增了?>>>
?表示無符號的右移運算符。此前 C# 的右移運算符?>>
?默認是有符號的右移,即:右移操作保留符號位,因此對于?int
?而言,將會有如下結果:
1 >> 1 = -11 >> 2 = -11 >> 3 = -11 >> 4 = -1// ...
而新的?>>>
?則是無符號右移運算符,使用后將會有如下結果:
1 >>> 1 = 21474836471 >>> 2 = 10737418231 >>> 3 = 5368709111 >>> 4 = 268435455// ...
這省去了我們需要無符號右移時,需要先將數值轉換為無符號數值后進行計算,再轉換回來的麻煩,也能避免不少因此導致的意外錯誤。
移位運算符放開類型限制
C# 11 開始,移位運算符的右操作數不再要求必須是?int
,類型限制和其他運算符一樣被放開了,因此結合上面提到的抽象和虛靜態方法,允許我們聲明泛型的移位運算符了:
interface ICanShift<T> where T : ICanShift<T>
{ abstract static T operator <<(T left, T right); abstract static T operator >>(T left, T right);
}
當然,上述的場景是該限制被放開的主要目的。然而,相信不少讀者讀到這里心中都可能會萌生一個邪惡的想法,沒錯,就是?cin
?和?cout
!雖然這種做法在 C# 中是不推薦的,但該限制被放開后,開發者確實能編寫類似的代碼了:
using static OutStream;using static InStream;int x = 0;
_ = cin >> To(ref x); // 有 _ = 是因為 C# 不允許運算式不經過賦值而單獨成為一條語句_ = cout << "hello" << " " << "world!";public class OutStream{ public static OutStream cout = new(); public static OutStream operator <<(OutStream left, string right){Console.WriteLine(right); return left;}
}public class InStream{ public ref struct Ref<T>{ public ref T Value; public Ref(ref T v) => Value = ref v;} public static Ref<T> To<T>(ref T v) => new (ref v); public static InStream cin = new(); public static InStream operator >>(InStream left, Ref<int> right){ var str = Console.Read(...);right.Value = int.Parse(str);}
}
IntPtr、UIntPtr 支持數值運算
C# 11 中,IntPtr
?和?UIntPtr
?都支持數值運算了,這極大的方便了我們對指針進行操作:
UIntPtr addr = 0x80000048;
IntPtr offset = 0x00000016;
UIntPtr newAddr = addr + (UIntPtr)offset; // 0x8000005E
當然,如同?Int32
?和?int
、Int64
?和?long
?的關系一樣,C# 中同樣存在?IntPtr
?和?UIntPtr
?的等價簡寫,分別為?nint
?和?nuint
,n 表示 native,用來表示這個數值的位數和當前運行環境的內存地址位數相同:
nuint addr = 0x80000048;nint offset = 0x00000016;nuint newAddr = addr + (nuint)offset; // 0x8000005E
3. 模式匹配改進
列表模式匹配
C# 11 中新增了列表模式,允許我們對列表進行匹配。在列表模式中,我們可以利用?[ ]
?來包括我們的模式,用?_
?代指一個元素,用?..
?代表 0 個或多個元素。在?..
?后可以聲明一個變量,用來創建匹配的子列表,其中包含?..
?所匹配的元素。
例如:
var array = new int[] { 1, 2, 3, 4, 5 };if (array is [1, 2, 3, 4, 5]) Console.WriteLine(1); // 1if (array is [1, 2, 3, ..]) Console.WriteLine(2); // 2if (array is [1, _, 3, _, 5]) Console.WriteLine(3); // 3if (array is [.., _, 5]) Console.WriteLine(4); // 4if (array is [1, 2, 3, .. var remaining])
{Console.WriteLine(remaining[0]); // 4Console.WriteLine(remaining.Length); // 2}
當然,和其他的模式一樣,列表模式同樣是支持遞歸的,因此我們可以將列表模式與其他模式組合起來使用:
var array = new string[] { "hello", ",", "world", "~" };if (array is ["hello", _, { Length: 5 }, { Length: 1 } elem, ..])
{Console.WriteLine(elem); // ~}
除了在?if
?中使用模式匹配以外,在?switch
?中也同樣能使用:
var array = new string[] { "hello", ",", "world", "!" };switch (array)
{ case ["hello", _, { Length: 5 }, { Length: 1 } elem, ..]: // ...break; default: // ...break;
}var value = array switch{["hello", _, { Length: 5 }, { Length: 1 } elem, ..] => 1,_ => 2};Console.WriteLine(value); // 1
對 Span<char> 的模式匹配
在 C# 中,Span<char>
?和?ReadOnlySpan<char>
?都可以看作是字符串的切片,因此 C# 11 也為這兩個類型添加了字符串模式匹配的支持。例如:
int Foo(ReadOnlySpan<char> span){ if (span is "abcdefg") return 1; return 2;
}Foo("abcdefg".AsSpan()); // 1Foo("test".AsSpan()); // 2
如此一來,使用?Span<char>
?或者?ReadOnlySpan<char>
?的場景也能夠非常方便地進行字符串匹配了,而不需要利用?SequenceEquals
?或者編寫循環進行處理。
4. 字符串處理改進
原始字符串
C# 中自初便有?@
?用來表示不需要轉義的字符串,但是用戶還是需要將?"
?寫成?""
?才能在字符串中包含引號。C# 11 引入了原始字符串特性,允許用戶利用原始字符串在代碼中插入大量的無需轉移的文本,方便開發者在代碼中以字符串的方式塞入代碼文本等。
原始字符串需要被至少三個?"
?包裹,例如?"""
?和?"""""
?等等,前后的引號數量要相等。另外,原始字符串的縮進由后面引號的位置來確定,例如:
var str = """helloworld""";
此時?str
?是:
hello
world
而如果是下面這樣:
var str = """helloworld
""";
str
?則會成為:
helloworld
這個特性非常有用,例如我們可以非常方便地在代碼中插入 JSON 代碼了:
var json = """{"a": 1,"b": {"c": "hello","d": "world"},"c": [1, 2, 3, 4, 5]}""";
Console.WriteLine(json);/*
{"a": 1,"b": {"c": "hello","d": "world"},"c": [1, 2, 3, 4, 5]
}
*/
UTF-8 字符串
C# 11 引入了 UTF-8 字符串,我們可以用?u8
?后綴來創建一個?ReadOnlySpan<byte>
,其中包含一個 UTF-8 字符串:
var str1 = "hello world"u8; // ReadOnlySpan<byte>var str2 = "hello world"u8.ToArray(); // byte[]
UTF-8 對于 Web 場景而言非常有用,因為在 HTTP 協議中,默認編碼就是 UTF-8,而 .NET 則默認是 UTF-16 編碼,因此在處理 HTTP 協議時,如果沒有 UTF-8 字符串,則會導致大量的 UTF-8 和 UTF-16 字符串的相互轉換,從而影響性能。
有了 UTF-8 字符串后,我們就能非常方便的創建 UTF-8 字面量來使用了,不再需要手動分配一個?byte[]
?然后在里面一個一個硬編碼我們需要的字符。
字符串插值允許換行
C# 11 開始,字符串的插值部分允許換行,因此如下代碼變得可能:
var str = $"hello, the leader is {group.GetLeader().GetName()}.";
這樣一來,當插值的部分代碼很長時,我們就能方便的對代碼進行格式化,而不需要將所有代碼擠在一行。
5. 其他改進
struct 自動初始化
C# 11 開始,struct
?不再強制構造函數必須要初始化所有的字段,對于沒有初始化的字段,編譯器會自動做零初始化:
struct Point
{ public int X; public int Y; public Point(int x){X = x; // Y 自動初始化為 0}
}
支持對其他參數名進行 nameof
C# 11 允許了開發者在參數中對其他參數名進行?nameof
,例如在使用?CallerArgumentExpression
?這一 attribute 時,此前我們需要直接硬編碼相應參數名的字符串,而現在只需要使用?nameof
?即可:
void Assert(bool condition, [CallerArgumentExpression(nameof(condition))] string expression = "")
{ // ...}
這將允許我們在進行代碼重構時,修改參數名?condition
?時自動修改?nameof
?里面的內容,方便的同時減少出錯。
自動緩存靜態方法的委托
C# 11 開始,從靜態方法創建的委托將會被自動緩存,例如:
void Foo(){Call(Console.WriteLine);
}void Call(Action action){action();
}
此前,每執行一次?Foo
,就會從?Console.WriteLine
?這一靜態方法創建一個新的委托,因此如果大量執行?Foo
,則會導致大量的委托被重復創建,導致大量的內存被分配,效率極其低下。在 C# 11 開始,將會自動緩存靜態方法的委托,因此無論?Foo
?被執行多少次,Console.WriteLine
?的委托只會被創建一次,節省了內存的同時大幅提升了性能。
總結
從 C# 8 開始,C# 團隊就在不斷完善語言的類型系統,在確保靜態類型安全的同時大幅提升語言表達力,從而讓類型系統成為編寫程序的得力助手,而不是礙手礙腳的限制。
本次更新還完善了數值運算相關的內容,使得開發者利用 C# 編寫數值計算方法時更加得心應手。
另外,模式匹配的探索旅程也終于接近尾聲,引入列表模式之后,剩下的就只有字典模式和活動模式了,模式匹配是一個非常強大的工具,允許我們像對字符串使用正則表達式那樣非常方便地對數據進行匹配。
總的來說 C# 11 的新特性和改進內容非常多,每一項內容都對 C# 的使用體驗有著不小的提升。在未來的 C# 中還計劃著角色和擴展等更加令人激動的新特性,讓我們拭目以待。