C# 9.0中引入的新特性init和record的使用思考

艾心❤發表於2020-12-15

寫在前面

.NET 5.0已經發布,C# 9.0也為我們帶來了許多新特性,其中最讓我印象深刻的就是init和record type,很多文章已經把這兩個新特性討論的差不多了,本文不再詳細討論,而是通過使用角度來思考這兩個特性。

init

init是C# 9.0中引入的新的訪問器,它允許被修飾的屬性在物件初始化的時候被賦值,其他場景作為只讀屬性的存在。直接使用的話,可能感受不到init的意義,所以我們先看看之前是如何設定屬性為只讀的。

private set設定屬性為只讀

設定只讀屬性有很多種方式,本文基於private set來討論。
首先宣告一個產品類,如下程式碼所示,我們把Id設定成了只讀,這個時候也就只能通過建構函式來賦值了。在通常情況下,實體的唯一標識是不可更改的,同時也要防止Id被意外更改。

   1:  public class Product
   2:  {
   3:      public Product(int id)
   4:      {
   5:          this.Id = id;
   6:      }
   7:   
   8:      public int Id { get; private set; }
   9:      //public int Id { get; }
  10:   
  11:      public string ProductName { get; set; }
  12:   
  13:      public string Description { get; set; }
  14:  }
  15:   
  16:  class Program
  17:  {
  18:      static void Main(string[] args)
  19:      {
  20:          Product product = new Product(1)
  21:          {
  22:              ProductName = "test001",
  23:              Description = "Just a description"
  24:          };
  25:   
  26:          Console.WriteLine($"Current Product Id: {product.Id},\n\rProduct Name: {product.ProductName}, \n\rProduct Description: {product.Description}");
  27:          
  28:          //執行結果
  29:          //Current Product Id: 1,
  30:          //Product Name: test001,
  31:          //Product Description: Just a description
  32:          
  33:          Console.ReadKey();
  34:      }
  35:  }

record方式設定只讀

使用init方式,是非常簡單的,只需要把private set改成init就行了:

   1:  public int Id { get; init; }

兩者比較

為了方便比較,我們可以將ProductName設定成了private set,然後通過ILSpy來檢視一下編譯後的程式碼,看看編譯後的Id和ProductName有何不同

a3b12fcd-fa21-4be9-9003-7e7ffef9ee34

咋一看,貌似沒啥區別,都使用到了initonly來修飾。但是如果僅僅只是替換宣告方式,那麼這個新特性似乎就沒有什麼意義了。
接下來我們看第二張圖:

1dbcc742-7f95-4456-b3c8-fba0e4a549ed

如圖示記的那樣,區別還是很明顯的,通過init修飾的屬性並沒有完全替換掉set,由此看來微軟在設計init的時候,還是挺用心思的,也為後面的賦值留下了入口。

   1:  instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) set_Id (
   2:     int32 'value'
   3:    )

另外在賦值的時候,使用private set修飾的屬性,需要定義建構函式,通過建構函式賦值。而使用了init修飾的屬性,則不需要定義建構函式,直接在物件初始化器中賦值即可。

   1:  Product product = new Product
   2:  {
   3:      Id = 1,
   4:      ProductName = "test001",
   5:      Description = "Just a description"
   6:  };
   7:   
   8:  product.Id = 2;//Error CS8852 Init-only property or indexer 'Product.Id' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.

如上程式碼所示,只讀屬性Id的賦值並沒有在建構函式中賦值,畢竟當一個類的只讀欄位十分多的時候,建構函式也變得複雜。而且在賦值好之後,無法修改,這和我們對只讀屬性在通常情況下的理解是一致的。另外通過init修飾的好處便是省卻了一部分只讀屬性在操作上的複雜性,使得物件的宣告與賦值更加直觀。
在合適的場景下選擇最好的程式設計方式,是程式設計師的一貫追求,千萬不要為了炫技而把init當成了茴字的第N種寫法到處去問。

record

record是一個非常有用的特性,它是不可變型別,其相等性是通過內部的幾個屬性來確定的,同時它支援我們以更加方便的方式、像定義值型別那樣來定義不可變引用型別。
我們把之前的Product類改成record型別,如下所示:

   1:  public record Product
   2:  {
   3:      public Product(int id, string productName, string description) => (Id, ProductName, Description) = (id, productName, description);
   4:   
   5:      public int Id { get; }
   6:   
   7:      public string ProductName { get; }
   8:   
   9:      public string Description { get; }
  10:  }

然後檢視一下IL,可以看到record會被編譯成類,同時繼承了System.Object,並實現了IEquatable泛型介面。
編譯器為我們提供的幾個重要方法如下:

  • Equals
  • GetHashCode()
  • Clone
  • PrintMembers和ToString()

比較重要的三個方法

Equals:

3

通過圖片中的程式碼,我們知道比較兩個record物件,首先需要比較型別是否相同,然後再依次比較內部屬性。

GetHashCode():

4

record型別通過基型別以及所有的屬性及欄位的方式來計算HashCode,這在整個繼承層次結構中增強了基於值的相等性,也就意味著兩個同名同姓的人不會被認為是同一個人

Clone:

5

這個方法貌似非常簡單,實在看不出有什麼特別的地方,那麼我們通過後面的內容再來解釋這個方法。

record在DDD值物件中的應用

record之前的定義方式:

