歡迎來到 C# 9.0(Welcome to C# 9.0)【純手工翻譯】

技術譯民發表於2020-08-28

翻譯自 Mads Torgersen 2020年5月20日的博文《Welcome to C# 9.0》,Mads Torgersen 是微軟 C# 語言的首席設計師,也是微軟 .NET 團隊的專案群經理。

C# 9.0 正在成形,我想和大家分享一下我們對下一版本語言中新增的一些主要特性的想法。

對於 C# 的每一個新版本,我們都在努力讓常見的編碼場景的實現變得更加清晰和簡單,C# 9.0 也不例外。這次特別關注的是支援資料模型的簡潔和不可變表示。

就讓我們一探究竟吧!

一、僅初始化(init-only)屬性

物件初始化器非常棒。它們為型別的客戶端提供了一種非常靈活和可讀的格式來建立物件,並且特別適合於巢狀物件的建立,讓你可以一次性建立整個物件樹。這裡有一個簡單的例子:

new Person
{
    FirstName = "Scott",
    LastName = "Hunter"
}

物件初始化器還使型別作者不必編寫大量的建構函式——他們所要做的就是編寫一些屬性!

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 屬性的任何賦值都是錯誤的。

初始化(init) 訪問器和只讀(readonly)欄位

因為 init 訪問器只能在初始化期間呼叫,所以允許它們更改封閉類的只讀(readonly)欄位,就像在建構函式中一樣。

public class Person
{
    private readonly string firstName;
    private readonly string lastName;
    
    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)));
    }
}

二、記錄(record)

譯者注:
原文中宣告一個記錄的 data class ** 聯合關鍵字現在已經變成 record 關鍵字了,所以翻譯過程中做了修正。

如果您想使單個屬性不可變,那麼僅初始化(init-only)屬性是極好的。如果您想要整個物件是不可變的,行為像一個值,那麼你應該考慮宣告它為一個記錄(record)

public record Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

對於記錄(record),賦予了它一些類似值的行為,我們將在下面深入探討。一般來說,記錄更應該被看作是“值”——資料(data),而不是物件!它們並不具有可變的封裝狀態,相反,您需要通過建立表示新狀態的新記錄來表示其隨時間的變化。它們不是由它們的身份(identity)確定的,而是由它們的內容確定的。

with 表示式

當使用不可變資料(data)時,一種常見的模式是從現有的值中建立新值來表示新狀態。例如,如果我們的 person 要更改他們的 LastName,我們會將其表示為一個新物件,該物件是舊物件的副本,只是有不同的 LastName。這種技巧通常被稱之為非破壞性突變(non-destructive mutation)。記錄(record)不是代表 person 在一段時間內的 狀態,而是代表 person 在給定時間點的 狀態。

為了幫助實現這種程式設計風格,記錄(record)允許使用一種新的表示式 —— with 表示式:

var otherPerson = person with { LastName = "Hanselman" };

with 表示式使用物件初始化器語法來宣告新物件與舊物件的不同之處。您可以指定多個屬性。

記錄(record)隱式定義了一個受保護的(protected)“複製建構函式”——一個接受現有記錄物件並逐欄位將其複製到新記錄物件的建構函式:

protected Person(Person original) { /* copy all the fields */ } // generated

with 表示式會呼叫“複製建構函式”,然後在上面應用物件初始化器來相應地變更屬性。

如果您不喜歡生成的“複製建構函式”的預設行為,您可以定義自己的“複製建構函式”,它將被 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 = "Hunter" };

現在我們將得到 ReferenceEquals(person, originalPerson) = false(它們不是同一個物件),但是 Equals(person, originalPerson) = true(它們有相同的值)。

如果您不喜歡生成的 Equals 重寫的預設逐個欄位比較的行為,您可以自己編寫。您只需要注意理解“基於值的相等”是如何在記錄(record)中工作的,特別是在涉及繼承時,我們後面會講到。

除了基於值的 Equals 之外,還有一個基於值 GetHashCode() 的重寫。

資料成員(Data members)

絕大多數情況下,記錄(record)都是不可變的,僅初始化(init-only)公共屬性可以通過 with 表示式進行非破壞性修改。為了對這種常見情況進行優化,記錄(record)更改了 string FirstName 這種形式的簡單成員宣告的預設含義,與其他類和結構體宣告中的隱式私有欄位不同,它被當作是一個公共的、僅初始化(init-only) 自動屬性的簡寫!因此,宣告:

public record Person { string FirstName; string LastName; }

與我們之前的宣告意思完全一樣,即等同於宣告:

public record Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

我們認為這有助於形成漂亮而清晰的記錄(record)宣告。如果您確實需要私有欄位,只需顯式新增 private 修飾符:

private string firstName;

位置記錄(Positional records)

有時,對記錄(record)採用位置更明確的方法是有用的,其中它的內容是通過建構函式引數提供的,並且可以通過位置解構來提取。

完全可以在記錄(record)中指定自己的建構函式和解構函式:

