前言 .NET 7 的開發還剩下一個多月就要進入 RC,C# 11 的新特性和改進也即將敲定。在這個時間點上,不少新特性都已經實現完畢併合併入主分支 C# 11 包含的新特性和改進非常多,類型系統相比之前也有了很大的增強,在確保靜態類型安全的同時大幅提升了語言表達力。 那麼本文就按照方向從 5 個大 ...
前言
.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(); // error
CallFoo<Bar>(); // ok
struct Bar : IFoo
{
// 顯式實現介面方法
static void IFoo.Foo1()
{
return 7;
}
}
此外,介面可以基於另一個介面擴展,因此對於抽象和虛靜態方法而言,我們可以利用這個特性在介面上實現多態。
CallFoo<Bar1>(); // 5 5
CallFoo<Bar2>(); // 6 4
CallFoo<Bar3>(); // 3 7
CallFooFromIA<Bar4>(); // 1
CallFooFromIB<Bar4>(); // 2
void 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)); // 35
Console.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")] // error
void 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); // 2
ref 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.cs
file class Foo
{
// ...
}
file struct Bar
{
// ...
}
如此一來,如果我們在與 Foo
和 Bar
的不同文件中使用這兩個類型的話,編譯器就會報錯:
// A.cs
var foo = new Foo(); // ok
var bar = new Bar(); // ok
// B.cs
var foo = new Foo(); // error
var bar = new Bar(); // error
這個特性將可訪問性的粒度精確到了文件,對於代碼生成器等一些要放在同一個項目中,但是又不想被其他人接觸到的代碼而言將會特別有用。
required 成員
C# 11 新增了 required
成員,標記有 required
的成員將會被要求使用時必須要進行初始化,例如:
var foo = new Foo(); // error
var foo = new Foo { X = 1 }; // ok
struct Foo
{
public required int X;
}
開發者還可以利用 SetsRequiredMembers
這個 attribute 來對方法進行標註,表示這個方法會初始化 required
成員,因此用戶在使用時可以不需要再進行初始化:
using System.Diagnostics.CodeAnalysis;
var p = new Point(); // error
var p = new Point { X = 1, Y = 2 }; // ok
var p = new Point(1, 2); // ok
struct 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 = -1
1 >> 2 = -1
1 >> 3 = -1
1 >> 4 = -1
// ...
而新的 >>>
則是無符號右移運算符,使用後將會有如下結果:
1 >>> 1 = 2147483647
1 >>> 2 = 1073741823
1 >>> 3 = 536870911
1 >>> 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); // 1
if (array is [1, 2, 3, ..]) Console.WriteLine(2); // 2
if (array is [1, _, 3, _, 5]) Console.WriteLine(3); // 3
if (array is [.., _, 5]) Console.WriteLine(4); // 4
if (array is [1, 2, 3, .. var remaining])
{
Console.WriteLine(remaining[0]); // 4
Console.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()); // 1
Foo("test".AsSpan()); // 2
如此一來,使用 Span<char>
或者 ReadOnlySpan<char>
的場景也能夠非常方便地進行字元串匹配了,而不需要利用 SequenceEquals
或者編寫迴圈進行處理。
4. 字元串處理改進
原始字元串
C# 中自初便有 @
用來表示不需要轉義的字元串,但是用戶還是需要將 "
寫成 ""
才能在字元串中包含引號。C# 11 引入了原始字元串特性,允許用戶利用原始字元串在代碼中插入大量的無需轉移的文本,方便開發者在代碼中以字元串的方式塞入代碼文本等。
原始字元串需要被至少三個 "
包裹,例如 """
和 """""
等等,前後的引號數量要相等。另外,原始字元串的縮進由後面引號的位置來確定,例如:
var str = """
hello
world
""";
此時 str
是:
hello
world
而如果是下麵這樣:
var str = """
hello
world
""";
str
則會成為:
hello
world
這個特性非常有用,例如我們可以非常方便地在代碼中插入 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# 中還計划著角色和擴展等更加令人激動的新特性,讓我們拭目以待。