第2章
API設計
在編寫自己的類型時,要設計該類型的 API,而這些API實際上就是你與其他開發者互相溝通的一種管道。你應該把公開釋出的構造函數、屬性及方法寫得好用一些,讓使用這些 API 的開發者很容易就能編出正确的代碼。要想令 API 更加健壯,就必須從許多方面來考慮這個類型。例如,其他開發者會如何建立該類型的執行個體?你怎樣通過方法與屬性把該類型所具備的功能展示出來?該類型的對象應該怎樣觸發相應的事件或調用其他的方法來表示自己的狀态發生了變化?不同的類型之間具備哪些共同的特征,這些特征又應該如何展現?
第11條:不要在API中提供轉換運算符
轉換運算符使得某個類型的對象可以取代另一種類型的對象。所謂可以取代,意思是說能夠當成另一種類型的對象來使用。這當然有好處,例如,派生類的對象可以當成基類的對象來用。幾何圖形(Shape)就是個很經典的例子。如果用Shape充當各種圖形的基類,那麼矩形(Rectangle)、橢圓(Ellipse)及圓(Circle)等圖形就都可以繼承該類。這樣的話,凡是需要使用Shape對象的地方,就都可以傳入Circle等子類對象,而且在Shape對象上執行某些方法時,程式還可以根據該對象所表示的具體圖形展現出不同的行為,也就是會産生多态的效果。之是以能夠這樣用,是因為Circle是一種特殊的 Shape。
對于你建立出的新類來說,某些轉換操作是可以由系統自動執行的。例如,凡是需要用到System.Object執行個體的地方,系統都允許開發者用這個新類的對象來代替object,因為不管這個新類是什麼類型,它都是從 .NET 類型體系中最根本的那個類型(也就是System.Object)中派生出來的。同理,如果你的類實作了某個接口,那麼凡是需要用到該接口的地方,就都可以使用該類。此外,如果該接口還有其他的基接口,或是這個新類與 System.Object之間還隔着其他的一些基類,那麼凡是用到基接口與基類的地方也都可以使用這個類來代替。另外要注意,C# 語言還能在許多種數值之間轉換。
如果給自己的類型定義了轉換運算符,那就相當于告訴編譯器可以用這個類型來代替目标類型。然而這種轉換很容易引發某些微妙的錯誤,因為你自己的這種類型不一定真的能夠像目标類型那樣運作。有些方法在處理目标類型的對象時會産生附帶效果,令對象的狀态發生變化,而這種效果可能不适用于你自己所寫的類型。還有一個更嚴重的問題在于,開發者可能并沒有意識到,它操作的并不是自己想操作的那個對象,而是由轉換運算符所産生的某個臨時對象。在這樣的對象上操作是沒有意義的,因為這種對象很快就會讓垃圾收集器給回收掉。最後還有一個問題:轉換運算符是根據對象的編譯期類型來觸發的,為此,開發者在使用你的類型時可能必須經過多次轉換才能觸發該運算符,而這樣寫會導緻代碼難于維護。
如果允許開發者把其他類型的對象轉換為本類型的對象,那麼就應該提供相應的構造函數,這樣能夠使他們更為明确地意識到這是在建立新的對象。假如不這樣做,而是通過轉換運算符來實作,那麼可能會出現難以排查的問題。以一個繼承體系為例,在該體系中,Circle(圓形)類與Ellipse(橢圓)類都繼承自Shape類,之是以這樣做,是因為假如Circle繼承自Ellipse,那麼在編寫實作代碼時,可能會遇到一些困難,是以,我們決定不讓Circle從Ellipse中繼承。然而你很快就會發現,在幾何意義上,每一個圓形其實都可以說成是特殊的橢圓。反之,某些本來用于處理圓形的邏輯其實也可以用來處理某些橢圓。
意識到這個問題之後,可能就想添加兩種轉換運算符,以便在這兩種類型的對象之間進行轉換。由于每一個圓形都可以當作特殊的橢圓,是以,會添加一種隐式的(implicit)轉換操作符,根據Circle對象建立與之相仿的Ellipse對象。凡是本來應該使用Ellipse但卻出現了Circle對象的地方,都會自動觸發這種轉換操作。與之相反,假如把轉換操作設計成顯式的(explicit)操作,那麼這種轉換操作就不會自動觸發,而是必須由開發者在源代碼裡通過cast(強制類型轉換)來明确地觸發。

