這個做法說明了 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
這個操作雖然可以成功,但是實際上會讓 ZipCode
與 State
變成無意義的狀態,因為這個地址需要是三個欄位都資料正確
才符合設計,所以有另一個執行序在我修改 City
之後同時進行讀取物件就可能會讀取到舊的資料。
同時也有可能在設定 State
與 ZipCode
時內部拋出錯誤,這樣就只有 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
你可以要建立的型別的複雜度來挑選。
- 使用建構函式,也就是像上面的
Address
結構那樣把set
存取子移除,並建立一個建構函式來初始化。 - 使用工廠方法,像是
Color
型別就是採用這樣的方式,使用FromKnownColor()
與FromName
靜態方法可以直接建立出新的顏色。 - 建立
mutable companion class
透過多步驟來產生最終的immutable
物件,像是StringBuilder
就是用這個策略,中間的過程都是mutable
到最後才產生一個immutable string
。
Summary
immutable types
可以讓程式更容易維護也更容易撰寫,並不要盲目的把型別中的屬性全部都加上 get
與 set
存取子,
在使用 Value Types 最好把它設計成具備 immutable 特性。