前言
開頭防槓:.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 的好處其實有很多,例如你無需重寫 GetHashCode
和 Equals
之類的方法了。
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 版本,這個版本包含很多改進,包括強型別的程式碼構建器,以及增量編譯的支援等等。