C#語言引入了許多新的語法來表達程序設計。我們所選擇的技巧,實際上是向維護、擴展和使用我們軟件的開發(fā)人員表達了我們的設計意圖。所有的C#類型都生存于.NET環(huán)境中。.NET環(huán)境對于所有類型的能力也都有某種假設。如果我們違反了這些假設,那么類型不能正常工作的可能性就會大大增加。
本章的條款并不是要對軟件設計技巧進行概要介紹——這方面的著作已經(jīng)不少。相反,本章主要探討如何更好地利用不同的C#語言特性,來表達我們的軟件設計意圖。C#語言的設計者們添加了許多語言特性,來讓我們更清晰地表達現(xiàn)代軟件設計中的各種慣用法(idiom)。某些語言特性之間的差別非常小,我們通常有許多選擇。選擇多剛開始看起來似乎是好事情,但是當我們發(fā)現(xiàn)需要擴展現(xiàn)有的程序時,區(qū)別就開始顯現(xiàn)了。我們首先要確保很好地理解本章中的各個條款,然后在應用它們的時候,要對軟件未來可能的擴展有一個清醒的認識。
某些語法的改變使我們擁有了新的詞匯來表述日常的慣用法。屬性、索引器、事件和委托都是這樣例子,還有類與接口的區(qū)別:類定義類型,接口聲明行為?;惵暶黝愋?,同時定義一組相關類型所共有的行為。其他一些設計慣用法也由于垃圾收集器的引入而有所改變。而且,由于絕大多數(shù)變量都是引用類型,因此也會為我們的設計慣用法帶來一些變化。
本章的推薦條款將幫助大家選擇最自然的構造來表達自己的軟件設計,從而使創(chuàng)建的軟件更易于維護、擴展和使用。
條款19:定義并實現(xiàn)接口優(yōu)于繼承類型
抽象基類為類層次(class hierarchy)提供了一個共用的祖先類(ancestor)。接口則描述了一組可以由某個類型實現(xiàn)的緊湊的功能。每一個都有自己的用武之地,但用處各不相同。接口是一種按合同設計(design by contract)的方式:一個實現(xiàn)了某個接口的類型,必須提供接口中約定的方法實現(xiàn)。抽象基類則為一組相關的類型提供了一個共用的抽象。下面的表述雖然是陳詞濫調(diào),但是很有用:繼承意味著“is a”,接口意味著“behaves like”。這些表述之所以至今仍有生命力,是因為它們很好地描述了兩種構造之間的差別:基類描述了對象是什么;接口描述了對象的行為方式。
接口描述了一組功能,或者說一個合同。我們可以在接口中為任何構造創(chuàng)建占位符(placeholder):方法、屬性、索引器和事件。任何實現(xiàn)了接口的類型都必須為接口中定義的所有元素提供具體的實現(xiàn),即必須實現(xiàn)所有的方法,提供所有的屬性訪問器和索引器,并定義接口中定義的所有事件。我們應該識別可重用的行為,并將它們提取出來定義在接口中。我們可以將接口用做函數(shù)的參數(shù),并返回值。由于不相關的類型可以共同實現(xiàn)一個接口,因此我們將有更多機會重用代碼。而且,實現(xiàn)一個接口對于開發(fā)人員來說,要比繼承一個我們創(chuàng)建的類型更加容易。
我們不能在接口中提供任何成員的實現(xiàn)。接口不能包含實現(xiàn),也不能包含任何具體的數(shù)據(jù)成員。接口是在聲明一種合同:所有實現(xiàn)了接口的類型都要負責履行其中的約定。
除了描述共同的行為外,抽象基類還可以為派生類型提供一些具體的實現(xiàn)。在抽象類中,我們可以指定數(shù)據(jù)成員、具體的方法、虛方法的實現(xiàn)、屬性、事件和索引器?;惪梢詫崿F(xiàn)一些具體的方法,因此可以為子類提供一些通用的可重用代碼。任何元素都可以為虛擬成員、抽象成員或者非虛成員。抽象基類可以為任何具體的行為提供一個實現(xiàn),而接口則不能。
這種實現(xiàn)重用還提供了另一種好處:如果向基類中添加一個方法,所有派生類都將自動隱含這個方法。從這個角度來看,基類為我們提供了一種隨時間推移可以有效擴展多個類型功能的方式。通過向基類中添加并實現(xiàn)某種功能,所有的派生類都將立即擁有該功能。而向接口中添加一個成員,則會破壞所有實現(xiàn)了該接口的類。它們不會包含新的方法,并且不會再通過編譯。每一個具體的類型都必須更新自己,來實現(xiàn)新的成員。
在抽象基類和接口之間做選擇,實際上是一個如何隨著時間的推移更好地支持抽象的問題。接口的特點是比較穩(wěn)定:我們將一組功能封裝在一個接口中,作為其他類型的實現(xiàn)合同?;悇t可以隨著時間的推移進行擴展。這些擴展將成為每個派生類的一部分。
上述兩種模型可以混合使用,從而允許類型在支持多個接口的同時,可以重用實現(xiàn)代碼。一個典型的例子是System.Collections.CollectionBase。該類提供了一個基類,使用它可以避免.NET集合類中缺乏類型安全的問題。同時,它也實現(xiàn)了幾個我們需要的接口:IList、ICollection和IEnumerable。另外,它還提供了一些受保護的方法,我們可以重寫它們來定制一些自己需要的行為。IList接口包含的Insert()方法會將一個新的對象添加到集合中。不用提供我們自己的Insert()實現(xiàn),我們就可以通過重寫CollectionBase類的OnInsert()或者OnInsertCcomplete()虛方法來處理一些事件。
public class IntList : System.Collections.CollectionBase
{
protected override void OnInsert( int index, object value )
{
try
{
int newValue = System.Convert.ToInt32( value );
Console.WriteLine( "Inserting {0} at position {1}",
index.ToString(), value.ToString());
Console.WriteLine( "List Contains {0} items",
this.List.Count.ToString());
}
catch( FormatException e )
{
throw new ArgumentException(
"Argument Type not an integer",
"value", e );
}
}
protected override void OnInsertComplete( int index,
object value )
{
Console.WriteLine( "Inserted {0} at position {1}",
index.ToString( ), value.ToString( ));
Console.WriteLine( "List Contains {0} items",
this.List.Count.ToString( ) );
}
}
public class MainProgram
{
public static void Main()
{
IntList l = new IntList();
IList il = l as IList;
il.Insert( 0,3 );
il.Insert( 0, "This is bad" );
}
}
上述代碼創(chuàng)建了一個整數(shù)數(shù)組鏈表,并使用IList接口指針往集合中添加兩個不同的值。通過重寫OnInsert()方法,IntList類可以測試插入值的類型,如果其類型不是整數(shù),它就會拋出一個異常?;悶槲覀兲峁┝四J的實現(xiàn),并設置了一些掛鉤(hook)供我們定制派生類的行為。
CollectionBase基類為我們提供了一個可用的實現(xiàn)。我們基本上不需要編寫很多代碼,因為可以使用基類中提供的通用實現(xiàn)。但是IntList的公有API來自于CollectionBase實現(xiàn)的接口:IList、ICollection和IEnumerable。CollectionBase為我們提供了這些接口的通用實現(xiàn)。
下面談談將接口用做參數(shù)和返回值的情況。一個接口可以被任意數(shù)量的無關類型實現(xiàn)。針對接口的編碼方式(coding to interface)為其他開發(fā)人員提供了比針對基類型的編碼方式(coding to base class type)更大的靈活性。這很重要,因為.NET環(huán)境將類型繼承層次限定為單繼承。
下面兩個方法執(zhí)行的是同樣的任務:
public void PrintCollection( IEnumerable collection )
{
foreach( object o in collection )
Console.WriteLine( "Collection contains {0}",
o.ToString( ) );
}
public void PrintCollection( CollectionBase collection )
{
foreach( object o in collection )
Console.WriteLine( "Collection contains {0}",
o.ToString( ) );
}
第2個方法的可重用性比較差,它不能和Arrays、ArrayLists、DataTables、Hashtables、ImageLists或其他很多集合類一起使用。將接口作為方法的參數(shù)類型不僅適應面廣,而且易于重用。
使用接口為一個類定義API還會為我們提供更大的靈活性。例如,許多應用程序都使用DataSet在應用程序的組件之間傳遞數(shù)據(jù)。這樣,就很容易將代碼像如下一樣寫死:
public DataSet TheCollection
{
get { return _dataSetCollection; }
}
這會使我們很容易在將來遇到問題。比如,在未來的某個時候,我們可能不希望向外界提供DataSet,轉而提供DataTable或者DataView,甚至是創(chuàng)建自定義的對象。所有這些改變都會破壞現(xiàn)有的代碼。當然,我們可以改變參數(shù)類型,但是那會改變類型的公有接口。改變一個類的公有接口,會導致我們對龐大的系統(tǒng)做很多改變。該公有屬性被訪問的所有地方,都需要進行改變。
第2個問題更為直接和棘手:DataSet類提供有許多方法可以改變其中包含的數(shù)據(jù)。這樣,類型用戶便可能刪除其中的表,修改其中的列,甚至替換其中的每一個對象。那肯定不會是我們想要的結果。幸運的是,我們可以通過返回期望給用戶使用的接口(而非返回整個DataSet對象引用),來限制類型用戶的能力。DataSet支持IListSource接口,可作數(shù)據(jù)綁定之用:
using System.ComponentModel;
public IListSource TheCollection
{
get { return _dataSetCollection as IListSource; }
}
IListSource接口允許用戶通過GetList()方法來查看其中的數(shù)據(jù)。它還有一個ContainsListCollection屬性允許用戶判斷集合的整體結構。使用IListSource接口,可以訪問DataSet中的單個條目,但是其整體結構不能被改變。另外,調(diào)用者也不能通過刪除約束或者添加功能,來使用DataSet上的方法改變其中數(shù)據(jù)上可用的行為。
當使用類將屬性提供給外界時,它實際上會把整個類的接口暴露給外界。通過使用接口,我們可以選擇只提供那些期望給用戶使用的方法和屬性。用來實現(xiàn)接口的類屬于實現(xiàn)細節(jié),它會隨著時間的推移而改變(參見條款23)。
此外,不相關的類型可以實現(xiàn)同樣的接口。假設我們編寫了一個應用程序來管理員工、客戶和廠商。至少在類層次中,它們之間沒有關聯(lián)。但是,它們共享著某種相同的功能。它們都有名稱,我們可能會在一些Windows控件中顯示這些名稱。
public class Employee
{
public string Name
{
get
{
return string.Format( "{0}, {1}", _last, _first );
}
}
// 忽略其他細節(jié)。
}
public class Customer
{
public string Name
{
get
{
return _customerName;
}
}
// 忽略其他細節(jié)。
}
public class Vendor
{
public string Name
{
get
{
return _vendorName;
}
}
}
Employee、Customer和Vendor三個類不應該共享一個基類。但是它們共享著一些屬性:名稱(如上面的代碼所展示)、地址和聯(lián)系電話。我們可以將這些屬性放在一個接口中:
public interface IContactInfo
{
string Name { get; }
PhoneNumber PrimaryContact { get; }
PhoneNumber Fax { get; }
Address PrimaryAddress { get; }
}
public class Employee : IContactInfo
{
// 忽略實現(xiàn)。
}
這個新的接口可以簡化我們的編程任務,因為它允許我們創(chuàng)建相同的函數(shù)來操作不相關的類型:
public void PrintMailingLabel( IContactInfo ic )
{
// 忽略實現(xiàn)。
}
上面的函數(shù)可以應用于所有實現(xiàn)了IContactInfo接口的類型。Employee、Customer和Vendor類型都可以作為上述函數(shù)的參數(shù),因為它們都實現(xiàn)了該接口。
有時候,使用接口還可以幫助我們避免結構類型的拆箱(unbox)代價。當我們將結構實例放入一個裝箱對象時,該裝箱對象實際上支持結構支持的所有接口。當通過接口指針來訪問該結構時,我們不必拆箱即可訪問到內(nèi)部的數(shù)據(jù)。下面的例子展示了一個結構,其中定義了一個鏈接和一個描述:
public struct URLInfo : IComparable
{
private string URL;
private string description;
public int CompareTo( object o )
{
if (o is URLInfo)
{
URLInfo other = ( URLInfo ) o;
return CompareTo( other );
}
else
throw new ArgumentException(
"Compared object is not URLInfo" );
}
public int CompareTo( URLInfo other )
{
return URL.CompareTo( other.URL );
}
}
由于URLInfo實現(xiàn)了IComparable接口,因此我們可以創(chuàng)建一個URLInfo對象的排序鏈表。將URLInfo結構添加到鏈表中時,它會被裝箱。但是Sort()方法不需要對排序過程中需要比較的兩個對象進行拆箱,即可調(diào)用CompareTo()方法。當然,我們?nèi)匀恍枰獙ζ渲凶鳛閰?shù)的那個對象(other)進行拆箱,但是對于調(diào)用IComparable.CompareTo()方法時左邊的那個對象,則不需要拆箱。
綜上所述,基類描述并實現(xiàn)了一組相關類型間共用的行為。接口則描述了一組比較緊湊的功能,供其他不相關的具體類型來實現(xiàn)。二者都有自己的用武之地。類定義了我們要創(chuàng)建的類型。接口以功能分組的形式描述了那些類型的行為。如果理解好二者之間的差別,我們便可以創(chuàng)建更富表現(xiàn)力、更能應對變化的設計。應該使用類層次來定義相關的類型,然后讓它們實現(xiàn)不同的接口,以便通過接口向外界提供功能。