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

翻譯自 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
譯者 : 技術譯民
出品 : 技術譯站
鏈接 : 英文原文
©純手工翻譯,歡迎轉載,轉載請標明出處