public record Person 
{ 
    string FirstName; 
    string LastName; 
    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("Scott", "Hunter"); // 用位置引數構造(positional construction)
var (f, l) = person;                        // 用位置引數解構(positional deconstruction)

如果不喜歡生成的自動屬性,您可以定義自己的同名屬性,生成的建構函式和解構函式將只使用您自定義的屬性。

記錄與可變性(Records and mutation)

記錄(record)的基於值的語義不能很好地適應可變狀態。想象一下,將一個記錄(record)物件放入字典中。再次查詢它依賴於 EqualsGetHashCode(有時)。但是如果記錄改變了狀態,它的 Equals 值也會隨之改變,我們可能再也找不到它了!在雜湊表實現中,它甚至可能破壞資料結構,因為位置是基於它的雜湊碼得到的。

記錄(record)內部的可變狀態或許有一些有效的高階用法,特別是對於快取。但是重寫預設行為以忽略這種狀態所涉及的手工工作很可能是相當大的。

with 表示式和繼承(With-expressions and inheritance)

眾所周知,基於值的相等和非破壞性突變與繼承結合在一起時是極具挑戰性的。讓我們在執行示例中新增一個派生的記錄(record)類 Student

public record Person { string FirstName; string LastName; }
public record Student : Person { int ID; }

然後,讓我們從 with 表示式示例開始,實際地建立一個 Student,但將它儲存在 Person 變數中:

int newId = 1;
Func<int> GetNewId = () => ++newId;
//上面兩上是譯者在測試時發現需要新增的程式碼。

Person person = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };
otherPerson = person with { LastName = "Hanselman" };

在最後一行帶 with 表示式的地方,編譯器不知道 person 實際上包含 Student。然而,如果新的 person(即 otherPerson) 不是一個真正的 Student 物件,並且具有從第一個 person 複製過去的相同的 ID,那麼它就不是一個恰當的拷貝。

C# 實現了這一點。記錄(record)有一個隱藏的虛方法(virtual method),它被委託“克隆”整個物件。每個派生記錄型別都重寫此方法以呼叫該型別的複製建構函式,並且派生記錄的複製建構函式將連結到基記錄的複製建構函式。with 表示式只需呼叫隱藏的“克隆”方法並將物件初始化器應用於其返回結果。

基於值的相等和繼承(Value-based equality and inheritance)

with 表示式支援類似,基於值的相等也必須是“虛的(virtual)”,即 Student 需要比較 Student 的所有欄位,即使比較時靜態已知的型別是 Person 之類的基型別。這很容易通過重寫虛的(virtual) Equals 方法來實現。

然而,關於相等還有一個額外的挑戰:如果你比較兩種不同的 Person 會怎樣?我們不能僅僅讓其中一個來決定實施哪個相等:相等應該是對稱的,所以不管兩個物件哪個在前面,結果應該是相同的。換句話說,它們必須在相等的實施上達成一致

舉例說明一下這個問題:

Person person1 = new Person { FirstName = "Scott", LastName = "Hunter" };
Person person2 = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };

這兩個物件相等嗎? person1 可能會認為相等,因為 person2 對於 Person 的所有屬性都是正確的,但是 person2 不敢苟同!我們需要確保它們都同意它們是不同的物件。

同樣,C# 會自動為您處理這個問題。實現的方式是,記錄有一個名為 EqualityContract 的“虛的(virtual)”受保護的屬性。每個派生記錄(record)都會重寫它,為了比較相等,這兩個物件必須具有相同的 EqualityContract

三、頂級程式(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 可以作為一個“魔法”引數使用。

區域性函式是語句的一種形式,也允許在頂級程式中使用。從頂級語句部分之外的任何地方呼叫它們都是錯誤的。

四、改進的模式匹配(Improved pattern matching)

C# 9.0 中新增了幾種新的模式。讓我們從模式匹配教程的程式碼片段的上下文中來看看它們:

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) { ... }

五、改進的目標型別(Improved target typing)

“目標型別(Target typing)”是一個術語,當一個表示式從使用它的地方的上下文中獲得其型別時,我們使用這個術語。例如,nulllambda表示式始終是目標型別的。

在 C# 9.0 中,一些以前不是目標型別的表示式變得可以由其上下文推導。

目標型別的 new 表示式(Target-typed new expressions)

C# 中的 new 表示式總是要求指定型別(隱式型別的陣列表示式除外)。現在,如果表示式被賦值為一個明確的型別,則可以省略該型別。

Point p = new (3, 5);

目標型別的 ???:(Target typed ?? and ?:

有時有條件的 ???: 表示式在分支之間沒有明顯的共享型別,這種情況目前是失敗的。但是如果有一個兩個分支都可以轉換成的目標型別,在 C# 9.0 中將是允許的。

Person person = student ?? customer; // Shared base type
int? result = b ? 0 : null; // nullable value type

六、協變式返回值(Covariant returns)

有時候,這樣的表達是有用的——派生類中的方法重寫具有一個比基型別中的宣告更具體(更明確)的返回型別。C# 9.0 允許:

abstract class Animal
{
    public abstract Food GetFood();
    ...
}
class Tiger : Animal
{
    public override Meat GetFood() => ...;
}

更多內容……

要檢視 C# 9.0 即將釋出的全部特性並追隨它們的完成,最好的地方是 Roslyn(C#/VB 編譯器) GitHub 倉庫上的 Language Feature Status


作者 : Mads Torgersen
譯者 : 技術譯民
出品 : 技術譯站
連結 : 英文原文
©純手工翻譯,歡迎轉載,轉載請標明出處

相關文章