C# 9.0 正式釋出了(C# 9.0 on the record)

技術譯民發表於2020-11-12

翻譯自 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; }
}

有了這個宣告,上面的客戶端程式碼仍然是合法的,但是隨後對 FirstNameLastName 屬性的任何賦值都是錯誤的:

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 表示式的工作原理是將舊物件的完整狀態實際地複製到一個新物件中,然後根據物件初始化器對其進行改變。這意味著屬性必須具有 initset 訪問器才能在 with 表示式中進行更改。

基於值的相等(Value-based equality)

所有物件都從物件類(object)繼承一個虛的 Equals(object) 方法。這被用作是當兩個引數都是非空(non-null)時,靜態方法 Object.Equals(object, object) 的基礎。

結構體重寫了 Equals(object) 方法,通過遞迴地在結構體的每一個欄位上呼叫 Equals 來比較結構體的每一個欄位,從而實現了“基於值的相等”。記錄(record)是一樣的。

這意味著,根據它們的“值性(value-ness)”,兩個記錄(record)物件可以彼此相等,而不是同一個物件。例如,如果我們將被修改 personLastName 改回去:

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)

最後,您可以將模式與邏輯運算子 andornot 組合起來,這些運算子用單詞拼寫,以避免與表示式中使用的運算子混淆。例如,上面巢狀的 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))

此外,notif 條件中包含 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)”是我們在表示式從使用位置的上下文中獲取其型別時所用的一個術語。例如,nulllambda 表示式始終是目標型別的。

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]


作者 : Mads Torgersen
譯者 : 技術譯民
出品 : 技術譯站
連結 : 英文原文


  1. https://devblogs.microsoft.com/dotnet/c-9-0-on-the-record/ C# 9.0 on the record ↩︎

  2. https://www.cnblogs.com/ittranslator/p/13575059.html 歡迎來到 C# 9.0 ↩︎

  3. https://docs.microsoft.com/en-us/dotnet/csharp/tutorials/pattern-matching pattern matching tutorial ↩︎

  4. https://docs.microsoft.com/dotnet/csharp/whats-new/csharp-9 What's new in C# 9.0 ↩︎

相關文章