有了implicit轉換運算符之後,就可以在本來需要使用Ellipse的地方使用Circle類型的對象。這會自動引發轉換,而無須手工觸發:
這很好地展現了什麼叫作替換:Circle類型的對象可以代替Ellipse對象出現在需要用到Ellipse的地方。ComputeArea函數是可以與替換機制搭配起來使用的。但是,另外一個函數就沒這麼幸運了:
這樣寫實作不出正确的效果。由于Flatten()方法需要用Ellipse類型的對象作參數,是以,編譯器必須把傳入的Circle對象設法轉換成Ellipse對象。你定義的implicit轉換運算符恰好可以實作這種轉換,于是,編譯器會觸發這樣的轉換,并把轉換得到的Ellipse對象當成參數傳給Flatten()方法。Ellipse對象隻是個臨時的對象,它雖然會為Flatten()方法所修改,但是修改過後立刻就變成了垃圾,進而有可能遭到回收。Flatten()方法确實展現出了它的效果,但這個效果是發生在臨時對象上的,而沒有影響到本來應該套用該效果的那個對象,也就是名為c的Circle對象。
假如把轉換操作符從implict改為explicit,那麼開發者就必須先将其明确地轉為Ellipse對象,然後才能傳給Flatten()方法:
這樣改會強迫開發者必須把Circle對象明确地轉換成Ellipse對象,但由于傳進去的依然是臨時對象,是以,Flatten()方法還是會像剛才那樣,在這個臨時對象上進行修改,而這個臨時對象很快就會遭到丢棄,原來的c對象則依然保持不變。如果能在Ellipse類型中提供構造函數,令其根據Circle對象來建立Ellipse對象,那麼開發者就會通過構造函數來編寫程式,這樣的話,很快就會發現代碼中的錯誤:
許多開發者一看到這樣兩行代碼,立刻就能意識到傳給Flatten()方法的Ellipse對象很快就會丢失。為了解決這個問題,他們會建立變量來引用Ellipse對象。
經過Flatten()方法處理的橢圓現在會儲存到變量e中,而不會像早前那樣立刻變成垃圾。用構造函數取代轉換操作符非但不會減少程式的功能,反而可以更加明确地展現出程式會在什麼樣的地方建立什麼樣的對象。(熟悉 C++ 語言的開發者應該能注意到,C# 并不會通過調用構造函數來實作隐式或顯式的轉換,隻有當開發者明确用new運算符來建立對象時,它才會去調用構造函數,除此以外的其他情況C#都不會自動幫你調用構造函數。是以,C#中的構造函數沒有必要拿explicit關鍵字來修飾。)
如果你編寫的轉換運算符不是像早前的例子那樣把一種對象轉換成另一種對象,而是把對象内部的某個字段傳回給了調用方,那麼就不會出現臨時對象的問題了,但是,這樣做會有其他的問題,因為這種做法嚴重地破壞了類的封裝邏輯,使得該類的使用者能夠通路到本來隻應該在這個類的内部所使用的對象。本書第17條解釋了為什麼要避免這種做法。
轉換運算符可以用來實作類型替換,但這樣做可能會讓程式出現問題,因為使用者總是覺得他可以把你所寫的類型用在本來需要使用另一個類型的地方。如果他真的這樣用了,那麼他所修改的可能隻是轉換運算符所傳回的某個臨時對象或内部字段,而這種效果無法反映到他本來想要修改的對象上。經過修改的臨時對象很快就會遭到回收。如果他沒有把那個對象保留下來,那麼修改的結果就無法展現出來。這種 bug 很難排查,因為涉及對象轉換的這些代碼是由編譯器自動生成的。總之,不要在 API 中公布類型轉換運算符。
第12條:盡量用可選參數來取代方法重載
C# 允許調用者根據位置或名稱來給方法提供實際參數(argument,簡稱實參),這樣的話,形式參數(formal parameter,簡稱形參)的名稱就成了公有接口的一部分。如果類型的設計者修改了公有接口中某個方法的形參名稱,那麼可能會導緻早前用到該方法的代碼無法正常編譯。為了避免這個問題,調用方法的人最好不要使用命名參數來進行調用(或者說,最好不要用指定參數名稱的辦法來進行調用),而設計接口的人也應該注意,盡量不要修改 公有或受保護(protected)方法的形參名稱。
C# 語言的設計者提供這項特性當然不是為了故意給程式設計制造困難,而是基于一定的原因,而且,它确實有一些合理的用法。例如,把命名參數與可選參數相結合,能夠讓許多 API 變得清晰,尤其是給 Microsoft Office 設計的那些 COM API。現在考慮下面這段代碼,它通過經典的 COM 方法來建立 Word 文檔,并向其中插入一小段文本:
這段程式很小,而且并沒有什麼特别有意義的功能。然而,此處的重點在于,它把Type.Missing對象接連用了 4 次。對于其他一些涉及 Office interop(互操作)的應用程式來說,它們使用Type.Missing對象的次數可能遠遠多于這個例子。這些寫法會讓應用程式的代碼顯得很雜亂,進而掩蓋了軟體真正想要實作的功能。
C#語言之是以引入可選參數與命名參數,在很大程度上正是想要消除這些雜亂的寫法。利用可選參數這一機制,Office API 中的Add()方法能夠給參數指定預設值,以便在調用方沒有明确為該參數提供數值的情況下,直接使用預設的Type.Missing來充當參數值。改用這種寫法之後,剛才那段代碼就變得很簡單了,而且讀起來也相當清晰:
當然,你可能既不想讓所有的參數都取預設值,也不想逐個去指定這些參數,而是隻想明确給出其中某幾個參數的取值。還以剛才那段代碼為例。如果建立的不是 Word 文檔,而是一個網頁(或者說 Web 頁面),那麼你可能要單獨指出最後一個參數的取值,而把前三個參數都設為它們的預設值。在這種情況下,你可以通過命名參數來調用Add()方法,也就是隻把需要明确加以設定的參數給寫出來,并指出它的取值:
由于 C# 允許開發者按照參數名稱來調用方法,是以,對于其參數帶有預設值的 API 來說,調用者可以隻把那些自己想要明确指定數值的參數給寫出來。這一特性使得 API 的設計者不用再去提供很多個重載版本。如果不使用命名參數及可選參數等特性,那麼對于 Add() 這樣帶有 4 個參數的方法來說,就必須建立 16 個互相重載的版本才能實作出類似的效果。有一些 Office API 的參數多達 16 個,由此可見,命名參數與可選參數确實極大地簡化了 API 的設計工作。
剛才那兩個例子在調用Add()方法的時候,都為參數指定了ref修飾符,不過,C# 4.0 修改了規則,允許開發者在 COM 環境下省略這個ref。接下來的Range()方法用的就是這種寫法。如果把ref也寫上去,那麼反而會誤導閱讀這段代碼的人,而且,在大多數産品代碼中,調用Add()方法時所傳遞的參數都不應該添加ref修飾符。(剛才那兩個例子之是以寫了ref,是想反映出Add()方法的真實API簽名。)
筆者以COM與Office API為例示範了命名參數與可選參數的正當用途,然而,這并不意味着它們隻能用在涉及 Office interop 的應用程式中,實際上,也無法禁止開發者在除此以外的其他場合使用這些特性。例如,其他開發者在調用你所提供的 API 時,有可能通過命名參數來進行調用,而你無法禁止他們這麼用。
比如,有下面這個方法:
調用者可以通過命名參數來明确地展現出自己所提供的這兩個值分别對應于哪個參數,進而避開了到底是姓在前還是名在前的問題。
調用方法的時候,把參數的名稱标注出來可以讓人更清楚地看到每個參數的含義,而不至于在順序上産生困惑。如果把參數的名稱寫出來,能夠令閱讀代碼的人更容易看懂程式的意思,那麼開發者就很願意采用這種寫法。在多個參數都是同一種類型的情況下,更應該像這樣來調用方法,以厘清這些值所對應的參數。
修改參數的名稱會影響到其他代碼。參數名稱隻儲存在 MSIL 的方法定義中,而不會同時儲存在調用方法的地方,是以,如果你修改了參數的名稱,并且把修改後的元件重新釋出了出去,那麼對于早前已經用舊版元件編譯好的程式集來說,其功能并不會受到影響。但是,如果開發者試着用你釋出的新版元件來編譯他們早前所寫的代碼,那麼就會出現錯誤,隻有那些已經根據舊版元件編譯好了的程式集才能夠繼續與新版元件搭配着運作。開發者雖然不願意見到這種錯誤,但并不會是以太過責怪你。舉個例子,假如你把SetName()方法的參數名改成下面這個樣子:
然後,你把修改後的代碼編譯好,并将程式集作為更新檔釋出了出去。那麼,已經編譯好的其他程式集依然能夠正常調用SetName()方法,即便它們的代碼當初是通過指定參數名稱的方式進行調用的,也依然不會受到影響。但是,如果開發者想把手中的代碼依照你所釋出的新版元件來進行編譯,那麼就會發現這些代碼無法編譯:
無法編譯的原因在于,參數的名稱已經變了。
此外,修改參數的預設值也需要重新編譯代碼,隻有這樣,才能把修改後的預設值套用到使用這個方法的代碼上。如果你把修改後的代碼加以編譯,并作為更新檔釋出出去,那麼,對于那些已經根據舊版元件編譯好的程式集來說,他們所使用的預設值還是舊版的預設值。
其實,你也不希望使用你這個子產品的開發者因為方法發生變化而遇到困難。是以,你必須把參數的名稱也當作公有接口的一部分來加以考慮,并且要意識到:如果修改了這些參數的名字,那麼其他開發者在根據新版子產品來編譯原有的代碼時,就會出現錯誤。
此外,給方法添加參數也會導緻程式出錯,隻不過這個錯誤是出現在運作期的。就算新添加的參數帶有預設值,也還是會讓程式在運作的時候出錯。這是因為,可選參數的實作方式其實與命名參數類似,在 MSIL 中,某個參數是否有預設值以及預設值具體是什麼都儲存在定義函數的地方。遇到函數調用語句時,系統會判斷調用者所沒有提供的這些參數有沒有預設值可以使用,如果有,那麼就以這些預設值來進行調用。
是以,如果給子產品中的某個方法添加了參數,那麼早前已經編譯好的程式是沒有辦法與新版子產品一起運作的,因為它們在編譯的時候并不知道這個方法還需要使用你後來添加的這幾個參數,于是,等運作到這個方法的時候就會出錯。如果新添加的參數帶有預設值,那麼還沒有開始編譯的代碼是不會受到影響的。
看完這些解釋之後,你應該更清楚這一條的标題所要表達的意思了。為子產品編寫第1版代碼時,盡量利用可選參數與命名參數等機制來設計API,以便涵蓋同一個函數在參數上的不同用法。這樣一來,就無須針對這些用法分别進行重載。但是,如果你把這個子產品釋出出去之後又發現需要自己添加參數,那麼就隻好建立重載版本,唯有這樣,才能保證早前按舊版子產品建構的應用程式依然可以與新版子產品協同運作。另外要注意,更新子產品的時候,不應該修改參數的名稱,因為當你把子產品的第1版釋出出去之後,這些名稱實際上已經成了 public 接口的一部分。
第13條:盡量縮減類型的可見範圍
并不是所有人都需要看到程式中的每一個類型,是以無須将這些類型都設為public(公有)。你應該在能夠實作正常功能的前提下,盡量縮減它們的可見範圍。其實這個範圍通常比你所認為的要小,因為 internal(内部)與private(私有)類也可以實作公有接口,而且即便公有接口聲明在private類型中,其功能也依然可以為客戶代碼所使用。
由于 public 類型建立起來相當容易,是以,很多人不假思索地就把類型設計為 public。其實,許多獨立的類完全可以設計成 internal 類。你還可以把某些類嵌套在其他類中,并将這些類設計成 protected(受保護)類或 private 類,以進一步減少該類的暴露範圍。這個範圍越小,将來在更新整個系統時所需修改的地方就越少。把能夠通路到某個類型的地方變得少一些,将來在修改這個類型時,必須同步做出調整的地方就能相應地少一些。
隻公布那些确實需要公布的類型。在用某個類型來實作公有接口的時候,應該盡量縮減該類型的可見範圍。.NET Framework 中随處可見的 Enumerator 模式就是遵循着這條原則來設計的。System.Collections.Generic.List類中含有一個名為Enumerator的結構體,這個結構體實作了IEnumerator接口:
在使用List程式設計的時候,你并不需要知道其中有這樣一個List.Enumerator結構體,隻需要知道在List對象上調用GetEnumerator()方法會得到一個實作了IEnumerator接口的對象。至于這個對象究竟是什麼類型以及該類型是怎樣實作IEnumerator接口的,則屬于細節問題。.NET Framework的設計者在其他幾種集合類上也沿用了這一模式,例如Dictionary類中包含名為Dictionary .Enumerator的結構體,Queue類中包含名為 Queue.Enumerator的結構體。
如果把Enumerator這樣的類型設計成private類型,那麼會帶來很多好處。首先,這使得List類能夠在不需要告知使用者的前提下,改用另一種類型來實作IEnumerator接口,而無須擔心已有的程式及代碼會受到影響。使用者之是以能夠使用由GetEnumerator()方法所傳回的 enumerator,并不是因為他們完全了解這個 enumerator 是由什麼類型來實作的,而是因為他們明白:無論這個 enumerator 是由什麼類型來實作的,都必然會遵循IEnumerator接口所拟定的契約。在早前那個例子中,實作相關接口的enumerator其實是public結構體,之是以沒有設計成 private,僅僅是為了提升性能,而不是鼓勵你去直接使用這些類型本身。
有一種辦法能夠縮減類型的可見範圍,但很多人都忽視了它,這就是建立内部類,因為大多數程式員總是直接把類設為 public,而沒有考慮除此之外還有沒有其他選項。筆者寫這一條是想提醒你,以後不要直接把類型設為 public,而要仔細思考這個新類型的用法,看它是開放給所有客戶使用的,還是主要用在目前這個程式集的内部。
由于可以通過接口來釋出功能,是以,内部類的功能依然可以為本程式集之外的代碼所使用,因為很容易就能讓這個類實作相關的接口,并使得外界通過此接口來使用本類的功能(參見第 17 條)。有些類型不一定非要設為public,而是可以用同時實作了好幾個接口的内部類來表示。如果這樣做了,那麼将來可以很友善地拿另一個類來替換這個類,隻要那個類也實作了同一套接口就行。比方說,我們編寫下面這個類,用來驗證電話号碼:
它正常地運作了好幾個月,直到有一天,你發現自己還需要驗證其他格式的電話号碼。此時,這個PhoneValidator就顯得不夠用了,因為它的代碼是固定的,隻能按照美國的電話号碼格式來執行驗證。現在,軟體不僅要驗證美國的電話号碼,而且必須能夠驗證國際上的電話号碼,可是,你又不想把這兩塊驗證邏輯耦合得過于緊密,于是,可以把新的邏輯放到原有的PhoneValidator類之外。為此,需要建立一個接口來驗證任意電話号碼:
接下來,要讓已有的那個類實作上述接口。此時,可以考慮将其從public類改為 internal類:
最後,建立新的類,把驗證國際電話号碼的邏輯寫到這個類中:
為了把整套方案實作好,還需要提供一種手段,以便根據電話号碼的類型來确定相關的驗證邏輯所在的類,并建立該類的對象。這種需求可以用工廠模式來做。本程式集以外的地方隻知道工廠方法所傳回的對象實作了IPhoneValidator接口,而看不到該對象所屬的具體類型。那些具體類型分别用來處理世界各地的電話号碼格式,它們僅在本程式集之内可見。這套方案使得我們可以很友善地針對各個地區的電話号碼來編寫相應的格式驗證邏輯,同時,又不會影響到系統内的其他程式集。由于這些具體類型的可見範圍較小,是以,更新并擴充整個系統時,所需修改的代碼也會少一些。
也可以建立名為PhoneValidator的public抽象基類,把每一種具體的電話号碼驗證器都需要用到的算法提取到該類中。這樣的話,外界就可以通過這個基類來使用它所釋出的各種功能了。在剛才的例子中,這些具體的PhoneValidator之間,除了驗證電話号碼之外,幾乎沒有其他相似的功能,是以,最好是将其表述成接口,假如它們之間确實有其他一些相似的功能,那麼應該将這些功能以及實作這些功能所需的通用代碼提取到抽象基類中。無論采用哪種做法,你所要公開的類型數量都比直接把各種具體的驗證器設為public要少一些。
public類型變少之後,外界能夠通路的方法也會相應地減少,這樣的話,方法的測試工作就可以變得較為輕松。由于API公布的是接口,而不是實作該接口的具體類型,是以,在做單元測試的時候,可以構造一些實作了該接口的 mock-up 對象(模拟對象)或stub對象(替代對象),進而輕松地完成測試。
向外界公布類和接口相當于對其他開發者做出了約定或承諾,是以,在後續的各個版本中,必須繼續保持當初的 API 所宣稱的功能。API 設計得越繁雜,将來修改的餘地就越小,反之,如果能盡量少公布一些 public 類型,那麼将來就可以更加靈活地對相關的實作做出修改及擴充。
第14條:優先考慮定義并實作接口,而不是繼承
抽象基類可以作為類體系中其他類的共同祖先,而接口則用來描述與某套功能有關的一組方法,以便讓實作該接口的那些類型各自去實作這組方法。這兩種設計手法都很有用,但你必須知道它們分别适合用在什麼樣的地方。接口可以用來描述一套設計約定(design contract,設計契約),也就是說,它可以要求實作該接口的類型必須對接口中的方法做出相應的實作。與之相對,抽象基類描述的是一套抽象機制,從該類繼承出來的類型應該是彼此相關的一組類型,它們都會用到這套機制。有幾句老話雖然已經說爛了,但還是值得再說一遍:繼承描述的是類别上的從屬關系,乙類繼承自甲類意味着乙是一種特殊的甲;接口描述的是行為上的相似關系,乙類型實作了甲類型意味着乙表現得很像甲。這些話之是以反複有人提起,是因為它們很好地展現了繼承某個基類與實作某個接口之間的差別:某對象所屬的類型繼承自某個基類,意味着該對象就是那個基類的一種對象,而某對象所屬的類型實作了某個接口,則意味着該對象能夠表現出那個接口所描述的行為。
接口描述的是一套功能,這些功能合起來構成一份約定。可以在接口中規定一套方法、屬性、索引器及事件,使得實作該接口的非抽象類型必須為接口中所定義的這些元素提供具體的實作代碼。也就是說,它們必須實作接口所定義的每一個方法,而且要為接口所定義的每一個屬性及索引器實作出相應的通路器。此外,還必須把每一個事件都實作出來。在設計類型體系的時候,可以想一想,有哪些行為是能夠複用的,并把這些行為提取到接口中。在設計方法的時候,其參數類型及傳回值類型也可以設計成接口類型。彼此無關的多個類型完全可以實作同一個接口。對于其他開發者來說,實作你所提供的接口要比繼承你所提供的類更為容易。
接口本身無法給其中的成員提供實作代碼。它既不能含有實作代碼,也不能包含具體的資料成員,隻能用來規定實作該接口的類型所必須支援的功能或行為。可以針對接口建立擴充方法,使得該接口看起來好像真的定義了這些方法一樣。比方說,System.Linq.Enumerable類就針對IEnumerable接口提供了三十多個擴充方法,隻要對象所屬的類型實作了IEnumerable接口,那麼就可以在該對象上調用這些方法(參見《Effective C#》(第3版)第27條)。
抽象基類可以提供某些實作,以供派生類使用,當然它也能夠用來描述派生類所共同具備的行為。可以在其中指定資料成員與具體方法,并實作 virtual 方法、屬性、事件及索引器。可以把許多個派生類都有可能用到的方法放在基類中實作,以便讓派生類複用這些代碼,而無須分别去編寫。抽象基類的成員可以設為 virtual,也可以設為 abstract,還可以不用 virtual 修飾。抽象基類能夠給某種行為提供具體的實作代碼,而接口則不行。
通過抽象基類來複用實作代碼還有一個好處,就是如果給基類添加了新的方法,那麼所有派生類都會自動得到增強。這相當于把某項新的行為迅速推廣到繼承該基類的多個類型中。隻要給基類添加某項功能并予以實作,那麼所有派生類就立刻具備該功能。反之,給現有的接口中添加成員則有可能影響實作該接口的類型,因為它們不一定實作了這個新的成員,如果沒有實作,那麼代碼就無法編譯了。要想讓代碼能夠編譯,就必須更新這些類型,把接口中添加的新成員給實作出來。另一種辦法是從原接口中繼承一個新的接口,并把功能添加到新的接口中,這樣的話,實作了原接口的類型就不會受到影響了。
選用抽象基類還是選用接口,要看你的抽象機制是否需要不斷變化。接口是固定的,一旦釋出出來就會形成一套約定,以要求實作該接口的類型都必須提供其中所規定的功能。與之相對,基類則可以随時變化,對它所做的擴充會自動展現在每一個繼承自該類的子類上。
這兩種思路可以合起來使用,也就是把基本的功能定義在接口中,讓使用者在編寫他們自己的類型時去實作這些接口,同時在其他類中,對接口予以擴充,使得使用者實作的類型能夠自動使用你所提供的擴充功能。這就相當于讓客戶所編寫的類型自動複用了你為這些擴充功能所編寫的實作代碼,這樣一來,他們就不用再重新編寫這些代碼了。.NET Framework 中的IEnumerable接口與System.Linq.Enumerable類就明确地展現出這一點,前者定義了一些基本的功能,而後者則針對前者提供了相當多的擴充方法。像這樣把基本功能與擴充功能分開有很大的好處,因為IEnumerable接口的設計者可以隻把最基本的功能定義在接口中,而把較為進階的功能或是以後出現的新功能以擴充方法的形式定義在System.Linq.Enumerable這樣的類中,這既不會破壞早前已經實作了IEnumerable接口的類型,又可以令那些類型自動具備擴充方法所提供的功能,于是,那些類型就不用再為這些擴充功能去編寫實作代碼了。
下面舉一個例子來示範這種用法。比方說,開發者可以編寫WeatherDataStream類,并讓該類實作.NET Framework所提供的IEnumerable接口:
為了把多項天氣觀測資料表示成一個序列,我們設計WeatherDataStream類,并讓它實作IEnumerable接口。這意味着,該類必須建立兩個方法,一個是泛型版的GetEnumerator方法,另一個是經典的GetEnumerator方法。該類采用明确指定接口(Explicit Interface Implementation)的方式來實作後者,這使得一般的客戶代碼(也就是沒有采用明确指出接口的辦法來調用GetEnumerator的代碼)會解析到前者,也就是解析到泛型版的接口方法上。該方法會直接把元素類型視為T(也就是本例中的 WeatherData),而不會像後者那樣僅僅将其視為普通的System.Object。
由于WeatherDataStream類實作了IEnumerable接口,是以,它自動支援由System.Linq.Enumerable類為該接口所定義的擴充方法。這意味着,我們可以把 WeatherDataStream當成資料源,并在它上面進行LINQ查詢:
LINQ查詢語句會編譯成方法調用代碼,比方說,剛才那條查詢語句就會轉譯成下面這種方法調用代碼:
代碼中的Where方法和select方法看上去好像屬于IEnumerable接口,但實際上并不是。之是以覺得它們屬于該接口,是因為可以作為該接口的擴充方法而得到調用,實際上,它們是定義在System.Linq.Enumerable中的靜态方法。編譯器會把剛才那行方法調用代碼轉變成下面這種靜态方法調用語句(隻用來做示範,并不是說真的要這麼寫):
上面這個例子讓我們看到:接口本身确實不能包含實作代碼,然而其他類可以給該接口提供擴充方法,使得這些方法看起來好像真的是定義并實作在該接口中的。System.Linq.Enumerable類正是采用了這種寫法為IEnumerable接口建立了許多擴充方法。
說起擴充方法,我們還會想到參數與傳回值的類型其實也可以聲明為接口類型。同一個接口可以由多個互不相關的類型來實作。針對接口來設計要比針對基類來設計顯得更加靈活,其他開發者用起來也更加友善。這是很重要的一點,因為 .NET 的類型體系隻支援單繼承。
下面這3個方法都能完成同樣的任務:
第一個方法最有用。凡是支援IEnumerable接口的類型其對象都可以充當該方法的參數。這意味着,除了WeatherDataStream之外,還可以用List、SortedList、數組以及任何一次LINQ查詢所得到的結果來充當方法參數。第二個方法支援很多類型,但它寫得比第一個稍差,因為它用的是不帶泛型的普通IEnumerable接口。第三個方法能夠複用的範圍最小,因為它是針對具體的WeatherDataStream類而寫的,是以,不支援Array、ArrayList、DataTable、Hashtable、ImageList.ImageCollection以及其他許多集合類。
用接口來定義類中的 API還有個好處,就是能讓這個類用起來更加靈活。比方說,WeatherDataStream類的API中就有這樣一個方法,它傳回由WeatherData對象所構成的集合。有人可能會把該方法寫成下面這樣:
這樣寫,以後修改起來就比較困難了,因為将來我們可能想把該方法所傳回的集合從 List改為普通的數組,或是改為經過排序的SortedList。到了那個時候,你會發現,原來依照List所編寫的代碼必須做出相應的調整。修改某個 API 的參數類型或傳回值類型相當于修改了這個類的公有接口,而修改了公有接口又意味着整個系統中有很多地方需要相應地更新,早前通過這個接口來通路該類的代碼現在必須遵照修改後的參數類型或傳回值類型來使用此接口。
這樣寫還有個更嚴重的缺陷,因為List類所提供的許多方法都可以修改清單中的資料,也就是說,拿到了List對象的人可以删除或修改清單中的對象,甚至把整個清單的内容全都換掉,在絕大多數情況下,這都不是WeatherDataStream類的設計者想要看到的效果。為此,可以設法限制使用者在這個清單上所能執行的操作。不要直接把指向内部對象的引用傳回給使用者,而是以接口的形式傳回這個對象,使得使用者隻能通過此接口所支援的功能來操作該對象。在本例中,這個接口是IEnumerable。
如果你寫的類型直接把屬性所在的類公布給外界,那麼相當于允許外界使用那個類的各種功能來操作該屬性,反之,如果公布的僅僅是屬性所在的接口,那麼外界就隻能在這個屬性上使用此接口所支援的方法與屬性了。與此同時,實作接口的那個類可以自行修改其實作細節,而無須擔心使用者所寫的代碼會受到影響(參見第 17 條)。
同一個接口可以由彼此無關的多個類型來實作。比方說,你的應用程式想把雇員、客戶及廠商的資訊給顯示出來,可是這些類型的實體彼此之間卻沒有關聯,至少從類的角度來看,它們不該處在同一個繼承體系中。盡管如此,這些類型之間還是确實有一些相似的功能,例如它們都有名字或名稱,而你的應用程式正需要将這些資訊顯示在控件中。
Employee(雇員)、Customer(客戶)及Vendor(廠商)這 3 個類不應該繼承自同一個基類,然而它們确實擁有一些相似的屬性,除了剛才示範的Name(名稱)之外,可能還包括位址與聯系電話。這些屬性可以提取到接口中:
這個接口可以簡化程式設計工作,讓你能夠用同一套邏輯來處理這些彼此不相幹的類型:
凡是實作了IContactInfo接口的實體都可以交給上面這個例程來處理,這意味着,該例程能夠支援Customer、Employee與Vendor等不同類型的對象。之是以如此,并不是因為這 3 個類型都繼承自某個基類,而是因為它們都實作了同一個接口,而且它們所共有的功能也已經提取到了該接口中。
接口還有個好處,就是可以不用對struct進行解除裝箱操作,進而能降低一些開銷。如果某個struct已經裝箱,那麼可以直接在它上面調用接口所具備的功能。也就是說,如果你是通過指向相關接口的引用來通路這個struct的,那麼系統就不用對其解除裝箱,而是能夠直接在這個已經裝箱的struct上進行操作。為了示範這種用法,我們定義下面這樣的結構體來封裝一個連結(URL)以及與該連結有關的一條描述資訊:
這個例子用到了C# 7的兩項新特性,其中一項是條件運算符的第一部分(也就是進行判斷的那一部分)所使用的模式比對表達式。這種寫法會判斷obj是否為URLInfo對象,如果是,就将其賦給other變量。另一項特性是條件運算符的第三部分(也就是當條件不成立時所執行的那一部分)所使用的throw表達式。如果obj不是URLInfo,那麼就抛出異常。這樣寫可以直接在條件運算符中抛異常,而無須單獨編寫語句。
由于URLInfo實作了IComparable及IComparable接口,是以,很容易就能建立一份經過排序的清單,使得其中的URLInfo之間按照一定的順序出現。即便是依賴老式IComparable接口的代碼,也依然能夠少執行一些裝箱與解除裝箱操作,因為客戶代碼可以把URLInfo對象當成老式的IComparable接口,并在它上面明确地調用非泛型版的CompareTo()方法,這樣做不會導緻系統給該對象執行解除裝箱操作。
基類可以描述彼此相關的一些具體類型所共同具備的行為,并對其加以實作,而接口則用來描述一套功能,其中的每項功能都自成一體,彼此無關的多個類型均可實作這套功能。這兩種機制都有各自的用途。你要建立的具體類型可以用一個一個的類來表示,而那些類型所具備的功能則可以提取到接口中。懂得類與接口的差別之後,就能做出更容易應對變化的設計方案了。彼此相關的一組類型可以納入同一個繼承體系,而不同體系的類型之間,如果有相似的功能,那麼這些功能可以描述成接口,使得這些類型全都實作這個接口。
第15條:了解接口方法與虛方法之間的差別
從表面上看,實作接口的方法與重寫類中的抽象函數似乎一樣,因為這兩種做法都是給聲明在另一個類型中的成員提供定義。然而事實并非如此。實作接口的方法與重寫類中的虛(virtual)函數是大不相同的。對基類中的 abstract 或 virtual 成員所做的實作也必須是 virtual 的,而對接口中的成員所做的實作則未必設為 virtual。當然,我們确實經常用 virtual來實作接口中的成員。接口方法可以明确地予以實作(或者說,顯式地予以實作),這相當于把它從類的公有API中隐藏了起來,除非調用者指名要調用這個接口方法,否則,它是不會納入考慮範圍的。總之,實作接口方法與重寫虛函數是兩個不同的概念,而且有着各自的用法。
某個類實作了接口方法之後,它的派生類還是可以修改該類所提供的實作邏輯。在這種情況下,基類對接口方法所做的實作實際上相當于一個挂鈎函數。
為了示範接口方法與虛方法之間的差別,我們先建立一個簡單的接口,并編寫一個類來實作該接口:
MyClass類的Message()方法成了該類公有API中的一部分,當然,這個方法也可以通過該類所實作的IMessage接口來調用。如果MyClass類還有子類,那麼情況會稍微複雜一點:
注意,筆者剛才在定義Message方法的時候,還用到了new關鍵字(對于這個關鍵字的用法參見《Effective C#》(第 3 版)第 10 條)。基類MyClass的Message()方法并不是virtual方法,是以,它的子類MyDerivedClass不能通過重寫該方法來提供自己的版本,而是隻能建立新的版本,這個版本雖然也叫Message,但它沒有重寫基類MyClass的Message方法,而是将其隐藏起來。不過,基類的Message方法仍然可以通過IMessage接口來調用:
如果在設計類的時候想實作某個接口,那麼意味着你寫的類必須通過相關的方法來履行接口中拟定的契約。至于這些方法到底應不應該設為 virtual 方法,則可以由你自己來把握。
我們現在回顧一下 C# 語言中與實作接口有關的一些規則。如果在聲明類時于它的基類型清單中寫出了某個接口,而這個類的超類也實作了那個接口,那麼就要思考接口中所拟定的成員究竟會對應到本類中的某個成員上,還是會對應到超類中的某個成員上。C#系統在判斷的時候,首先會考慮本類所給出的實作版本,其次才會考慮從超類自動繼承下來的版本。也就是說,它會先在本類的定義中尋找對接口中的某個成員所做的實作,如果找不到,那麼再從可以通路到的超類成員中尋找。同時要注意,系統會把 virtual 成員與 abstract 成員當成聲明它們的那個類型所具有的成員,而不是重寫它們的那個類型所具有的成員。
在許多情況下,都是先建立接口,然後在基類中實作它們,将來如果有必要的話,又會在子類中修改基類的行為。然而除此之外,還有另一種情況,就是基類不受控制,此時,可以考慮在子類中重新實作該接口:
在基類型清單中,添加了IMessage接口之後,這個子類的行為就和原來不同了。如果還是像早前那段代碼一樣,通過IMessage接口來調用Message方法,那麼會發現你調用的是子類的版本:
修改後的子類在書寫Message方法時,依然應該标上new關鍵字,以表示此處仍有問題需要注意(參見第 33 條)。可是即便這樣寫,子類也沒能完全屏蔽基類的Message方法,因為我們還是可以通過基類引用來通路這個方法:
如果想把通過基類引用所執行的方法調用也派發到實際的類型上,那麼可以考慮修改基類本身,将其中所實作的接口方法聲明為 virtual:
這樣寫會讓MyClass的所有子類(當然也包括這裡的MyDerivedClass)都能夠聲明它們自己的Message方法。重寫之後的版本總是能夠得到調用,無論是通過子類引用來通路、通過接口來通路還是通過基類引用來通路,都會産生同樣的效果。
如果你讨厭這種含有代碼的虛方法—或者說,更喜歡不含代碼的純虛方法—那麼可以稍稍修改基類,将其定義成 abstract(抽象)類,并把Message()方法也設為abstract:
這樣寫之後,基類便可以隻宣稱自己實作某個接口,而不用真的去為接口中的方法編寫代碼。如果基類用 abstract 方法來實作接口中的對應方法,那麼從該類繼承下來的具體子類就必須重寫這些成員,以提供各自的版本。具體到本例來看,MyClass基類宣稱自己實作了IMessage接口,但并沒有針對接口中的Message()方法編寫實作代碼,而是把這些代碼留給具體的子類去寫。
還有一種辦法可以部分解決這個問題。可以讓基類的接口方法去調用該類的某個 virtual 方法,并讓子類去重寫這個 virtual 方法。例如,MyClass類可以改成
這樣修改之後,凡是繼承MyClass的類都可以重寫OnMessage()方法,使得與自身有關的一些邏輯能夠在程式執行Message()的過程中順帶運作。這種用法在其他地方也能見到,如基類在實作IDisposable接口的Dispose()方法時(參見《Effective C#》(第3版)第17條)。
還有一個與接口方法有關的問題,就是以明确指定接口的方式來實作接口所要求的方法,這叫作 Explicit interface implementation(顯式接口實作),參見《Effective C#》(第 3 版)第26條。這樣實作出來的方法會從本類型的公有API中隐藏起來。如果類中存在這樣兩個版本,一個是以明确指定接口的方式所實作的版本,還有一個是以重寫基類 virtual 方法的形式所實作的版本,那麼系統會把對同名方法所做的調用派發到後一個版本上。《Effective C#》(第 3 版)第20條以IComparable接口為例詳細講解了這個問題。
最後再講一個問題,它涉及接口與基類。如果子類宣稱自己實作某個接口,而該類所繼承的基類又碰巧提供了這樣一個符合接口要求的方法,那麼,子類就會自動拿這個方法來實作接口方法。下面這個例子示範了這種情況:
由于基類所提供的方法滿足子類想要實作的接口所拟定的契約,是以,子類可以直接宣稱自己實作了該接口,而無須再為其中的方法編寫實作代碼。隻要子類能夠通路到的某個基類方法擁有适當的方法簽名,那麼子類就可以自動用它來實作接口中的對應方法。
通過這一條,大家可以看出,接口方法可以用很多手段來實作,而不一定非要在基類中将其實作成 virtual 函數,并在子類中重寫。除了采用這種做法,還可以直接在基類中把接口方法寫好,或是幹脆不寫代碼,而是将其設為 abstract 方法,并交給子類去編寫。此外,也可以在基類中把接口方法的大緻流程定好,并在其中調用某個 virtual 函數,使得子類能夠重寫那個 virtual 函數,以修改基類的預設行為。總之,接口方法既可以用 virtual 函數來實作,也可以用别的辦法來實作,它的重點在于描述某項約定,你隻要滿足這項約定即可。