概述
本文是《Effective C#》一書第七節的讀書筆記。通過這篇文章,我主要想向大家說明一個我們平時 可能不太會注意到的問題:創建具有常量性和原子性的值類型。
從類型設計談起
從Class到Struct
假如我們要設計一個存儲收信人地址的類型(Type), 我們管這個類型叫 Address。它應該包含這樣幾 個屬性:
Province 省
City 市
Zip 郵編
要求對Zip的格式進行控制(必須全為數字,且為6位),大家該如何設計呢?我想很多人會寫成這樣吧 :
public class Address {
private string province;
private string city;
private string zip;
public string Province {
get { return province; }
set { province = value; }
}
public string City {
get { return city; }
set { city = value; }
}
public string Zip {
get { return zip; }
set {
CheckZip(value); // 驗證格式
zip = value;
}
}
// 檢測是不是正確的 zip
private void CheckZip(string value) {
string pattern = @"d{6}";
if(!Regex.IsMatch(value, pattern))
throw new Exception("Zip is invalid! ");
}
public override string ToString() {
return String.Format("Province: {0}, City: {1}, Zip: {2}", province, city, zip);
}
}
這裡已經存在第一個問題:當我們聲明一個類時,更多的是定義一系列相關的操作(或者叫行為、方法 ),當然類中也會包含字段和屬性,但這些字段通常都是為類的方法所使用,而屬性則常用於表示類的狀 態(比如StringBuilder的Length),類的能力(比如StringBuilder的 Capacity),方法進行的狀態或者階 段。而定義一個結構時,我們通常僅僅是用它來保存數據,而不提供方法,或者是僅提供對其自身進行操 作或者轉換的方法,而非對其它類型提供服務的方法。
Address 不包含任何的方法,它僅僅是將Provice、City、Zip這樣的三個數據組織起來成為一個獨立 的個體,所以最好將其聲明為一個Struct而非是一個Class。(這裡也有例外的情況:如果Address包含二 十個或者更多的字段,則考慮將其聲明為Class,因為Class在參數傳遞時是傳引用,而Struct是傳值。在 數據較小的情況下,傳值的效率更高一些;而在數據較大的時候,傳引用占據更小的內存空間。)
所以我們首先可以將Address聲明為一個Struct而非Class。
數據不一致的問題
我們接下來使用一下剛剛創建的Address類型:
Address a = new Address();
a.Province = "陝西";
a.City = "西安";
a.Zip = "710068";
Console.WriteLine(a.ToString()); // Province: 陝西, City: 西安, Zip: 710068
看上去是沒有問題的,但是回想下類型的定義,在給Zip屬性賦值時是有可能拋出異常的,所以我們還 是把它放在一個Try Catch語句中,同時我們給Zip賦一個錯誤的值,看會發生什麼:
try {
a.City = "青島";
a.Zip = "12345"; // 這裡觸發異常
a.Province = "山東";
} catch {
}
Console.WriteLine(a.ToString());//Province: 陝西, City: 青島, Zip: 710068
結果是出現了數據不一致的問題,當為Zip賦值的時候,因為引發了異常,所以對Zip以及其後的 Province的賦值都失敗了,但是對City的賦值是成功的。結果就是出現了Provice是陝西,City卻是青島 這種情況。
即是在賦值Zip時沒有引發異常,也會出現問題:在多線程情況下,當當前線程執行到修改了 City為 “青島”,但還沒有修改 Zip 和 Province的時候(Zip仍為 “710068”、Province仍為“陝西”)。如果 此時其他線程訪問類型實例a,那麼也將會讀取到不一致的數據。
常量性和原子性
我們現在已經知道了上面存在的問題,那麼接下來該如何改進呢?我們先來看看作者對常量性和原子 性給的定義:
對象的原子性:對象的狀態是一個整體,如果一個字段改變,其他字段也要同時做出相應改變。簡單 來說,就是要麼不改,要麼全改。
對象的常量性:對象的狀態一旦確定,就不能再次更改了。如果想再次更改,需要重新構造一個對象 。
我們已經知道了對象的原子性和常量性這兩個概念,那麼接下來該如何去實施呢?對於原子性,我們 實施的辦法是添加一個構造函數,在這個構造函數中為對象的所有字段賦值。而為了實施常量性,我們不 允許在為對象賦值以後還能對對象狀態進行修改,所以我們將屬性中的set訪問器刪除掉,同時將字段聲 明為readonly:
public struct Address {
private readonly string province;
private readonly string city;
private readonly string zip;
public Address(string province, string city, string zip) {
this.city = city;
this.province = province;
this.zip = zip;
CheckZip(zip); // 驗證格式
}
public string Province {
get { return province; }
}
public string City {
get { return city; }
}
public string Zip {
get { return zip; }
}
// 其余略 ...
}
這樣,我們對Address對象的創建,將所有字段的賦值都在構造函數中作為一個整體來進行;而當我們 需要改變單個字段的值時,也需要重新創建對象再賦值。我們看下下面的測試:
Address a = new Address("陝西", "西安", "710068");
try {
a = new Address("青島", "山東", "22233");// 發生異常,對a重新賦值失敗,但狀態保 持一致
} catch {
}
Console.WriteLine(a.ToString()); // 輸出:Province: 陝西, City: 西安, Zip: 710068
避免外部類型對類型內部的訪問
上面的方法解決了數據不一致的問題,但是還漏掉了一點:當類型內部維護著一個引用類型字段,比 如說數組。盡管我們將它聲明為了readonly,類型外部還是可以對它進行訪問(如果你不清楚值類型和引 用類型的區別,請參考 C#類型基礎)。現在我們修改Address 類,添加一個數組phones,存儲電話號碼:
private readonly string[] phones;
public Address(string province, string city, string zip, string[] phones) {
// 略...
this.phones = phones;
}
public string[] Phones {
get { return phones; }
}
我們接下來做個測試:
string[] phones = { "029-88401100", "029-88500321" };
Address a = new Address("陝西", "西安", "710068", phones);
Console.WriteLine(a.Phones[0]); // 輸出: 029-88401100
string[] b = a.Phones;
b[0] = "029-XXXXXXXX"; // 通過b修改了 Address的內容
Console.WriteLine(a.Phones[0]); // 輸出: 029-XXXXXXXX
可以看到,盡管 phones字段聲明為了readonly,並且也只提供了get屬性訪問器。我們仍然可以通過 Address對象a外部的變量b,修改了a對象內部的內容。如何避免這種情況的發生呢?我們可以通過深度復 制的方式來解決,在Phones的get屬性訪問器中添加如下代碼:
public string[] Phones {
get {
string[] rtn = new string[phones.Length];
phones.CopyTo(rtn, 0);
return rtn;
}
}
在Get訪問器中,我們創建了一個新的數組,並將Address對象本身的數組內容進行了拷貝,然後返回 給調用者。此時,再次運行剛才的代碼,由於b指向了新創建的這個數組對象,而非Address對象a內部的 數組對象,所以對於b的修改將不再影響到a。再次運行剛才的代碼,我們可以得到 029-88401100 的輸出 。
但是問題還沒有結束,我們再看下面這段代碼:
string[] phones = { "029-88401100", "029-88500321" };
Address a = new Address("陝西", "西安", "710068", phones);
Console.WriteLine(a.Phones[0]); // 輸出: 029-88401100
phones[0] = "029-XXXXXXXX"; // 通過phones變量修改了Address對象內部的數據
Console.WriteLine(a.Phones[0]); // 輸出: 029-XXXXXXXX
再創建Address對象完畢,我們依然可以通過之前的數組變量來修改對象內部的數據,受到前面的啟發 ,很容易想到我們可以在構造函數中對外部傳遞進來的數組進行深度復制:
public Address(string province, string city, string zip, string[] phones) {
// 前面略...
this.phones = new string[phones.Length];
phones.CopyTo(this.phones, 0);
CheckZip(zip); // 驗證格式
}
這樣,我們再次運行上面的代碼,對於phones的修改便不會再影響到Address對象本身。
總結
這篇文章向大家講述了類型設計時需要注意的三個問題:1、當創建類型的目的是為了存儲一組相關的 數據,且數據量不是很大的時候,將它聲明為Struct比Class會獲得更高的效率;2、將類型聲明為具有原 子性和常量性,可以避免可能出現的數據不一致問題;3、通過在構造函數和Get訪問器中,對對象的字段 進行深度復制,可以避免在類型的外部修改類型內部數據的問題。
感謝閱讀,希望這篇文章能帶給你幫助!