C# 10 完整特性介紹

前言

開頭防杠:.NET 的基礎庫、語言、運行時團隊從來都是相互獨立各自更新的,.NET 6 在基礎庫、運行時上同樣做了非常多的改進,不過本文僅僅介紹語言部分。

距離上次介紹 C# 10 的特性已經有一段時間了,伴隨著 .NET 6 的開發進入尾聲,C# 10 最終的特性也終於敲定了。總的來說 C# 10 的更新內容很多,並且對類型系統做了不小的改動,解決了非常多現有的痛點。

從 C# 10 可以看到一個消息,那就是 C# 語言團隊開始主要著重於改進類型系統和功能性方面的東西,而不是像以前那樣熱衷於各種語法糖了。C# 10 只是這個旅程的開頭,後面的 C# 11 、12 將會有更多關於類型系統的改進,使其擁有強如 Haskell 、Rust 的表達能力,不僅能提供從頭到尾的跨程式集的靜態類型支援,還能做到像動態類型語言那樣的靈活。邏輯程式碼是類型的證明,只有類型系統強大了,程式碼編寫起來才能更順暢、更不容易出錯。

record struct

首先自然是 record struct,解決了 record 只能給 class 而不能給 struct 用的問題:

record struct Point(int X, int Y);

用 record 定義 struct 的好處其實有很多,例如你無需重寫 GetHashCodeEquals 之類的方法了。

sealed record ToString 方法

之前 record 的 ToString 是不能修飾為 sealed 的,因此如果你繼承了一個 record,相應的 ToString 行為也會被改變,因此這是個虛方法。

但是現在你可以把 record 里的 ToString 方法標記成 sealed,這樣你的 ToString 方法就不會被重寫了。

struct 無參構造函數

一直以來 struct 不支援無參構造函數,現在支援了:

struct Foo
{
    public int X;
    public Foo() { X = 1; }
}

但是使用的時候就要注意了,因為無參構造函數的存在使得 new struct()default(struct) 的語義不一樣了,例如 new Foo().X == default(Foo).X 在上面這個例子中將會得出 false

匿名對象的 with

可以用 with 來根據已有的匿名對象創建新的匿名對象了:

var x = new { A = 1, B = 2 };
var y = x with { A = 3 };

這裡 y.A 將會是 3 。

全局的 using

利用全局 using 可以給整個項目啟用 usings,不再需要每個文件都寫一份。比如你可以創建一個 Import.cs,然后里面寫:

using System;
using i32 = System.Int32;

然後你整個項目都無需再 using System,並且可以用 i32 了。

文件範圍的 namespace

這個比較簡單,以前寫 namespace 還得帶一層大括弧,以後如果一個文件里只有一個 namespace 的話,那直接在最上面這樣寫就行了:

namespace MyNamespace;

常量字元串插值

你可以給 const string 使用字元串插值了,非常方便:

const string x = "hello";
const string y = $"{x}, world!";

lambda 改進

這個改進可以說是非常大,我分多點介紹。

1. 支援 attributes

lambda 可以帶 attribute 了:

f = [Foo] (x) => x; // 給 lambda 設置
f = [return: Foo] (x) => x; // 給 lambda 返回值設置
f = ([Foo] x) => x; // 給 lambda 參數設置

2. 支援指定返回值類型

此前 C# 的 lambda 返回值類型靠推導,C# 10 開始允許在參數列表最前面顯示指定 lambda 類型了:

f = int () => 4;

3. 支援 ref 、in 、out 等修飾

f = ref int (ref int x) => ref x; // 返回一個參數的引用

4. 頭等函數

函數可以隱式轉換到 delegate,於是函數上升至頭等函數:

void Foo() { Console.WriteLine("hello"); }
var x = Foo;
x(); // hello

5. 自然委託類型

lambda 現在會自動創建自然委託類型,於是不再需要寫出類型了。

var f = () => 1; // Func<int>
var g = string (int x, string y) => $"{y}{x}"; // Func<int, string, string>
var h = "test".GetHashCode; // Func<int>

CallerArgumentExpression

現在,CallerArgumentExpression 這個 attribute 終於有用了。藉助這個 attribute,編譯器會自動填充調用參數的表達式字元串,例如:

void Foo(int value, [CallerArgumentExpression("value")] string? expression = null)
{
    Console.WriteLine(expression + " = " + value);
}

當你調用 Foo(4 + 5) 時,會輸出 4 + 5 = 9。這對測試框架極其有用,因為你可以輸出 assert 的原表達式了:

static void Assert(bool value, [CallerArgumentExpression("value")] string? expr = null)
{
    if (!value) throw new AssertFailureException(expr);
}

tuple 支援混合定義和使用

比如:

int y = 0;
(var x, y, var z) = (1, 2, 3);

