C# 9.0新特性詳解系列之五:記錄(record)和with表達式

1 背景與動機

傳統面向對象編程的核心思想是一個對象有着惟一標識,表現爲對象引用,封裝着隨時可變的屬性狀態,若是你改變了一個屬性的狀態,這個對象仍是原來那個對象,就是對象引用沒有由於狀態的改變而改變,也就是說該對象能夠有不少種狀態。C#從最初開始也是一直這樣設計和工做的。可是一些時候,你可能很是須要一種剛好相反的方式,例如我須要一個對象只有一個狀態,那麼原來那種默認方式每每會成爲阻力,使得事情變得費時費力。web

當一個類型的對象在建立時被指定狀態後,就不會再變化的對象,咱們稱之爲不可變類型。這種類型是線程安全的,不須要進行線程同步,很是適合並行計算的數據共享。它減小了更新對象會引發各類bug的風險,更爲安全。System.DateTime和string就是不可變類型很是經典的表明。編程

原來,咱們要用類來建立一個不可變類型,你首先要定義只讀字段和屬性,而且還要重寫涉及相等判斷的方法等。在C#9.0中,引入了record,專門用來以最簡的方式建立不可變類型的新方式。若是你須要一個行爲像值類型的引用類型,你可使用record;若是你須要整個對象都是不可變的,且行爲像一個值,那麼你也可考慮將其聲明爲一個record類型。 那麼什麼是record類型?api

2 Record介紹

record類型是一種用record關鍵字聲明的新的引用類型,與類不一樣的是,它是基於值相等而不是惟一的標識符——對象引用。他有着引用類型的支持大對象、繼承、多態等特性,也有着結構的基於值相等的特性。能夠說有着class和struct二者的優點,在一些狀況下能夠用以替代class和struct。安全

提到不可變的類型,咱們會想到readonly struct,那麼爲何要選擇添加一個新的類型,而不是用readonly struct呢?這是由於記錄有着以下優勢:數據結構

  • 在構造不可變的數據結構時,它的語法簡單易用。多線程

  • record爲引用類型,不用像值類型在傳遞時須要內存分配,並進行總體拷貝。併發

  • 構造函數和結構函數爲一體的、簡化的位置記錄app

  • 有力的相等性支持,重寫了Equals(object), IEquatable , 和GetHashCode()這些基本方法。 ide

2.1 record類型的定義與使用

2.1.1 常規方式

record類型能夠定義爲可變的,也能夠是不可變的。如今,咱們用record定義一個只有只讀屬性的Person類型以下。這種只有只讀屬性的類型,由於其在建立好以後,屬性就不能再被修改,咱們一般把這種類型叫作不可變類型。函數

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

    public Person(string first, string last) => (FirstName, LastName) = (first, last);
}

上面這種聲明,在使用時,只能用帶參的構造函數進行初始化。要建立一個record對象跟類沒有什麼區別:

Person person = new("Andy", "Kang");

若是要支持用對象初始化器進行初始化,則在屬性中使用init關鍵字。這種形式,若是不須要用帶參的構造函數進行初始化,能夠不定義帶參的構造函數,上面的Person能夠改成下面形式。

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

如今,建立Person對象時,用初始化器進行初始化以下:

Person person = new() { FirstName = "Andy", LastName = "Kang"};

若是須要是可變類型的record,咱們定義以下。這種由於有set訪問器,全部它支持用對象初始化器進行初始化,若是你想用構造函數進行初始化,你能夠添加本身的構造函數。

public record Person
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }

}

2.1.2 位置記錄 / Positional records

爲了支持將record對象能解構成元組,咱們給record添加解構函數Deconstruct。這種record就稱爲位置記錄。下面代碼定義的Person,記錄的內容是經過構造函數的參數傳入,而且經過位置解構函數提取出來。你徹底能夠在記錄中定義你本身的構造和解構函數(注意不是析構函數)。以下所示:。

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);
}

針對上面如此複雜的代碼,C#9.0提供了更精簡的語法表達上面一樣的內容。須要注意的是,這種記錄類型是不可變的。這也就是爲何有record默認是不可變的說法由來。

public record Person(string FirstName, string LastName);

