C# 11 的新特性和改進前瞻

2022-07-10 06:00:31

前言

.NET 7 的開發還剩下一個多月就要進入 RC,C# 11 的新特性和改進也即將敲定。在這個時間點上,不少新特性都已經實現完畢併合併入主分支

C# 11 包含的新特性和改進非常多,型別系統相比之前也有了很大的增強,在確保靜態型別安全的同時大幅提升了語言表達力。

那麼本文就按照方向從 5 個大類來進行介紹,一起來提前看看 C# 11 的新特性和改進都有什麼。

1. 型別系統的改進

抽象和虛靜態方法

C# 11 開始將 abstractvirtual 引入到靜態方法中,允許開發者在介面中編寫抽象和虛靜態方法。

介面與抽象類不同,介面用來抽象行為,通過不同型別實現介面來實現多型;而抽象類則擁有自己的狀態,通過各子型別繼承父類別型來實現多型。這是兩種不同的正規化。

在 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 的生命週期,標註了 scopedref 表示這個參照的生命週期不會超過當前函數的生命週期:

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
{
    // ...
}

如此一來,如果我們在與 FooBar 的不同檔案中使用這兩個型別的話,編譯器就會報錯:

// 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# 自古以來就有 checkedunchecked 概念,分別表示檢查和不檢查算術溢位:

byte x = 100;

byte y = 200;
unchecked
{
    byte z = (byte)(x + y); // ok
}

checked
{
    byte z = (byte)(x + y); // error
}

在 C# 11 中,引入了 checked 運運算元概念,允許使用者分別實現用於 checkedunchecked 的運運算元:

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);
}

當然,上述的場景是該限制被放開的主要目的。然而,相信不少讀者讀到這裡心中都可能會萌生一個邪惡的想法,沒錯,就是 cincout!雖然這種做法在 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 中,IntPtrUIntPtr 都支援數值運算了,這極大的方便了我們對指標進行操作:

UIntPtr addr = 0x80000048;
IntPtr offset = 0x00000016;
UIntPtr newAddr = addr + (UIntPtr)offset; // 0x8000005E

當然,如同 Int32intInt64long 的關係一樣,C# 中同樣存在 IntPtrUIntPtr 的等價簡寫,分別為 nintnuint,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# 中還計劃著角色和擴充套件等更加令人激動的新特性,讓我們拭目以待。