於是 y 就變成 2 了,同時還創建了兩個變數 x 和 z,分別是 1 和 3 。

介面支援抽象靜態方法

這個特性將會在 .NET 6 作為 preview 特性放出,意味著默認是不啟用的,需要設置 <LangVersion>preview</LangVersion><EnablePreviewFeatures>true</EnablePreviewFeatures>,然後引入一個官方的 nuget 包 System.Runtime.Experimental 來啟用。

然後介面就可以聲明抽象靜態成員了,.NET 的類型系統正式具備虛靜態方法分發能力。

例如,你想定義一個可加而且有零的介面 IMonoid<T>

interface IMonoid<T> where T : IMonoid<T>
{
    abstract static T Zero { get; }
    abstract static T operator+(T l, T r);
}

然後可以對其進行實現,例如這裡的 MyInt:

public class MyInt : IMonoid<MyInt>
{
    public MyInt(int val) { Value = val; }

    public static MyInt Zero { get; } = new MyInt(0);
    public static MyInt operator+(MyInt l, MyInt r) => new MyInt(l.Value + r.Value);

    public int Value { get; }
}

然後就能寫出一個方法對 IMoniod<T> 進行求和了,這裡為了方便寫成擴展方法:

public static class IMonoidExtensions
{
    public static T Sum<T>(this IEnumerable<T> t) where T : IMonoid<T>
    {
        var result = T.Zero;
        foreach (var i in t) result += i;
        return result;
    }
}

最後調用:

List<MyInt> list = new() { new(1), new(2), new(3) };
Console.WriteLine(list.Sum().Value); // 6

你可能會問為什麼要引入一個 System.Runtime.Experimental,因為這個包裡面包含了 .NET 基礎類型的改進:給所有的基礎類型都實現了相應的介面,比如給數值類型都實現了 INumber<T>,給可以加的東西都實現了 IAdditionOperators<TLeft, TRight, TResult> 等等,用起來將會非常方便,比如你想寫一個函數,這個函數用來把能相加的東西加起來:

T Add<T>(T left, T right) where T : IAdditionOperators<T, T, T>
{
    return left + right;
}

就搞定了。

介面的靜態抽象方法支援和未來 C# 將會加入的 shape 特性是相輔相成的,屆時 C# 將利用 interface 和 shape 支援 Haskell 的 class、Rust 的 trait 那樣的 type classes,將類型系統上升到一個新的層次。

泛型 attribute

是的你沒有看錯,C# 的 attributes 支援泛型了:

class TestAttribute<T> : Attribute
{
    public T Data { get; }
    public TestAttribute(T data) { Data = data; }
}

然後你就能這麼用了:

[Test<int>(3)]
[Test<float>(4.5f)]
[Test<string>("hello")]

允許在方法上指定 AsyncMethodBuilder

C# 10 將允許方法上使用 [AsyncMethodBuilder(...)] 來使用你自己實現的 async method builder,代替自帶的 Task 或者 ValueTask 的非同步方法構造器。這也有助於你自己實現零開銷的非同步方法。

line 指示器支援行列和範圍

以前 #line 只能用來指定一個文件中的某一行,現在可以指定行列和範圍了,這對寫編譯器和程式碼生成器的人非常有用:

#line (startLine, startChar) - (endLine, endChar) charOffset "fileName"

// 比如 #line (1, 1) - (2, 2) 3 "test.cs"

嵌套屬性模式匹配改進

以前在匹配嵌套屬性的時候需要這麼寫:

if (a is { X: { Y: { Z: 4 } } }) { ... }

現在只需要簡單的:

if (a is { X.Y.Z: 4 }) { ... }

就可以了。

改進的字元串插值

以前 C# 的字元串插值是很粗暴的 string.Format,並且對於值類型參數來說會直接裝箱,對於多個參數而言還會因此而分配一個數組(比如 string.Format("{} {}", a, b) 其實是 string.Format("{} {}", new object [] { (object)a, (object)b })),這很影響性能。現在字元串插值被改進了:

var x = 1;
Console.WriteLine($"hello, {x}");

會被編譯成:

int x = 1;
DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(7, 1);
defaultInterpolatedStringHandler.AppendLiteral("hello, ");
defaultInterpolatedStringHandler.AppendFormatted(x);
Console.WriteLine(defaultInterpolatedStringHandler.ToStringAndClear());

上面這個 DefaultInterpolatedStringHandler 也可以藉助 InterpolatedStringHandler 這個 attribute 替換成你自己實現的插值處理器,來決定要怎麼進行插值。藉助這些可以實現接近零開銷的字元串插值。

Source Generator v2

程式碼生成器在 C# 10 將會迎來 v2 版本,這個版本包含很多改進,包括強類型的程式碼構建器,以及增量編譯的支援等等。