該方式聲明瞭公開的、僅可初始化的自動屬性、構造函數和解構函數。如今建立對象,你就能夠寫以下代碼:

var person = new Person("Mads", "Torgersen"); // 位置構造函數
var (firstName, lastName) = person;                        // 位置解構函數

固然,若是你不喜歡產生的自動屬性、構造函數和解構函數,你能夠自定義同名成員代替,產生的構造函數和解構函數將會只使用你自定義的那個。在這種狀況下,被自定義參數處於你用於初始化的做用域內,例如,你想讓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);

2.1.3 定義的總結

record默認狀況下是被設計用來進行描述不可變類型的,所以位置記錄這種短小簡明的聲明方式是推薦方式。

2.2 with表達式

當使用不可變的數據時,一個常見的模式是從現存的值建立新值來呈現一個新狀態。例如,若是Person打算改變他的姓氏(last name),咱們就須要經過拷貝原來數據,並賦予一個不一樣的last name值來呈現一個新Person。這種技術被稱爲非破壞性改變。做爲描繪隨時間變化的person,record呈現了一個特定時間的person的狀態。爲了幫助進行這種類型的編程,針對records就提出了with表達式,用於拷貝原有對象,並對特定屬性進行修改:

var person = new Person { FirstName = "Mads", LastName = "Nielsen" };
var otherPerson = person with { LastName = "Torgersen" };

若是隻是進行拷貝,不須要修改屬性,那麼無須指定任何屬性修改,以下所示:

Person clone = person with { };

with表達式使用初始化語法來講明新對象在哪裏與原有對象不一樣。with表達式其實是拷貝原來對象的整個狀態值到新對象,而後根據對象初始化器來改變指定值。這意味着屬性必須有init或者set訪問器,才能用with表達式進行更改。

須要注意的是:

  • with表達式左邊操做數必須爲record類型。
  • record的引用類型的成員在拷貝的時候,只是將所指實例的引用進行了拷貝。

2.3 record的面向對象的特性——繼承、多態等

記錄(record)和類同樣,在面向對象方面,支持繼承,多態等全部特性。除過前面提到的record專有的特性,其餘語法寫法跟類也是同樣。同其餘類型同樣,record的基類依然是object。
要注意的是:

  • 記錄只能從記錄繼承,不能從類繼承,也不能被任何類繼承。

  • record不能定義爲static的,可是能夠有static成員。

下面一個學生record,它繼承自Person:

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

public sealed record Student : Person
{
    public int ID { get; init; }
}

對於位置記錄,只要保持record特有的寫法便可:

public record Person(string FirstName, string LastName);

public sealed record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName);

public sealed record Teacher(string FirstName, string LastName, string Title) : Person(FirstName, LastName)
{
    public override string ToString()
    {
        StringBuilder s = new();
        base.PrintMembers(s);
        return $"{s.ToString()} is a Teacher";
    }
}

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值不一樣

2.4 record實現原理

從本質上來說,record仍然是一個類,可是關鍵字record賦予這個類額外的幾個像值的行爲。也就是,當你定義了record時候,編譯器會自動生成如下方法,來實現基於值相等的特性(即只要兩個record的全部屬性都相等,且類型相同,那麼這兩個record就相等)、對象的拷貝和成員及其值的輸出。

  • 基於值相等性的比較方法,如Equals,==,!=,EqualityContract等。

  • 重寫GetHashCode()

  • 拷貝和克隆成員

  • PrintMembers和ToString()方法

例如我先定義一個Person的記錄類型:

public record Person(string FirstName, string LastName);

編譯器生成的代碼和下面的代碼定義是等價的。可是要注意的是,跟編譯器實際生成的代碼相比,名字的命名是有所不一樣的。

public class Person : IEquatable<Person>
{
    private readonly string _FirstName;
    private readonly string _LastName;

    protected virtual Type EqualityContract
    {
        get
        {
            return typeof(Person);
        }
    }

    public string FirstName
    {
        get
        {
            return _FirstName;
        }
        init
        {
            _FirstName = value;
        }
    }
    public string LastName
    {
        get
        {
            return _LastName;
        }
        init
        {
            _LastName = value;
        }
    }
    public Person(string FirstName, string LastName)
    {
        _FirstName = FirstName;
        _LastName = LastName;
    }