瞭解DDD值物件的小夥伴應該想到了,record型別的特性非常像DDD中關於值物件的描述,比如不可變性、其相等於是基於其內部的屬性的等等,我們先來看下值型別的定義方式。

   1:  public abstract class ValueObject
   2:  {
   3:      public static bool operator ==(ValueObject left, ValueObject right)
   4:      {
   5:          if (ReferenceEquals(left, null) ^ ReferenceEquals(right, null))
   6:          {
   7:              return false;
   8:          }
   9:          return ReferenceEquals(left, null) || left.Equals(right);
  10:      }
  11:   
  12:      public static bool operator !=(ValueObject left, ValueObject right)
  13:      {
  14:          return !(left == right);
  15:      }
  16:   
  17:      protected abstract IEnumerable<object> GetEqualityComponents();
  18:   
  19:   
  20:      public override bool Equals(object obj)
  21:      {
  22:          if (obj == null || obj.GetType() != GetType())
  23:          {
  24:              return false;
  25:          }
  26:   
  27:          var other = (ValueObject)obj;
  28:   
  29:          return this.GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
  30:      }
  31:   
  32:      public override int GetHashCode()
  33:      {
  34:          return GetEqualityComponents()
  35:              .Select(x => x != null ? x.GetHashCode() : 0)
  36:              .Aggregate((x, y) => x ^ y);
  37:      }
  38:      // Other utility methods
  39:  }
  40:  public class Address : ValueObject
  41:  {
  42:      public string Street { get; private set; }
  43:      public string City { get; private set; }
  44:      public string State { get; private set; }
  45:      public string Country { get; private set; }
  46:      public string ZipCode { get; private set; }
  47:   
  48:      public Address(string street, string city, string state, string country, string zipcode)
  49:      {
  50:          Street = street;
  51:          City = city;
  52:          State = state;
  53:          Country = country;
  54:          ZipCode = zipcode;
  55:      }
  56:   
  57:      protected override IEnumerable<object> GetEqualityComponents()
  58:      {
  59:          // Using a yield return statement to return each element one at a time
  60:          yield return Street;
  61:          yield return City;
  62:          yield return State;
  63:          yield return Country;
  64:          yield return ZipCode;
  65:      }
  66:   
  67:      public override string ToString()
  68:      {
  69:          return $"Street: {Street}, City: {City}, State: {State}, Country: {Country}, ZipCode: {ZipCode}";
  70:      }
  71:  }

main方法如下:

   1:  static void Main(string[] args)
   2:  {
   3:      Address address1 = new Address("aaa", "bbb", "ccc", "ddd", "fff");
   4:      Console.WriteLine($"address1: {address1}");
   5:   
   6:      Address address2 = new Address("aaa", "bbb", "ccc", "ddd", "fff");
   7:      Console.WriteLine($"address2: {address2}");
   8:   
   9:      Console.WriteLine($"address1 == address2: {address1 == address2}");
  10:   
  11:      string jsonAddress1 = address1.ToJson();
  12:      Address jsonAddress1Deserialize = jsonAddress1.FromJson<Address>();
  13:      Console.WriteLine($"jsonAddress1Deserialize == address1: {jsonAddress1Deserialize == address1}");
  14:   
  15:      Console.ReadKey();
  16:  }

執行結果如下:

   1:  基於class:
   2:  address1: Street: aaa, City: bbb, State: ccc, Country: ddd, ZipCode: fff
   3:  address2: Street: aaa, City: bbb, State: ccc, Country: ddd, ZipCode: fff
   4:  address1 == address2: True
   5:  jsonAddress1Deserialize == address1: True
採用record方式定義:

如果有大量的值物件需要我們編寫,這無疑是加重我們的開發量的,這個時候record就派上用場了,最簡潔的record風格的程式碼如下所示,只有一行:

   1:  public record Address(string Street, string City, string State, string Country, string ZipCode);

IL程式碼如下圖所示,從圖中我們也可以看到record型別的物件,預設情況下用到了init來限制屬性的只讀特性。

6

main方法程式碼不變,執行結果也沒有因為Address從class變成record而發生改變

   1:  基於record:
   2:  address1: Street: aaa, City: bbb, State: ccc, Country: ddd, ZipCode: fff
   3:  address2: Street: aaa, City: bbb, State: ccc, Country: ddd, ZipCode: fff
   4:  address1 == address2: True
   5:  jsonAddress1Deserialize == address1: True

如此看來我們的程式碼節省的不止一點點,而是太多太多了,是不是很爽啊。

record物件屬性值的更改

使用方式如下:

   1:  class Program
   2:  {
   3:      static void Main(string[] args)
   4:      {
   5:          Address address1 = new Address("aaa", "bbb", "ccc", "ddd", "fff");
   6:          Console.WriteLine($"1. address1: {address1}");
   7:   
   8:          Address addressWith = address1 with { Street = "############" };
   9:   
  10:          Console.ReadKey();
  11:      }
  12:  }
  13:   
  14:  public record Address(string Street, string City, string State, string Country, string ZipCode);

通過ILSpy檢視如下所示:

   1:  private static void Main(string[] args)
   2:  {
   3:      Address address1 = new Address("aaa", "bbb", "ccc", "ddd", "fff");
   4:      Console.WriteLine($"1. address1: {address1}");
   5:      Address address2 = address1.<Clone>$();
   6:      address2.Street = "############";
   7:      Address addressWith = address2;
   8:      Console.ReadKey();
   9:  }

由此可以看到record在更改的時候,實際上是通過呼叫Clone而產生了淺拷貝的物件,這也非常符合DDD ValueObject的設計理念。

參考:

相關文章