C# 9.0 正式發布了(C# 9.0 on the record)
翻譯自 Mads Torgersen 2020年11月10日的博文《C# 9.0 on the record》 [1],Mads Torgersen 是微軟 C# 語言的首席設計師,也是微軟 .NET 團隊的項目群經理。
C# 9.0 正式發布
正式宣布:C# 9.0 發布了! 早在5月,我就寫了一篇關於 C# 9.0 計劃的博文 [2],以下是該帖子的更新版本,以匹配我們最終實際交付的產品。
對於 C# 的每一個新版本,我們都在努力讓常見編碼場景的實現變得更加清晰和簡單,C# 9.0 也不例外。這次特別關注的是支援數據模型的簡潔和不可變表示。
一、僅初始化屬性(Init-only properties)
對象初始化器非常棒。它們為類型的客戶端提供了一種非常靈活和易讀的格式來創建對象,並且特別適合於嵌套對象的創建,讓你可以一次性創建整個對象樹。這裡有一個簡單的例子:
var person = new Person { FirstName = "Mads", LastName = "Torgersen" };
對象初始化器還使類型作者不必編寫大量的構造函數 —— 他們所要做的就是編寫一些屬性!
public class Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
目前最大的限制是屬性必須是可變的(即可寫的),對象初始化器才能工作:它們首先調用對象的構造函數(本例中是默認的無參數構造函數),然後賦值給屬性 setter
。
僅初始化(init-only)屬性解決了這個問題!它引入了一個 init
訪問器,它是 set
訪問器的變體,只能在對象初始化時調用:
public class Person
{
public string? FirstName { get; init; }
public string? LastName { get; init; }
}
有了這個聲明,上面的客戶端程式碼仍然是合法的,但是隨後對 FirstName
和 LastName
屬性的任何賦值都是錯誤的:
var person = new Person { FirstName = "Mads", LastName = "Nielsen" }; // OK
person.LastName = "Torgersen"; // ERROR!
因此,僅初始化屬性可在初始化完成後保護對象的狀態免遭突變。
初始化訪問器和只讀欄位(Init accessors and readonly fields)
因為 init
訪問器只能在初始化期間調用,所以允許它們更改封閉類的只讀(readonly
)欄位,就像在構造函數中一樣。
public class Person
{
private readonly string firstName = "<unknown>";
private readonly string lastName = "<unknown>";
public string FirstName
{
get => firstName;
init => firstName = (value ?? throw new ArgumentNullException(nameof(FirstName)));
}
public string LastName
{
get => lastName;
init => lastName = (value ?? throw new ArgumentNullException(nameof(LastName)));
}
}
二、記錄(Records)
經典的面向對象編程的核心思想是,對象具有強大的身份並封裝了隨時間演變的可變狀態。 C# 在這方面一直都很出色,但是有時您想要的恰恰相反,而在此時,C# 的默認設置往往會妨礙工作,使事情變得非常麻煩。
如果您發現自己希望整個對象是不可變的,並且行為像一個值,那麼您應該考慮將其聲明為記錄(record):
public record Person
{
public string? FirstName { get; init; }
public string? LastName { get; init; }
}
記錄仍然是類,但是 record
關鍵字賦予了它一些另外的類似於值的行為。 一般來說,記錄是根據其內容而不是其標識來定義的。 在這點上,記錄更接近於結構體,但是記錄仍然是引用類型。
雖然記錄是可變的,但它們主要是為更好地支援不可變數據模型而構建的。
with
表達式(With-expressions)
處理不可變數據時,一種常見的模式是從現有值創建新值以表示新狀態。例如,如果我們的 person
要更改其 LastName
,我們會將其表示為一個新對象,該對象是舊對象的副本,只是有不同的 LastName
。這種技巧通常被稱之為非破壞性突變(non-destructive mutation)。記錄(record
)不是代表 person
在一段時間內的 狀態,而是代表 person
在給定時間點的 狀態。
為了幫助實現這種編程風格,記錄(record
)允許使用一種新的表達式 —— with
表達式:
var person = new Person { FirstName = "Mads", LastName = "Nielsen" };
var otherPerson = person with { LastName = "Torgersen" };
with
表達式使用對象初始化器語法來聲明新對象與舊對象的不同之處。您可以指定多個屬性。
with
表達式的工作原理是將舊對象的完整狀態實際地複製到一個新對象中,然後根據對象初始化器對其進行改變。這意味著屬性必須具有 init
或 set
訪問器才能在 with
表達式中進行更改。
基於值的相等(Value-based equality)
所有對象都從對象類(object
)繼承一個虛的 Equals(object)
方法。這被用作是當兩個參數都是非空(non-null
)時,靜態方法 Object.Equals(object, object)
的基礎。
結構體重寫了 Equals(object)
方法,通過遞歸地在結構體的每一個欄位上調用 Equals
來比較結構體的每一個欄位,從而實現了「基於值的相等」。記錄(record
)是一樣的。
這意味著,根據它們的「值性(value-ness)」,兩個記錄(record
)對象可以彼此相等,而不是同一個對象。例如,如果我們將被修改 person
的 LastName
改回去:
var originalPerson = otherPerson with { LastName = "Nielsen" };
現在我們將得到 ReferenceEquals(person, originalPerson)
= false
(它們不是同一個對象),但是 Equals(person, originalPerson)
= true
(它們有相同的值)。除了基於值的 Equals
之外,還有一個基於值的 GetHashCode()
重寫。另外,記錄實現了 IEquatable<T>
並且重載 ==
和 !=
操作符,因此基於值的行為在所有這些不同的相等機制中表現一致。
值的相等性和可變性並不總是很好地融合在一起。一個問題是,更改值可能導致 GetHashCode
的結果隨時間變化,如果對象存儲在哈希表中,這是很不幸的!我們不會禁止使用可變記錄,但是我們不鼓勵它們,除非您充分考慮過後果!
繼承(Inheritance)
記錄可以從其他記錄繼承:
public record Student : Person
{
public int ID;
}
with
表達式和值的相等性與記錄的繼承很好地結合在一起,因為它們考慮了整個運行時對象,而不僅僅是它的靜態已知類型。假設我創建了一個 Student
,但將其存儲在 Person
變數中:
Person student = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 129 };
with
表達式仍將複製整個對象並保留運行時類型:
var otherStudent = student with { LastName = "Torgersen" };
WriteLine(otherStudent is Student); // true
以相同的方式,值的相等性確保兩個對象具有相同的運行時類型,然後比較它們的所有狀態:
Person similarStudent = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 130 };
WriteLine(student != similarStudent); //true, 因為 ID 不同
位置記錄(Positional records)
有時,對記錄採用更具位置定位的方法很有用,因為記錄的內容是通過構造函數參數指定的,並且可以通過位置解構來提取。完全可以在記錄(record
)中指定您自己的構造函數和解構函數:
public record Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
public Person(string firstName, string lastName)
=> (FirstName, LastName) = (firstName, lastName);
public void Deconstruct(out string firstName, out string lastName)
=> (firstName, lastName) = (FirstName, LastName);
}
但是有一種更簡短的語法來表達完全相同的意思(參數名稱包裝模式modulo casing of parameter names
):
public record Person(string FirstName, string LastName);
它聲明了公共的僅初始化(init-only)自動屬性以及構造函數和解構函數,因此您就可以編寫:
var person = new Person("Mads", "Torgersen"); //用位置參數構造(positional construction)
var (f, l) = person; //用位置參數解構(positional deconstruction)
如果不喜歡生成的自動屬性,您可以定義自己的同名屬性,生成的構造函數和解構函數將只使用您自定義的屬性。在這種情況下,該參數在您用於初始化的作用域內。舉例來說,假設您希望將 FirstName
設為受保護的屬性:
public record Person(string FirstName, string LastName)
{
protected string FirstName { get; init; } = FirstName;
}
位置記錄可以像這樣調用基構造函數:
public record Student(string FirstName, string LastName, int ID) : Person(FirstName, LastName);
三、頂級程式(Top-level programs)
譯者註:
什麼是 Top-level program ? 這是在頂級編寫程式的一種更簡單的方式:一個更簡單的Program.cs
文件。
用 C# 編寫一個簡單的程式需要大量的樣板程式碼:
using System;
class Program
{
static void Main()
{
Console.WriteLine("Hello World!");
}
}
這不僅對語言初學者來說是難以承受的,而且還會使程式碼混亂,增加縮進級別。
在 C# 9.0 中,您可以在頂級編寫主程式(main program):
using System;
Console.WriteLine("Hello World!");
允許任何語句。此程式必須在文件中的 using
語句之後,任何類型或命名空間聲明之前執行,並且只能在一個文件中執行。就像目前只能有一個 Main
方法一樣。
如果您想返回一個狀態碼,您可以做。如果您想等待(await
)事情,您可以做。如果您想訪問命令行參數,args
可以作為一個「魔法」參數使用。
using static System.Console;
using System.Threading.Tasks;
WriteLine(args[0]);
await Task.Delay(1000);
return 0;
局部函數是語句的一種形式,也允許在頂級程式中使用。從頂級語句部分之外的任何地方調用它們都是錯誤的。
四、改進的模式匹配(Improved pattern matching)
C# 9.0 中添加了幾種新的模式。讓我們從模式匹配教程 [3]的以下程式碼片段的上下文中來看看它們:
public static decimal CalculateToll(object vehicle) =>
vehicle switch
{
...
DeliveryTruck t when t.GrossWeightClass > 5000 => 10.00m + 5.00m,
DeliveryTruck t when t.GrossWeightClass < 3000 => 10.00m - 2.00m,
DeliveryTruck _ => 10.00m,
_ => throw new ArgumentException("Not a known vehicle type", nameof(vehicle))
};
簡單類型模式(Simple type patterns)
目前,類型模式需要在類型匹配時聲明一個標識符 —— 即使該標識符是一個棄元 _
,如上面的 DeliveryTruck _
所示。但現在你只需寫下類型就可以了:
DeliveryTruck => 10.00m,
關係模式(Relational patterns)
C# 9.0 引入了與關係運算符 <
、<=
等相對應的模式。因此,現在可以將上述模式的 DeliveryTruck
部分編寫為嵌套的 switch
表達式:
DeliveryTruck t when t.GrossWeightClass switch
{
> 5000 => 10.00m + 5.00m,
< 3000 => 10.00m - 2.00m,
_ => 10.00m,
},
這裡的 > 5000
和 < 3000
是關係模式。
邏輯模式(Logical patterns)
最後,您可以將模式與邏輯運算符 and
、or
和 not
組合起來,這些運算符用單詞拼寫,以避免與表達式中使用的運算符混淆。例如,上面嵌套的 switch
的示例可以按如下升序排列:
DeliveryTruck t when t.GrossWeightClass switch
{
< 3000 => 10.00m - 2.00m,
>= 3000 and <= 5000 => 10.00m,
> 5000 => 10.00m + 5.00m,
},
此例中間的案例使用 and
合併了兩個關係模式,形成一個表示區間的模式。
not
模式的一個常見用法是將其應用於 null
常量模式,如 not null
。例如,我們可以根據未知實例是否為空來拆分它們的處理:
not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))
此外,not
在 if
條件中包含 is
表達式時將會很方便,可以取代笨拙的雙括弧,例如:
if (!(e is Customer)) { ... } // 舊的寫法
您可以寫成:
if (e is not Customer) { ... } // 新的寫法
實際上,在 is not
表達式中,允許您命名 Customer
以供後續使用:
if (e is not Customer c) { throw ... } // 如果此分支,則拋出異常或返回...
var n = c.FirstName; // ... 在這裡,c 肯定已賦值
五、目標類型的 new
表達式(Target-typed new expressions)
「目標類型(Target typing
)」是我們在表達式從使用位置的上下文中獲取其類型時所用的一個術語。例如,null
和 lambda
表達式始終是目標類型的。
C# 中的 new
表達式總是要求指定類型(隱式類型的數組表達式除外)。在 C# 9.0 中,如果表達式被賦值為一個明確的類型,則可以省略該類型。
Point p = new (3, 5);
當您有很多重複時,例如在數組或對象初始化設定中,這特別地好用:
Point[] ps = { new (1, 2), new (5, 2), new (5, -3), new (1, -3) };
六、協變式返回值(Covariant returns)
有時候,這樣的表達是有用的 —— 派生類中的方法重寫,具有一個比基類型中的聲明更具體(更明確)的返回類型。C# 9.0 允許:
abstract class Animal
{
public abstract Food GetFood();
...
}
class Tiger : Animal
{
public override Meat GetFood() => ...;
}
更多內容……
查看 C# 9.0 全部特性集的最好地方是 「What’s new in C# 9.0」 文檔頁面 [4]。
-
//devblogs.microsoft.com/dotnet/c-9-0-on-the-record/ C# 9.0 on the record ↩︎
-
//www.cnblogs.com/ittranslator/p/13575059.html 歡迎來到 C# 9.0 ↩︎
-
//docs.microsoft.com/en-us/dotnet/csharp/tutorials/pattern-matching pattern matching tutorial ↩︎
-
//docs.microsoft.com/dotnet/csharp/whats-new/csharp-9 What’s new in C# 9.0 ↩︎