    public override string ToString()
    { 
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.Append("Person");
        stringBuilder.Append(" { ");
        if (PrintMembers(stringBuilder))
        {
            stringBuilder.Append(" ");
        }
        stringBuilder.Append("}");
        return stringBuilder.ToString();
    }

    protected virtual bool PrintMembers(StringBuilder builder)
    { 
        builder.Append("FirstName");
        builder.Append(" = ");
        builder.Append((object)FirstName);
        builder.Append(", ");
        builder.Append("LastName");
        builder.Append(" = ");
        builder.Append((object)LastName);
        return true;
    }

    public static bool operator !=(Person r1, Person r2)
    {
        return !(r1 == r2);
    }

    public static bool operator ==(Person r1, Person r2)
    {
        return (object)r1 == r2 || ((object)r1 != null && r1.Equals(r2));
    }

    public override int GetHashCode() 
    { 
        return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 
                + EqualityComparer<string>.Default.GetHashCode(_FirstName)) * -1521134295 
                + EqualityComparer<string>.Default.GetHashCode(_LastName);
    }

    public override bool Equals(object obj)
    {
        return Equals(obj as Person);
    }

    public virtual bool Equals(Person other)
    { 
        return (object)other != null 
                && EqualityContract == other.EqualityContract 
                && EqualityComparer<string>.Default.Equals(_FirstName, other._FirstName) 
                && EqualityComparer<string>.Default.Equals(_LastName, other._LastName);
    }

    public virtual Person Clone()
    { 
        return new Person(this);
    }

    protected Person(Person original)
    { 
        _FirstName = original._FirstName;
        _LastName = original._LastName;
    }
    public void Deconstruct(out string FirstName, out string LastName)
    { 
        FirstName = this.FirstName;
        LastName = this.LastName;
    }
}

這些由編譯器生成的一些成員,是容許編程人員自定義的,一旦編譯器發現有自定義的某個成員,它就不會再生成這個成員。

因而可知,record實際上就是編譯器特性,而且records由他們的內容來界定,不是他們的引用標識符。從這一點上講,records更接近於結構,可是他們依然是引用類型。

2.4.1 基於值的相等

全部對象都從object類型繼承了 Equals(object),這是靜態方法 Object.Equals(object, object) 用來比較兩個非空參數的基礎。結構重寫了這個方法,經過遞歸調用每一個結構字段的Equals方法,從而有了「基於值的相等」。Recrods也是這樣。這意味着只要他們的值保持一致,兩個record對象能夠不是同一個對象實例就會相等。例如咱們將修改的Last name又修改回去了:

var originalPerson = otherPerson with { LastName = "Nielsen" };

如今咱們會獲得 ReferenceEquals(person, originalPerson) = false (他們不是同一對象),可是 Equals(person, originalPerson) = true (他們有一樣的值).。與基於值的Equals一塊兒的,還伴有基於值的GetHashCode()的重寫。另外,records實現了IEquatable 並重載了==和 !=這兩個操做符,這些都是爲了基於值的行爲在全部的不一樣的相等機制方面保持一致。

基於值的相等和可變性契合的不老是那麼好。一個問題是改變值可能引發GetHashCode的結果隨時變化,若是這個對象被存放在哈希表中,就會出問題。咱們沒有不容許使用可變的record,可是咱們不鼓勵那樣作,除非你已經想到了後果。

若是你不喜歡默認Equals重寫的字段與字段比較行爲,你能夠進行重寫。你只須要認真理解基於值的相等時如何在records中工做原理,特別是涉及到繼承的時候。

除了熟悉的Equals,==和!=操做符以外,record還多了一個新的EqualityContract只讀屬性,該屬性返回類型是Type類型,返回值默認爲該record的類型。該屬性用來在判斷兩個具備繼承關係不一樣類型的record相等時,該record所依據的類型。下面咱們看一個有關EqualityContract的例子,定義一個學生record,他繼承自Person:

public record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName);

這個時候,咱們分別建立一個Person和Student實例,都用來描述一樣的人:

Person p = new Person("Jerry", "Kang");
Person s = new Student("Jerry", "Kang", 1);
WriteLine(p == s); // False

