More Effective C# 03.實值類型優先使其具不可變性 More Effective C# 03.實值類型優先使其具不可變性(Prefer Immutability for Value Types)

Published on Friday, November 1, 2024

這個做法說明了 immutable types 與讓類別或結構擁有 immutability 特性的好處與案例。

首先 immutable types 的定義很容易了解: 在型別建立之後內部的狀態就不允許被改變,也就是變成一個常數(constant)。 當你用建構函式進行實例化後的物件目前狀態就是它生命週期唯一合法的狀態(valid state),任何在它生命週期進行的修改都會讓這個物件變成非法的狀態(invalid state)。

如果你按照這個定義去處理,那麼你的類別或結構就可以說是擁有 immutability 特性,由於不允許事後改變那麼也就代表只會有讀取的操作, 不會有修改的操作產生那麼就可以節省很多修改的邏輯與錯誤檢查的邏輯,並且不會有兩個執行緒一方在讀取一方在修改的情況發生, 代表不會有執行緒會讀取到不同狀態的物件資料,因為物件從一開始的狀態就是固定的,所以又能有 thread safe 保證。

並且能夠安全的將物件匯出,因為使用者沒辦反修改你物件內部的狀態,同時又很適合當作 hash-based collections 內部的元素進行運算, 因為物件不能被修改的特性 Object.GetHashCode() 回傳的值永遠都是不變的,也就是說在 Hashtable 這種類別使用 GetHashCode 回傳的的 key 不會被改變。

但實務上很難讓每個類別變成 immutable types,所以這個做法推薦在 value types 上優先實做。

下面這個範例建立了一個結構 Address 並且它是一個 mutable types,也就是代表初始化後還能再次進行修改,這個在多執行序環境底下就會造成問題, 例如我在最後修改了 City 這個操作雖然可以成功,但是實際上會讓 ZipCodeState 變成無意義的狀態,因為這個地址需要是三個欄位都資料正確 才符合設計,所以有另一個執行序在我修改 City 之後同時進行讀取物件就可能會讀取到舊的資料。

同時也有可能在設定 StateZipCode 時內部拋出錯誤,這樣就只有 City 被修改成功導致資料錯亂。

void Main()
{
	Address a1 = new Address();
	a1.Line1 = "111 S. Main";
	a1.City = "Anytown";
	a1.State = "IL";
	a1.ZipCode = 61111;
	// Modify:
	a1.City = "Ann Arbor"; // Zip, State invalid now.
	a1.ZipCode = 48103; // State still invalid now.
	a1.State = "MI"; // Now fine.
}

public struct Address
{
	private string state;
	private int zipCode;

	public string Line1 { get; set; }
	public string Line2 { get; set; }
	public string City { get; set; }
	public string State
	{
		get => state;
		set
		{
			ValidateState(value);
			state = value;
		}
	}
	public int ZipCode
	{
		get => zipCode;
		set
		{
			ValidateZip(value);
			zipCode = value;
		}
	}
}

所以可以試著把 Address 類別修改成 immutable types,下面屬性把 set 存取子移除可以達到唯讀的效果,並且只保留建構函式能進行初始化, 使用上要進行修改操作,並不是像之前那樣透過屬性修改欄位,而是直接建立新的物件並覆蓋舊的物件,這樣就能保證兩個狀態都是合法的狀態不會有改到一半的事情發生。

void Main()
{
 // Create an address:
 Address a2 = new Address("111 S. Main", "", "Anytown", "IL", 61111);
 // To change, re-initialize:
 a2 = new Address(a1.Line1, a1.Line, "Ann Arbor", "MI", 48103);
}

public struct Address
{
	public string Line1 { get; }
	public string Line2 { get; }
	public string City { get; }
	public string State { get; }
	public int ZipCode { get; }
	public Address(string line1,
		string line2,
		string city,
		string state,
		int zipCode) :
		this()
	{
		Line1 = line1;
		Line2 = line2;
		City = city;
		ValidateState(state);
		State = state;
		ValidateZip(zipCode);
		ZipCode = zipCode;
	}
}

在設計 immutable types 的時候要注意程式碼不能產生允許讓使用者修改內部狀態的漏洞,例如這個結構就可能會讓使用者間接修改到 PhoneList 內部的狀態, 可以發現問題在於建構函式複製的 Array 是參考型別,導致修改的時候 PhoneList 同時被修改。

void Main()
{
	Phone[] phones = new Phone[10];
	PhoneList pl = new PhoneList(phones);
	phones[5] = Phone.GeneratePhoneNumber();
}

public struct PhoneList
{
	private readonly Phone[] phones;
	public PhoneList(Phone[] ph)
	{
		phones = ph;
	}
	public IEnumerable<Phone> Phones
	{
		get { return phones; }
	}
}

public class Phone
{
	public int PhoneNumber { get; }
	public Phone(int phoneNumber)
	{
		PhoneNumber = phoneNumber;
	}
	public static Phone GeneratePhoneNumber()
	{
		return new Phone(0911111111);
	}
}

要修正這個問題可以改用 System.Collections.Immutable 命名空間中的類別,像是把 Array 改成用 ImmutableList 將集合轉換成不可變的集合。

public struct PhoneList
{
	private readonly ImmutableList<Phone> phones;
	public PhoneList(Phone[] ph)
	{
		phones = ph.ToImmutableList();
	}
	public IEnumerable<Phone> Phones => phones;
}

最後提供三種策略用來初始化 immutable types 你可以要建立的型別的複雜度來挑選。

  1. 使用建構函式,也就是像上面的 Address 結構那樣把 set 存取子移除,並建立一個建構函式來初始化。
  2. 使用工廠方法,像是 Color 型別就是採用這樣的方式,使用 FromKnownColor()FromName 靜態方法可以直接建立出新的顏色。
  3. 建立 mutable companion class 透過多步驟來產生最終的 immutable 物件,像是 StringBuilder 就是用這個策略,中間的過程都是 mutable 到最後才產生一個 immutable string

Summary

immutable types 可以讓程式更容易維護也更容易撰寫,並不要盲目的把型別中的屬性全部都加上 getset 存取子, 像是保存資料的場合你應該優先選擇 immutable, atomic value types