C# 9.0 向 C# 語言添加了以下功能和增強功能:
- 記錄
- 僅限 Init 的資源庫
- 頂級語句
- 模式匹配增強功能
- 本機大小的整數
- 函數指針
- 禁止發出 localsinit 標志
- 目標類型的新表達式
- 靜態匿名函數
- 目標類型的條件表達式
- 協變返回類型
- 擴展
GetEnumerator
支持foreach
循環 - Lambda 棄元參數
- 本地函數的屬性
- 模塊初始值設定項
- 分部方法的新功能
.NET 5 支持 C# 9.0。 有關詳細信息,請參閱 C# 語言版本控制。
C# 9.0 引入了記錄類型,這是一種引用類型,它提供合成方法來提供值語義,從而實現相等性。 默認情況下,記錄是不可變的。
使用記錄類型可在 .NET 中輕松創建不可變的引用類型。 以前,.NET 類型主要分為引用類型(包括類和匿名類型)和值類型(包括結構和元組)。 雖然建議使用不可變的值類型,但可變的值類型通常不會引入錯誤。 值類型變量可保存值,因此在將值類型傳遞給方法時,會對原始數據的副本進行更改。
不可變的引用類型也有許多優點。 這些優點在使用共享數據的並發程序中更為明顯。 遺憾的是,C# 強制編寫大量額外的代碼來創建不可變的引用類型。 記錄為不可變的引用類型提供類型聲明,該引用類型使用值語義實現相等性。 如果用於實現相等性的合成方法的屬性和哈希代碼的屬性都相等,則認為兩條記錄相等。 請考慮以下定義:
public record Person { public string LastName { get; } public string FirstName { get; } public Person(string first, string last) => (FirstName, LastName) = (first, last); }
記錄定義會創建一個包含兩個只讀屬性(FirstName
和 LastName
)的 Person
類型。 Person
類型是引用類型。 如果查看 IL,它就是一個類。 它是不可變的,因為在創建它后,無法修改任何屬性。 定義記錄類型時,編譯器會合成其他幾種方法:
- 基於值的相等性比較方法
- 替代 GetHashCode()
- 復制和克隆成員
PrintMembers
和 ToString()
記錄支持繼承。 可聲明派生自 Person
的新記錄,如下所示:
public record Teacher : Person { public string Subject { get; } public Teacher(string first, string last, string sub) : base(first, last) => Subject = sub; }
還可密封記錄以防止進一步派生:
public sealed record Student : Person { public int Level { get; } public Student(string first, string last, int level) : base(first, last) => Level = level; }
編譯器會合成上述方法的不同版本。 方法簽名取決於記錄類型是否密封以及直接基類是否為對象。 記錄應具有以下功能:
- 相等性是基於值的,包括檢查類型是否匹配。 例如,即使兩條記錄的名稱相同,
Student
也不能等於Person
。 - 記錄具有為你生成的一致的字符串表示形式。
- 記錄支持副本構造。 正確的副本構造必須包括繼承層次結構和開發人員添加的屬性。
- 可通過修改復制記錄。 這些復制和修改操作支持非破壞性轉變。
除了熟悉的 Equals
重載、operator ==
和 operator !=
外,編譯器還會合成新的 EqualityContract
屬性。 該屬性返回與記錄類型匹配的 Type
對象。 如果基類型為 object
,則屬性為 virtual
。 如果基類型是其他記錄類型,則屬性為 override
。 如果記錄類型為 sealed
,則屬性為 sealed
。 合成的 GetHashCode
使用基類型和記錄類型中聲明的所有屬性和字段中的 GetHashCode
。 這些合成方法在整個繼承層次結構中強制執行基於值的相等性。 這意味着,絕不會將 Student
視為與同名的 Person
相等。 兩條記錄的類型必須匹配,而且記錄類型之間共享的所有屬性也必須相等。
記錄還具有合成的構造函數和用於創建副本的“克隆”方法。 合成的構造函數具有記錄類型的一個參數。 該函數會為記錄的所有屬性生成具有相同值的新記錄。 如果記錄是密封的,則此構造函數是專用函數;否則它將受到保護。 合成的“克隆”方法支持用於記錄層次結構的副本構造。 “克隆”一詞用引號引起來,因為實際名稱是編譯器生成的。 無法在記錄類型中創建名為 Clone
的方法。 合成的“克隆”方法返回使用虛擬調度復制的記錄類型。 編譯器根據 record
上的訪問修飾符為“克隆”方法添加不同的修飾符:
- 如果記錄類型為
abstract
,則“克隆”方法也為abstract
。 如果基類型不是object
,則方法也是override
。 - 當基類型為
object
時,對於不是abstract
的記錄類型:- 如果記錄為
sealed
,則不向“克隆”方法添加其他修飾符(這意味着它不是virtual
)。 - 如果記錄不是
sealed
,則“克隆”方法為virtual
。
- 如果記錄為
- 當基類型不是
object
時,對於不是abstract
的記錄類型:- 如果記錄是
sealed
,則“克隆”方法也是sealed
。 - 如果記錄不是
sealed
,則“克隆”方法為override
。
- 如果記錄是
所有這些規則的結果都是,跨記錄類型的任何層次結構一致地實現了相等性。 如果兩條記錄的屬性相等且類型相同,則它們彼此相等,如下例所示:
var person = new Person("Bill", "Wagner"); var student = new Student("Bill", "Wagner", 11); Console.WriteLine(student == person); // false
編譯器合成了兩種支持打印輸出的方法:ToString() 替代和 PrintMembers
。 PrintMembers
采用 System.Text.StringBuilder 作為其參數。 它對記錄類型中的所有屬性追加一個用逗號分隔的屬性名稱和值的列表。 PrintMembers
會調用派生自其他記錄的任何記錄的基本實現。 ToString() 替代會返回由 PrintMembers
生成的字符串,並將其括在 {
和 }
內。 例如,Student
的 ToString() 方法返回一個 string
,類似於以下代碼:
"Student { LastName = Wagner, FirstName = Bill, Level = 11 }"
截至目前顯示的示例都使用傳統語法聲明屬性。 還有一種更簡潔的格式,稱為“位置記錄”。 下面是先前定義為位置記錄的 3 種記錄類型:
public record Person(string FirstName, string LastName); public record Teacher(string FirstName, string LastName, string Subject) : Person(FirstName, LastName); public sealed record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName);
這些聲明創建的功能與早期版本相同(以下部分介紹了幾項額外的功能)。 這些聲明以分號而不是方括號結尾,因為這些記錄沒有添加其他方法。 可添加正文,還可包括其他任何方法:
public record Pet(string Name) { public void ShredTheFurniture() => Console.WriteLine("Shredding furniture"); } public record Dog(string Name) : Pet(Name) { public void WagTail() => Console.WriteLine("It's tail wagging time"); public override string ToString() { StringBuilder s = new(); base.PrintMembers(s); return $"{s.ToString()} is a dog"; } }
編譯器為位置記錄生成 Deconstruct
方法。 Deconstruct
方法的參數與記錄類型中所有公共屬性的名稱匹配。 Deconstruct
方法可用於將記錄析構為其組件屬性:
var person = new Person("Bill", "Wagner"); var (first, last) = person; Console.WriteLine(first); Console.WriteLine(last);
最后,記錄支持 with 表達式。 with 表達式指示編譯器創建記錄的副本,但修改了指定的屬性:
Person brother = person with { FirstName = "Paul" };
上述行創建新的 Person
記錄,其中 LastName
屬性是 person
的副本,FirstName
為“Paul”。 可在 with 表達式中設置任意數量的屬性。 你可編寫除“克隆”方法以外的任何合成成員。 如果記錄類型的方法與任何合成方法的簽名匹配,則編譯器不會合成該方法。 較早的 Dog
記錄示例包含手動編碼的 ToString() 方法作為示例。
僅限 init 的資源庫提供一致的語法來初始化對象的成員。 屬性初始值設定項可明確哪個值正在設置哪個屬性。 缺點是這些屬性必須是可設置的。 從 C# 9.0 開始,可為屬性和索引器創建 init
訪問器,而不是 set
訪問器。 調用方可使用屬性初始化表達式語法在創建表達式中設置這些值,但構造完成后,這些屬性將變為只讀。 僅限 init 的資源庫提供了一個窗口用來更改狀態。 構造階段結束時,該窗口關閉。 在完成所有初始化(包括屬性初始化表達式和 with 表達式)之后,構造階段實際上就結束了。
上述位置記錄示例演示了如何使用僅限 init 的資源庫通過 with 表達式來設置屬性。 可在編寫的任何類型中聲明僅限 init 的資源庫。 例如,以下結構定義了天氣觀察結構:
public struct WeatherObservation { public DateTime RecordedAt { get; init; } public decimal TemperatureInCelsius { get; init; } public decimal PressureInMillibars { get; init; } public override string ToString() => $"At {RecordedAt:h:mm tt} on {RecordedAt:M/d/yyyy}: " + $"Temp = {TemperatureInCelsius}, with {PressureInMillibars} pressure"; }
調用方可使用屬性初始化表達式語法來設置值,同時仍保留不變性:
var now = new WeatherObservation { RecordedAt = DateTime.Now, TemperatureInCelsius = 20, PressureInMillibars = 998.0m };
但在初始化后更改觀察值是錯誤的,它會在初始化之外分配給僅限 init 的屬性:
// Error! CS8852. now.TemperatureInCelsius = 18;
對於從派生類設置基類屬性,僅限 init 的資源庫很有用。 它們還可通過基類中的幫助程序來設置派生屬性。 位置記錄使用僅限 init 的資源庫聲明屬性。 這些設置器可在 with 表達式中使用。 可為定義的任何 class
或 struct
聲明僅限 init 的資源庫。
頂級語句從許多應用程序中刪除了不必要的流程。 請考慮規范的“Hello World!” 程序:
using System; namespace HelloWorld { class Program { static void Main(string[] args) { Console.WriteLine("Hello World!"); } } }
只有一行代碼執行所有操作。 借助頂級語句,可使用 using
語句和執行操作的一行替換所有樣本:
using System; Console.WriteLine("Hello World!");
如果需要單行程序,可刪除 using
指令,並使用完全限定的類型名稱:
System.Console.WriteLine("Hello World!");
應用程序中只有一個文件可使用頂級語句。 如果編譯器在多個源文件中找到頂級語句,則是錯誤的。 如果將頂級語句與聲明的程序入口點方法(通常為 Main
方法)結合使用,也會出現錯誤。 從某種意義上講,可認為一個文件包含通常位於 Program
類的 Main
方法中的語句。
此功能最常見的用途之一是創建材料。 C# 初級開發人員可以用一兩行代碼 編寫規范的“Hello World!”。 不需要額外的工作。 不過,經驗豐富的開發人員還會發現此功能的許多用途。 頂級語句可提供類似腳本的試驗體驗,這與 Jupyter 筆記本提供的很類似。 頂級語句非常適合小型控制台程序和實用程序。 Azure 函數是頂級語句的理想用例。
最重要的是,頂層語句不會限制應用程序的范圍或復雜程度。 這些語句可訪問或使用任何 .NET 類。 它們也不會限制你對命令行參數或返回值的使用。 頂級語句可訪問名為 args 的字符串數組。 如果頂級語句返回整數值,則該值將成為來自合成 Main
方法的整數返回代碼。 頂級語句可能包含異步表達式。 在這種情況下,合成入口點將返回 Task
或 Task<int>
。
C# 9 包括新的模式匹配改進:
- 類型模式要求在變量是一種類型時匹配
- 帶圓括號的模式強制或強調模式組合的優先級
- 聯合
and
模式要求兩個模式都匹配 - 析取
or
模式要求任一模式匹配 - 求反
not
模式要求模式不匹配 - 關系模式要求輸入小於、大於、小於等於或大於等於給定常數。
這些模式豐富了模式的語法。 請考慮下列示例:
public static bool IsLetter(this char c) => c is >= 'a' and <= 'z' or >= 'A' and <= 'Z';
還可使用可選的括號來明確 and
的優先級高於 or
:
public static bool IsLetterOrSeparator(this char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ',';
最常見的用途之一是用於 NULL 檢查的新語法:
if (e is not null) { // ... }
這些模式中的任何一種都可在允許使用模式的任何上下文中使用:is
模式表達式、switch
表達式、嵌套模式以及 switch
語句的 case
標簽的模式。
3 項新功能改進了對需要高性能的本機互操作性和低級別庫的支持:本機大小的整數、函數指針和省略 localsinit
標志。
本機大小的整數 nint
和 nuint
是整數類型。 它們由基礎類型 System.IntPtr 和 System.UIntPtr 表示。 編譯器將這些類型的其他轉換和操作作為本機整數公開。 本機大小的整數定義 MaxValue
或 MinValue
的屬性。 這些值不能表示為編譯時編譯時,因為它取決於目標計算機上整數的本機大小。 這些值在運行時是只讀的。 可在以下范圍內對 nint
使用常量值:[int.MinValue
.. int.MaxValue
]. 可在以下范圍內對 nuint
使用常量值:[uint.MinValue
.. uint.MaxValue
]. 編譯器使用 System.Int32 和 System.UInt32 類型為所有一元和二元運算符執行常量折疊。 如果結果不滿足 32 位,操作將在運行時執行,且不會被視為常量。 在廣泛使用整數數學且需要盡可能快的性能的情況下,本機大小的整數可提高性能。
函數指針提供了一種簡單的語法來訪問 IL 操作碼 ldftn
和 calli
。 可使用新的 delegate*
語法聲明函數指針。 delegate*
類型是指針類型。 調用 delegate*
類型會使用 calli
,而不是使用在 Invoke()
方法上采用 callvirt
的委托。 從語法上講,調用是相同的。 函數指針調用使用 managed
調用約定。 在 delegate*
語法后面添加 unmanaged
關鍵字,以聲明想要 unmanaged
調用約定。 可使用 delegate*
聲明中的屬性來指定其他調用約定。
最后,可添加 System.Runtime.CompilerServices.SkipLocalsInitAttribute 來指示編譯器不要發出 localsinit
標志。 此標志指示 CLR 對所有局部變量進行零初始化。 從 1.0 開始,localsinit
標志一直是 C# 的默認行為。 但在某些情況下,額外的零初始化可能會對性能產生可衡量的影響, 特別是在使用 stackalloc
時。 在這些情況下,可添加 SkipLocalsInitAttribute。 可將它添加到單個方法或屬性中,或者添加到 class
、struct
、interface
,甚至是模塊中。 此屬性不會影響 abstract
方法,它會影響為實現生成的代碼。
這些功能在某些情況下可提高性能。 僅應在采用前后對這些功能進行仔細的基准測試之后使用它們。 涉及本機大小整數的代碼必須在使用不同整數大小的多個目標平台上進行測試。 其他功能需要不安全的代碼。
還有其他很多功能有助於更高效地編寫代碼。 在 C# 9.0 中,已知創建對象的類型時,可在 new
表達式中省略該類型。 最常見的用法是在字段聲明中:
private List<WeatherObservation> _observations = new();
當需要創建新對象作為參數傳遞給方法時,也可使用目標類型 new
。 請考慮使用以下簽名的 ForecastFor()
方法:
public WeatherForecast ForecastFor(DateTime forecastDate, WeatherForecastOptions options)
可按如下所示調用該方法:
var forecast = station.ForecastFor(DateTime.Now.AddDays(2), new());
此功能還有一個不錯的用途是,將其與僅限 init 的屬性組合使用來初始化新對象:
WeatherStation station = new() { Location = "Seattle, WA" };
可使用 return new();
語句返回由默認構造函數創建的實例。
類似的功能可改進條件表達式的目標類型解析。 進行此更改后,兩個表達式無需從一個隱式轉換到另一個,而是都可隱式轉換為目標類型。 你可能不會注意到此更改。 你會注意到,某些以前需要強制轉換或無法編譯的條件表達式現在可以正常工作。
從 C# 9.0 開始,可將 static
修飾符添加到 Lambda 表達式或匿名方法。 靜態 Lambda 表達式類似於 static
局部函數:靜態 Lambda 或匿名方法無法捕獲局部變量或實例狀態。 static
修飾符可防止意外捕獲其他變量。
協變返回類型為替代函數的返回類型提供了靈活性。 替代的虛函數可返回從基類方法中聲明的返回類型派生的類型。 這對於記錄和其他支持虛擬克隆或工廠方法的類型很有用。
此外,foreach
循環將識別並使用擴展方法 GetEnumerator
,否則將滿足 foreach
模式。 此更改意味着 foreach
與其他基於模式的構造(例如異步模式和基於模式的析構)一致。 實際上,此更改意味着可以為任何類型添加 foreach
支持。 在設計中,應將其限制為在枚舉對象有意義時使用。
接下來,可使用棄元作為 Lambda 表達式的參數。 這樣可免於為參數命名,並且編譯器也可避免使用它。 可將 _
用於任何參數。 有關詳細信息,請參閱 Lambda 表達式一文中的 Lambda 表達式的輸入參數一節。
最后,現在可將屬性應用於本地函數。 例如,可將可為空的屬性注釋應用於本地函數。
最后兩項功能支持 C# 代碼生成器。 C# 代碼生成器是可編寫的組件,類似於 roslyn 分析器或代碼修補程序。 區別在於,代碼生成器會在編譯過程中分析代碼並編寫新的源代碼文件。 典型的代碼生成器會在代碼中搜索屬性或其他約定。
代碼生成器使用 Roslyn 分析 API 讀取屬性或其他代碼元素。 通過該信息,它將新代碼添加到編譯中。 源生成器只能添加代碼,不能修改編譯中的任何現有代碼。
為代碼生成器添加的兩項功能是分部方法語法和模塊初始化表達式的擴展。 首先是對分部方法的更改。 在 C# 9.0 之前,分部方法為 private
,但不能指定訪問修飾符、不能返回 void
,也不能具有 out
參數。 這些限制意味着,如果未提供任何方法實現,編譯器會刪除對分部方法的所有調用。 C# 9.0 消除了這些限制,但要求分部方法聲明必須具有實現。 代碼生成器可提供這種實現。 為了避免引入中斷性變更,編譯器會考慮沒有訪問修飾符的任何分部方法,以遵循舊規則。 如果分部方法包括 private
訪問修飾符,則由新規則控制該分部方法。
代碼生成器的第二項新功能是模塊初始化表達式。 模塊初始化表達式是附加了 ModuleInitializerAttribute 屬性的方法。 程序集加載時,運行時將調用這些方法。 模塊初始化表達式方法:
- 必須是靜態的
- 必須沒有參數
- 必須返回 void
- 不能是泛型方法
- 不能包含在泛型類中
- 必須能夠從包含模塊訪問
最后一個要點實際上意味着該方法及其包含類必須是內部的或公共的。 方法不能為本地函數。
參考微軟官方文檔:https://docs.microsoft.com/zh-cn/dotnet/csharp/whats-new/csharp-9#record-types