這二者比較的結果是False,這與咱們實際需求不相符。那麼咱們能夠重寫EqualityContract來實現兩種相等:

public record Student(string FirstName, string LastName, int Level) : Person(FirstName, LastName)
{
    protected override Type EqualityContract
    {
        get => typeof(Person);
    }
}

通過此改造以後,上面例子中的兩個實例就會相等。EqualityContract的修飾符是依據下面狀況肯定的:

  • 若是基類是object, 屬性是virtual;
  • 若是基類是另外一個record類型,則該屬性是override;
  • 若是基類類型是sealed,則該屬性也是sealed的。

2.4.2 拷貝克隆與with表達式

一個record在編譯的時候,會自動生成一個帶有保護訪問級別的「拷貝構造函數」,用來將現有record對象的字段值拷貝到新對象對應字段中:

protected Person(Person original) { /* 拷貝全部字段 */ } // 編譯器生成

with表達式就會引發拷貝構造函數被調用,而後應用對象初始化器來有限更改屬性相應值。若是你不喜歡默認的產生的拷貝構造函數,你能夠自定義該構造函數,編譯器一旦發現有自定義的構造函數,就不會在自動生成,with表達式也會進行調用。

public record Person(string FirstName, string LastName)
{
    protected Person(Person original)
    {
        this.FirstName = original.FirstName;
        this.LastName = original.LastName;
    }
}

編譯器默認地還會生成with表達式會使用的一個Clone方法用於建立新的record對象,這個方法是不能在record類型裏面自定義的。
2.4.3 PrintMembers和ToString()方法
若是你用Console.WriteLine來輸出record的實例,就會發現其輸出與用class定義的類型的默認的ToString徹底不一樣。其輸出爲各成員及其值組成的字符串:

Person {FirstName = Andy, LastName = Kang}

這是由於,基於值相等的類型,咱們更加關注於具體的值的狀況,所以在編譯record類型時會自動生成重寫了ToString的行爲的代碼。針對record類型,編譯器也會自動生成一個保護級別的PrintMembers方法,該方法用於生成各成員及其值的字符串,即上面結果中的紅色字體部分。ToString中,就調用了PrintMembers來生成其成員字符串部分,其餘部分即藍色字體部分在ToString中補充。

咱們也能夠定義PrintMembers和重寫ToString方法來實現本身想要的功能,以下面實現ToString輸出爲Json格式:

public record Person(string FirstName, string LastName)
{
    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append("\"FirstName\"");
        builder.Append(" : ");
        builder.Append($"\"{ FirstName}\"");
        builder.Append(", ");
        builder.Append("\"LastName\"");
        builder.Append(" : ");
        builder.Append($"\"{ LastName}\"");
        return true;
    }

    public override string ToString()
    {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.Append("{");
        if (PrintMembers(stringBuilder))
        {
            stringBuilder.Append(" ");
        }
        stringBuilder.Append("}");
        return stringBuilder.ToString();
    }
}

record由於都是繼承自Object,所以ToString都是採用override修飾符。而PrintMembers方法修飾符是依據下面狀況決定的:

  • 若是記錄不是sealed而是從object繼承的, 該方法是protected virtual;

  • 若是記錄基類是另外一個record類型,則該方法是protected override;

  • 若是記錄類型是sealed,則該方法也是private的。

3 應用場景

3.1 Web Api

用於web api返回的數據,一般做爲一種一次性的傳輸型數據,不須要是可變的,所以適合使用record。

3.2 併發和多線程計算

做爲不可變數據類型record對於並行計算和多線程之間的數據共享很是適合,安全可靠。

3.3 數據日誌

record自己的不可變性和ToString的數據內容的輸出,不須要不少人工編寫不少代碼,就適合進行日誌處理。

3.4 其餘

其餘涉及到有大量基於值類型比較和複製的場景,也是record的經常使用的使用場景。

4 結束語

在生產應用中,有着衆多的使用場景,以便咱們用record來替換寫一個類。未知的還在等咱們進一步探索。

如對您有價值,請推薦,您的鼓勵是我繼續的動力,在此萬分感謝。關注本人公衆號「碼客風雲」,享第一時間閱讀最新文章。

碼客風雲
相關文章
相關標籤/搜索