冒號調整了焦點:“鑒于目前專注的范式是OOP,參數多態最好放在以后的GP專題再作探討。除非特別說明,下面提到的多態專指子類型多態。談到這類多態,就不得不提及抽象類型。誰來說說,究竟什么是抽象類型?”
冒號抬手內揚,擺出了對練的姿勢。
嘆號率先搶攻:“抽象類型指的是至少含有一個抽象方法的類型。”
冒號輕松化解:“在C++中這句話尚可勉強成立,但在Java和C#中則大不盡然:一個類即使沒有一個抽象方法也可以被申明為抽象的;一個沒有任何成員的空接口或稱標記接口同樣屬于抽象類型。”
“抽象類型是指無法實例化的類型。”逗號發起二次進攻。
冒號見招拆招:“Java中的Math類也不能實例化,原因是它只有private構造器,并且沒有一個能返回實例的靜態方法。C#中的Math類是靜態類,同樣不能實例化。”
問號縱身而上:“抽象類型指能且只能通過繼承來實例化的類型。Math類是final類,無法被繼承。最主要的是,它的價值體現在它的靜態方法上,壓根兒就沒有實例化的必要。”
冒號借力反打:“為什么要強調無法實例化呢?”
引號一旁助攻:“一個抽象類型代表著一個抽象概念,而抽象概念自然是無法具化的。比如你無法實例化抽象的形狀,但可以實例化長方形、三角形等具體的形狀;無法實例化抽象的水果,但可以實例化蘋果、桔子等具體的水果。”
“很官方的說法。這就好比將繼承關系說成‘is-a’關系一樣,理論上雖通俗易懂,實踐上卻不足為訓。”冒號收起架勢,“要說抽象,Java和C#中的Object類可謂包羅萬象,該夠抽象了吧?不照樣實例化?列表(list)與映射(map)是抽象的還是具體的?在C++中它們是具體類型,而在Java和C#中它們卻是抽象類型[1]。這又是為什么?”
一連串的反問讓大家陷入沉思。
“相比其他編程范式,OOP更貼合客觀世界,人們經常用打比方的形式來描述和理解OOP的一些概念和思想。這本身并無不妥,但一定要保持清醒的頭腦:淺顯的比方只是門檻前的臺階,借之或可拾級入門,卻無法登堂入室。”冒號諄戒道,“天下之理皆同,天下之人皆同,故凡學問殿堂之前皆一般景象:入門者眾,入室者寡。本班的目的便是,引導諸位從徘徊于編程之門左右的人群中越眾而出,早達內室。”
“那就成了傳說中的內室弟子吧?大伙在門邊轉悠很久了,頭都發暈了,師父還是快些領我等入室吧。” 逗號近乎戲謔地懇求。
冒號一笑:“我可算不得你們的師父,只不過是個聞道在先的師兄而已。”
一直沒有出手的句號忽然開腔:“抽象是個相對概念,一個類型是否是抽象的完全取決于設計者對它的角色定位。如果想用它來創建對象,它就是可實例化的具體類型;如果想用它來作為其他類型的基類,它就是不可實例化的抽象類型。”
“這才擊中了要害!”冒號不禁喝彩道,“整理一下你的觀點:具體類型是創建對象的模板,抽象類型是創建類型的模塊。一個是為對象服務的,一個是為類型服務的。顯然,后者的抽象性正是源自其服務對象的抽象性。就拿剛才的實例來說,模板方法模式中的Authenticator類是抽象的,是為創建子類型SimpleAuthenticator、Sha1Authenticator等服務的;策略模式中的Authenticator類是具體的,是為創建對象服務的,但它合成的兩個接口KeyValueKeeper和Encrypter又是為創建算法類型服務的。值得注意的是,不要把抽象類型與抽象數據類型(ADT)混為一談,后者的抽象指的是類型的接口不依賴其實現。或者說,抽象數據類型的核心是數據抽象,而抽象類型[2]的核心是多態抽象。”
問號想讓概念更明確些:“抽象類型就只有接口(interface)和抽象類(abstract class)兩種嗎?”
“在Java和C#中基本上是這樣,但在C++中這兩種類型沒有顯式的區別[3]。”冒號,“此外,動態OOP語言如Ruby、Python、Perl、Scala、Smalltalk等還至少支持mixin和trait中的一種類型。mixin直譯為‘混入’,trait直譯為‘特質’,為避免翻譯上的問題,今后我們還是采用英文術語。這兩種類型大同小異,為簡便起見,下面以mixin類型為代表[4]。它們的出現是為了彌補接口與抽象類的一些不足,更好地實現代碼重用。我們知道,接口的主要目的是創建多態類型,本身不含任何實現。子類型通過接口繼承只能讓代碼被重用,卻無法重用超類型的實現代碼。抽象類可以重用代碼,可又有多重繼承的問題。Java和C#不支持這種機制,C++雖支持但有不少弊端。”
引號奇道:“這個問題上節課不是已經解決了嗎?用合成來代替繼承啊。”
冒號解釋:“合成是一種解決辦法,但也不是沒有缺陷。首先,合成的用法不如繼承那么簡便優雅,這也是許多人喜歡用繼承的主要原因;其次,合成不能產生子類型,而有時這正是設計者所需要的;再次,合成無法覆蓋基礎類的方法,也無法訪問它的protected成員;最后,卻可能是最大的缺點是:合成的基礎類只能是具體類型,不能是抽象類型[5]。”
逗號不明所以:“這能算是缺點嗎?”
“如前所述,具體類型的主要任務是創造新對象,如果用作合成或繼承的基礎類,等于是又承擔了原本抽象類型的任務——創造新類型。這不僅有越俎代庖之嫌,而且這兩個任務往往也是沖突的。我們曾提出,一個類的服務應該有純粹性和完備性。一方面,人們希望創造的新對象無所不能,因此更看重服務的完備性,傾向它包含盡可能多的功能;另一方面,人們又希望創造的新類型有所不依,因此更看重服務的純粹性,傾向它包含盡可能少的功能。”冒號擘肌分理,“妥協的結果是,一個新類型往往只用到基礎類型的部分功能,卻可能受到其他功能變動的影響。雖然這種影響在良好的封裝之下會大大削弱,但也難以完全消弭。”
句號思索片刻,已明其意:“換句話說,以具體類型為代碼重用的基本單位,難免顆粒度過大?”
“然也!”冒號的手在空中挽了個花,“其實作為抽象類型的接口也有類似的尷尬:對它的客戶類來說,它承諾的服務是多多益善;對它的實現類來說,承諾越多負擔卻越重。如果能有這樣一種可重用的模塊,既不像具體類型那樣面面俱到,又不像接口那樣有名無實,也沒有抽象類的多重繼承之弊,豈不妙哉?”
“想必就是mixin了!”嘆號眼中閃過一道光芒,旋即又暗淡下來,“只可惜Java并不支持啊。”。
“Java不支持就沒興趣了?” 冒號聽出他的話里有話,“要成為優秀的程序員,千萬不能畫地為牢、自我禁錮。始終要保持一顆開放的心,不要拘于某些語言或范式,也不要囿于某些概念或技術。”
嘆號的耳根有點發熱。
“陌生的理論和技術開始總是拒人千里,不過一旦你了解其問題來源,它們會慢慢變得和藹可親起來。”冒號循循善誘,“既然具體類型和現存的兩種抽象類型均有不足之處,mixin的產生便合情合理了。它是具體類型與接口類型的一種折衷,既可有抽象方法,也可有具體方法。這一點類似抽象類,但又沒有抽象類的多重繼承問題。舉例來說,Ruby中的Comparable就是一個簡單卻很典型的mixin。”
問號插話:“Java中也有Comparable接口啊。”
冒號道出其中差異:“Java中的Comparable和C#中的IComparable只有一個抽象的比較方法,而Ruby中的除了有類似的抽象方法——比較(<=>)之外,還提供了小于(<)、小于等于(<=)、等于(==)、大于(>)、大于等于(>=)和介于(between?)等六種具體方法。顯而易見,多出的方法均可通過唯一抽象的比較方法來實現。”
引號一點即通:“如此一來,重用Comparable的類只需實現一個抽象方法,便可自動擁有另外六個有用的功能。這既滿足了客戶類的需求,又不增加實現類的負擔。”
“買一送六,這買賣劃算!”逗號來勁了。
冒號雙眼微瞇:“更劃算的買賣是Ruby中Enumerable。任何包含該mixin的類只要實現一個遍歷方法each,便可免費得到二十多個有關遍歷和搜尋的方法。如果再實現比較方法<=>,還可獲贈排序和最值方法。相比Java中Enumeration和Iterator接口,優勢歷然。”
問號很好奇:“為什么稱為mixin呢?”
冒號述說由來:“冰淇淋中經常會摻混一些薄荷、香草、巧克力之類的調味料和花生、堅果之類的小零碎,人們管它們叫mix-in。后來被借用來表示一種抽象類型,主要有如下特點:一、抽象性和依賴性:本身沒有獨立存在的意義,必須融入主體類型才能發揮作用;二、實用性和可重用性:不僅提供接口,還提供部分實現;三、專一性和細粒度性:提供的接口職責明確而單一;四、可選性和邊緣性:為主體類型提供非核心的輔助功能。”
“這些特點與風味添加料還真的頗為神似。”嘆號想著想著,嘴里不自覺地咂摸了一下。
“雖然C++、Java和C#在語法上尚不支持mixin,但C++可通過多重繼承、Java和C#可通過合成和接口來分別模擬mixin。不僅如此,借助切面式編程(AOP),Java和C#甚至可完全實現mixin;借助泛型式編程(GP),C++也能通過模板更好地實現mixin[6]。”冒號點到為止,“就此我們重溫前面提到的兩個觀點。一是編程范式之間的合作性:mixin屬于OOP的范疇,但其他編程范式如切面式、泛型式以及二者背后的元編程都能與之相通;二是設計與語言的相關性:C++、Java和C#以及其他諸如Ruby、Python等動態語言對mixin有著不同的支持方式,這在一定程度上會影響系統的OOP設計。”
引號憧憬道:“語言是在發展的,說不定哪天Java也會支持mixin的。”
冒號以實相應:“Java的動態小兄弟Groovy在1.6版已經開始支持mixin ,而C#3.0也新引入了對mixin更友好的語法特性[7]。”
逗號提了一個長期困惑大家的問題:“每當一個新技術出現,我就覺得很矛盾:不追怕落伍,追吧又怕落空。如何判斷一個它是曇花一現,還是大勢所趨呢?”
“任何技術都是在贊美與批判中成長起來的,預測它們是流星還是恒星絕非易事。就拿OOP來說,上個世紀六十年代就出現了支持OOP的語言[8],但直到九十年代中后期它才真正成為主流的編程范式。這段時間恐怕比大多數人的程序員生涯還長吧。再說mixin,其實并非今日的重點,介紹它的目的不是盲目追新,而是希望透過其背后的需求驅動點,重新審視現有技術。至于它今后會不會為主流語言所接納,反倒不是那么重要了。如果一定要我給個建議,那就是八個字:‘不執一法,不舍一法’。”冒號以禪語作答,“軟件技術這棵大樹經過多年的快速成長,早已枝蔓叢生。欲臻不執不舍之境,當如開班導言中所說:究其根本以知過去,握其主干以知現在,察其生長點以知未來。我之所以傾向于用抽象的方式來談論技術,正是因為抽象的東西更接近根、更接近干、更接近生長點,從而更普泛深刻,也更穩定持久。”
句號借機問道:“您認為抽象比具體更重要?”
“抽象與具體無所謂孰高孰低,它們只是功用不同而已。”冒號輕輕晃了晃腦袋,“正所謂:必先以術養道,而后以道御術。也就是說,在學習時應注重從具體知識中領悟抽象思想,在應用時應注重用抽象理論來指導具體實踐。類似地,軟件開發也是如此:從具體需求中構建出抽象模型,再根據抽象模型來完成具體實現。因此,在設計階段抽象類型尤為關鍵,而在實現階段則是具體類型更為重要。”
問號表示理解:“假如從具體需求直接跨到具體實現,省去中間的抽象建模過程,那還用得著架構師和分析師嗎?”
“話雖不錯,但疑似倒果為因。”冒號洞若觀火,“是否有必要抽象建模,關鍵看項目需求。如果需求簡單而穩定,一步到位又何嘗不可?甚至軟件的開發效率和運行效率還更高——為劈幾根細柴而磨刀,值嗎?如果需求復雜而多變,引入抽象方有‘磨刀不誤砍柴工’之效。畢竟抽象不是目的而是手段,對它片面的追求反會導致過度的設計。”
眾人這才發現,給老冒戴頂“抽象派”的帽子是有些冤枉他了,應該是“抽象現實派”的。
冒號續道:“為進一步認識抽象類型,我舉個非常實用的例子。它只適用于C++,而不適用于Java和C#。如果你對這一點感到遺憾的話,不要忘記我們的原則:具體實例永遠是為抽象思想服務的。”
幻燈一閃,現出一段C++代碼——
/** 一個不可復制的類 */
class NonCopyable
{
protected:
// 非公有構造函數防止創建對象
NonCopyable() {}
// 非公有非虛析構函數建議子類非公有繼承
~NonCopyable() {}
private:
// 私有復制構造函數防止直接的顯式復制和通過參數傳遞的隱式復制
NonCopyable(const NonCopyable&);
// 私有賦值運算符防止通過賦值來復制
const NonCopyable& operator=(const NonCopyable&); // copy assignment
};
/** NonCopyable的一個私有繼承類 */
class SingleCopy : private NonCopyable {};
/** 測試代碼 */
int main()
{
SingleCopy singleCopy1;
SingleCopy copy(singleCopy1); // 編譯器報錯:企圖復制singleCopy1
SingleCopy singleCopy2;
singleCopy2 = singleCopy1; // 編譯器報錯:企圖復制singleCopy1
return 0;
}
冒號講解道:“有些對象是不希望被復制的。比如一些代表網絡連接、數據庫連接的資源對象,它們的復制要么意義不大,要么實現困難。由于C++的編譯器為每個類提供了默認的復制構造函數(copy constructor)和賦值運算符(assignment operator),要想阻止對象的復制,通常做法是將這兩個函數私有化。引入NonCopyable后,它的任何子類將自動擁有不可復制的特性。這樣為開發者節省了代碼編寫量,還免掉了相應的文檔說明,使用者也一望而知其意,可說是一石三鳥。雖然NonCopyable從語法上說不是抽象類,但從本質上看是一種類似mixin功能的抽象類型。”
引號考量一番后說道:“單就它的功效而言,的確非常符合mixin的四大特點,只是它的子類用的是私有繼承,而不是類繼承或接口繼承。”
“你說得很對??蓡栴}是,我們并沒有要求mixin或者trait一定要通過繼承的方式來重用???事實上,有些mixin甚至可在運行期間產生,還能克服繼承的靜態缺陷。即使采用繼承,一般也不滿足‘is-a’關系。你總不能說草莓冰淇淋是一種草莓吧?”冒號淡淡地說,“先前你們總結出抽象類型有兩個特征:需要繼承和無法實例化,但它們并非本質,關鍵還是它的目的——為類型服務。提供可被繼承的超類型只是一種服務方式,卻非唯一的方式;無法實例化只因它不是為對象服務的,禁止實例化不過是語法上的加強,目的是讓用戶在編譯期間就能發現用法錯誤。其實,即便NonCopyable類的構造函數是公有的,也不會有人去實例化。原因很簡單,它的價值只有通過子類才能體現,這是由其抽象的本性所決定的。”
逗號有些奇怪:“為什么在Java中就沒有類似的對象復制問題呢?”
“這是一個非?;A的問題,請容我下次再回答你。”冒號破天荒地沒有立即解疑,“以下重點還是放在接口和抽象類上面,我們稱之為基本抽象類型,以別于mixin、trait等其他抽象類型。我們先從語法上簡單地對比一下這兩種類型。”
屏幕上顯示出一張表格(如表10-1所示)——
表 10-1. Java/C#的抽象類與接口在語法上的區別
|
抽象類 |
接口 |
提供實現代碼 |
能 |
否 |
多重繼承 |
否 |
能 |
擁有非public成員 |
能 |
否 |
擁有域成員 |
能 |
否(Java中的static final域成員除外) |
擁有static成員 |
能 |
否(Java中的static final域成員除外) |
擁有非abstract方法成員 |
能 |
否 |
方法成員的默認修飾符 |
無 |
public abstract(Java:可選;C#:不能含有任何修飾符) |
域成員的默認修飾符 |
無 |
Java:public static final;C#:不允許域成員 |
冒號簡明扼要地總結:“C#的語法與Java的稍有不同,但二者在接口與抽象類的關鍵區別上還是一致的:接口不能提供實現但能多重繼承,抽象類則正相反;接口只能包含公有的、非靜態的、抽象的方法成員[9],抽象類則無此限制。”
問號言明難處:“從語法上區分它們并不難,難的是從設計上區分它們。”
逗號實話實說:“按照上節課‘提倡接口繼承,慎用實現繼承’的方針,應該傾向用接口而非抽象類。但總覺得接口太虛了,沒有抽象類實在。”
引號反駁:“要說實在,具體類型更實在啊。”
嘆號坦言:“在編程中經常需要用到標準的或第三方的類庫,可查起API來經常是左一個接口右一個接口的,遲遲不見具體類型現身,心里哪個急啊!”
冒號打了個比方:“如果到包子鋪買包子,作為客戶你也許會認為包子是具體類型,但對提供包子的人來說它卻是抽象類型。他一定會問你:是要肉包、菜包還是豆沙包?是要蒸包、煎包還是小籠包?他的鋪子開得越專業,給你出的選擇題越多,眾口難調嘛。同樣道理, 要建一個高度可重用的類庫,一些接口是必不可少的。”
句號悟道:“接口的意義就在于:提供者不是擅作主張,而是推遲決定,讓客戶選擇實現方式。”
“言之有理!類似地,抽象類的意義就在于:父類推遲決定,讓子類選擇實現方式。‘推遲’二字道出了抽象類型除創建類型之外的另一功用:提供動態節點。如果是具體類型,節點已經固定,沒有太多變化的余地[10]。反過來,要使節點動態化,一般通過多態來實現。由此,抽象類型常常與多態機制形影不離。”冒號稍加引申,“就說前面的驗證類吧,用模板方法模式實現的Authenticator類將關鍵的方法交給子類SimpleAuthenticator或Sha1Authenticator處理,用策略模式實現的Authenticator類將關鍵的方法交給內嵌接口KeyValueKeeper和Encrypter的實現類處理。后者的兩次接口繼承比前者的一次實現繼承多了一個動態節點,因而更加靈活。這也是為什么一個需要(M×N)個實現類,一個只要(M+N)個的原因。當然,這也不是完全沒有代價的。比如要創建一個用SHA-1算法加密的驗證類實例,兩種方法對比如下——”
模板方法模式:new Sha1Authenticator()
策略模式: new Authenticator(new MemoryKeeper(), new Sha1Encrypter())
冒號指點著黑板:“顯然,后者無論是使用上還是性能上都比前者稍有不如。但權衡利弊,多數時候它仍是更好的選擇。”
“包子鋪的包子用料種類越多、做法越多,買一個包子越費事。但只要不到餓得發昏的地步,大家還是更喜歡花樣更多的包子鋪??磥砦乙膊辉撛俦г诡悗斓慕涌谶^多了。”嘆號心下釋然。
“大家再看看這個電腦主板,開過機箱攢過機的人應該對它并不陌生。”冒號終于亮出了蓄藏已久的道具, “上面密密麻麻地布滿了各種元件,那是它的實部,而我們關注的是它的虛部——各種插槽和接口,包括CPU插槽、內存插槽、PCI插槽、AGP插槽、ATA接口、PS/2接口、USB接口以及其他林林總總的擴展插槽等等。這些接口的存在,使得主板與CPU、內存條、外圍設備以及擴展卡等不必硬性焊接在一起,大大增強了電腦主機的可定制性。”
引號受到啟發:“主板與其他硬件就好比一個個的具體類型,那些插槽和接口就相當于一個個的接口類型。所有的硬件以接口為橋來組裝合成,以機箱為殼來封裝隱藏,一個新的具體類型——具有完整功能的主機便產生了。”
“比喻非常到位!” 冒號很滿意,“不過準確地說,與接口類型對應的不是物理接口,而是接口規范。如果僅僅是物理接口,只能保證該接口適用于某種特定型號的硬件產品,卻不能保證同時適用于其他型號或者其他類型的硬件。以大家熟悉的USB(Universal Serial Bus)接口為例,它能接入各種外部設備,包括鼠標、鍵盤、打印機、外置硬盤、閃存和形形色色的數碼產品。這當然不是偶然的,因為所有廠家在生產這些硬件時均遵循了相同的業界標準——USB協議規范。換言之,任何一個與USB接口兼容的設備,都可看作是實現了此接口的具體類型,而主機對該設備的自動識別能力則可看作一種多態機制。”
“這下我更深刻地理解那句話了:接口繼承不是為了重用,而是為了被重用。”句號品味道,“比如一個鼠標,可以有串行接口、PS/2接口、USB接口或者無線接口,還可以同時擁有多個不同類型的接口。無論怎樣,它本身都是完整的產品,根本不需要重用主機上的其他硬件,它實現某些接口的目的完全是為了能被主機所用。”
逗號意識到:“看樣子,硬件設計也需要OOP思想呢。”
“相比軟件設計師,硬件設計師往往能更好地貫徹OOP的理念。”冒號加強了語氣,“他們的對象化概念更清晰更自然,因為硬件模塊比軟件模塊更實在更具體;他們更注重設計,因為硬件比軟件的修改成本大得多;他們更注重設計重用,因為硬件重新發明輪子的成本普遍很高;他們更注重實現重用,因為無法在舉手之間完成‘復制-粘貼’工作;他們更注重接口明確、封裝完好,因為把內部的接口或結構暴露在外不僅難看,還容易帶來纏繞、磨損、短路等問題;他們采用合成和接口來組裝模塊,因為硬件沒有類似實現繼承的機制。”
“看起來我們真得向硬件設計師取經了。”嘆號有些信服了。
冒號舊話重提:“我們曾對OOP有過這樣的描述:如果把OOP系統看作民主制社會,每個對象是獨立而平等的公民,那么封裝使得公民擁有個體身份,繼承使得公民擁有家庭身份,多態使得公民擁有社會身份。補充一下,其中的繼承主要指類繼承,多態主要指接口繼承帶來的多態。經過這段時間的學習,大家對此有何見解?”
問號發表看法:“廣義封裝讓每個類成為獨立的模塊,從而讓每個對象具備了個體身份。狹義封裝又進一步地把類的接口與實現分離,從而讓每個對象具有顯著的外在行為和隱藏的內在特性。繼承機制可使一個類成為其他類的子類或父類,從而確立了對象在類型家族中的身份。至于多態嘛,嗯。。。”
問號努力想抓住若隱若現的頭緒。
句號接過話頭:“一個公民的社會身份是指他在社會中所處的地位和扮演的角色。比如,一個人在學校里是學生,在公司里是職員,在商店里是顧客,他真正的個體身份往往是被掩蓋的。同樣地,一個對象在與外界聯系時,通常不以其實際類型的身份出現,而是在不同的場合下以不同的抽象類型的身份出現。我想,這大概就是多態帶來的社會身份吧。”
“這種社會身份的意義何在?”冒號不動聲色地問。
句號接著回答:“社會身份既是一種資格也是一種義務。比如在列車上有人得了急病,可以通過廣播找醫生。人們不用事先知道來者的具體個人身份,只要他是醫生,就會放心地讓他第一時間去救人。”
“這個比喻很恰當。”冒號贊道,“不用事先知道個人身份,不正說明廣播呼叫的對象是一個多態的抽象類型嗎?同理,當一個具體類型顯式繼承了一個接口,它的對象便擁有了個體身份之外社會身份:有資格以該接口的形式與外界打交道,也有義務履行該接口的職責。”
“咦,那為什么把社會身份歸功于多態而不是繼承呢?”問號發出疑問。
冒號釋疑:“繼承自然有功勞,畢竟子類型多態要建立在它的基礎上。但如果沒有多態機制,要確保一個對象的實際方法而不是其超類型的方法被調用,必須將其還原為具體類型,從而使社會身份變得幾乎有名無實。”
問號憬然醒悟。
冒號繼續深入:“對象每多一種社會身份,便多一條與外界交流的渠道。為什么遮遮掩掩地不肯以本來面目示人呢?非是羞于見人,蓋因一般的具體類型在公共場合是不為人知的,只有少數核心庫里的核心類是例外。即使僥幸被認識,也難被認可,因為那會以代碼的復雜度和耦合度為代價。社會身份則不然,它遠比一般的個體身份更容易被接受。”
逗號舉出例證:“這就好比上課得有學生證,上班得有工作證,上火車得有火車票,上飛機得有登機牌。只要不是炙手可熱的公眾人物,很多場合都是認牌認證不認人的。”
“道理人人都懂,可總有不少人以為自己編寫的類都是明星大腕,大有‘天下誰人不識我’的豪邁,無牌無證就敢到處亂竄。更有甚者,不用多態就算了,連封裝也不要,簡直是在裸奔嘛。”冒號揶揄道。
全班笑不可仰。
冒號恢復肅容:“談到這里,我們不能不再次提到‘針對接口編程’的基本原則。它有一種建立于數據抽象之上的形式,能讓用戶只關心抽象數據類型的API接口而無視其具體實現。不過,它至少有兩大局限。其一,雖然在接口不變的情況下,實現代碼的改變不會影響客戶代碼,但仍需要重新編譯,對于需要頭文件的C++來說則需要更多的編譯鏈接時間。其二,雖然相同的接口可以有多種實現方法,但它們不能同時并存,更無法動態切換。于是,另一種建立于多態抽象之上的形式應運而生。它把抽象數據類型隱藏在抽象類型的背后,從而提升了抽象接口。同一個抽象接口允許有多種實現并存,且能動態切換,新增、刪除或修改某種實現也不會導致其他代碼的修改或重新編譯。方才我們從主體類的角度來看,它的對象盡量以社會身份參與社會活動;現在再從客戶類的角度看,它會盡量召集有社會身份的對象。兩相結合,以社會身份而非個人身份作為公民之間聯系的紐帶,正是針對接口而非實現來編程的社會現實版。”
問號有所顧慮:“可是,有不少具體類型并沒有實現任何接口,也就沒有社會身份。”
“排除設計不良的因素,沒有抽象超類型的具體類型最常見的有兩種可能。一種是與世隔絕,一輩子幾乎足不出戶,至多在小圈子里活動。典型的有非公有類、內部類、局部類等等。一種是名滿天下,他的臉就是一張天然名片,他的個人身份也就是社會身份。典型的有基本數據類型、字符串類型、日期類型等通用數據類型以及特定領域的通用數據類型??梢?,個人身份與社會身份并無絕對的界限。同樣,家庭身份與社會身份也有交合之處,正如名門望族也可成為社會身份一樣。典型的有Java IO庫中的InputStream和OutputStream、Reader和Writer,以及UI庫中的Component和JComponent等等。”冒號信手拈來,“因此我們談到的社會身份,不必拘泥于接口,甚至不必限于抽象類型,關鍵是該類型是否具備了足夠的通用性和規范性、穩定性和獨立性、靈活性和專業性。還是應了那句話:抽象不是目的而是手段。再拿現實社會說事,每種社會身份都代表了個體與社會締結的一種契約,它有如下的特點:獨立而穩定——先于個體而存在,且不隨個體的變化而變化;公開而權威——為人所知、為人所信;規范而開放——制定的協議標準明確,且允許個體在遵守協議的前提下百花齊放。毫無疑問,推行契約制將使社會大受其惠。首先,相同身份的個體可相互替換、新型個體可隨時加入,而且不會影響整體框架和流程,保證了系統的靈活性和擴展性。其次,整體不因某一個體的變故而受沖擊,保證了系統的穩定性和可靠性;最后,個體角色清晰、分工明確,保證了系統的規范性和可讀性。”
引號非常注重概念:“社會身份所代表的契約對應的正是規范抽象吧。”
“每種身份都是規范抽象的結果。” 冒號推而廣之,“具體地說,個體身份對應的規范抽象借助封裝,以數據抽象(data abstraction)的形式出現;家庭身份對應的規范抽象借助繼承,以類型層級(type hierarchy)的形式出現;社會身份對應的規范抽象借助多態,以多態抽象(polymorphic abstraction)的形式出現。至此,我們分別從行為和規范兩個角度分別詮釋了OOP的三大特征與公民的三大身份之間的關系。這也非常合乎情理:一個合理設計和實現的類,其對象的行為與規范本應保持一致。”
句號欲印證自己的想法:“我的理解是,接口是一個攜帶契約的角色標簽,接口繼承的作用就是靜態地為對象貼上該標簽,而多態機制的作用就是動態地讓對象發揮該角色。因此,要賦予對象某個角色,就應該讓相應的類去繼承相應的接口。”
“你的前半部分表述得非常精當,后半部分則稍有瑕疵。”冒號評論道,“接口可用來代表角色,但角色卻不一定要通過接口。正如你提到的,接口繼承是靜態的,而角色卻可能是動態的。比如學生畢業后變成職員,職員升遷后變成經理等等。對于靜態類型語言來說,這類問題的解決單靠接口繼承是不夠的,還需要利用合成等手段,或者利用前面提到的其他抽象類型如mixin或trait。”
嘆號仍有疑惑:“接口的意義已經很清楚了,那抽象類呢?它們的區別真的很大嗎?”
“我們已經從語法上比較了它們的區別,那些只是表象的東西。如果對語言規則的理解僅僅停留于語法層面,那么它更多體現為對實現的束縛。只有提升到語義層面,它才更多體現為對設計的保障。”冒號保持一貫高舉高打的風格,“從語義上看,抽象類與接口的區別,并不比它與具體類的區別小多少。”
嘆號錯愕不已:“怎么可能?抽象類與接口好歹都是抽象類型啊。”
冒號反詰:“為什么不說抽象類與具體類好歹都是類呢?”
嘆號一時無語。
“先看段歷史吧。”冒號幽幽地說,“開始C++是沒有抽象類型的,直到1989年C++ Release 2.0發布前的最后一刻,Bjarne Stroustrup才力排眾議引入抽象類。從C++的前身C with Classes 開始算起,其間已經整整十年了。即便如此,它的意義在當時仍不為大多數人所認識。推出一個看似小小的語法特征竟會如此艱難,恐怕遠遠超出諸位的想象吧!有人幻想只通過看語法書就能完全領會語言的精髓,又與癡人說夢何異?”
冒號的聲音漸漸激昂起來。
逗號為自己找到了安慰:“難怪當初學到抽象類時,總感到只知其意而不知其用。”
冒號緊接著說:“抽象類的出現,讓兩種不同角色的類在語法上有了明確的界定:具體類描述對象,重在實現;抽象類描述規范,重在接口。這種分工降低了用戶與實現者之間的耦合度,大大減少了代碼的維護成本以及編譯時間[11]。由于抽象類不是為了創建對象,它的實例化自然是沒有意義的。又由于它是接口規范,在子類沒有實現其所有規范之前,是不能實例化的,否則規范豈不成了一紙空文?在沒有抽象類的語法之前,要實現類似的功能,唯一的辦法是:在本該抽象的方法被調用時強行中止程序。煩瑣丑陋不說,還只能在運行期間捕捉錯誤。在純虛函數(pure virtual function)——相當于Java和C#中的抽象方法——被引入之后,任何含有抽象方法的類都是抽象類,編譯器將保證它不會被實例化。”
問號連連點頭:“從這個角度來理解抽象類的語法,一切都順理成章了。不過,抽象類與接口的區別好像還是沒有看到。”
談到興頭,冒號出言更如下阪走丸:“從具體類中分離出抽象類是一次質的飛躍,從抽象類中進一步地分離出接口則是另一次飛躍。Java推出接口類型之時同樣飽受質疑,最終還是經受了實踐的考驗,后又為C#所采納。其實最初C++的抽象類是為了定義一組協議并強令各子類遵守,實質上正是Java和C#中的接口所起的作用。但在協議規范的實現過程中,可能會產生一些不完全實現類。允許這種類的存在固然是一種靈活的舉措,但必須認識到它們與純規范的抽象類已判若云泥。打個比方,如果把對象看作產品,把具體類看作一個制作產品的模具,那么接口就是模具的規格標準,而抽象類是在模具加工過程中產生的半成品。接口與抽象類無法實例化,模具規格與模具半成品也不能直接制作產品;一個具體類可以有多個接口,一個模具也可有多個不同方面的規格;一個具體類至多只能繼承一個抽象類,一個模具也至多只能在一種模具半成品的基礎上直接加工。”
引號細加回味:“如果具體類、抽象類和接口分別對應于模具、模具半成品和模具規格,那后兩者的區別的確比前兩者的區別還大??墒羌偃缫粋€抽象類完全沒有任何實現呢?拋開多重繼承的限制,它與接口又有何區別呢?”
冒號辨析其別:“一個抽象類可以沒有任何實現,但也隨時可以加入實現。接口則不同,永遠都不能有實現代碼。這正是引入關鍵字interface的目的,明明白白地表明:此乃規范集合所在,杜絕任何自以為是、畫蛇添足的實現。初看似乎不合常理:這不是自縛手腳、自廢武功嗎?殊不知自由源于自制。許多人為了貪戀一點點代碼重用,總忍不住把一些實現放在本該只是規范的地方。一來,這模糊了規范與實現的界限,背離了接口與實現相分離的設計初衷。要知道,再完美的實現都有改動的余地,將其捆綁到規范中只會增加不穩定因素;再完美的實現也不應該影響其他的實現,先入為主只會降低靈活性。二來,帶有實現的抽象類無法用于合成,必須通過類繼承才能起作用,而實現繼承的弊端我們已經見識過了。在有些情況下,規范的實現比較復雜,需要漸進實現,保留一些中間狀態的抽象類也是合理的,但最初的接口最好保留。總不能因為有了模具半成品,就拋棄模具規格吧?以Java Collections Framework為例,既規范了Collection、Set、List、Map等接口,又為這些接口提供了抽象類和具體類,從而給了用戶三種選擇:直接利用具體類、擴展抽象類、直接實現接口,方便程度遞減而靈活程度遞增。”
句號進行反思:“我在想,為什么以前對接口總有本能的排斥心理?原因在于:滿腦子更多想的是怎么讓程序工作,而不是想怎么讓程序工作得更好。因此更重視代碼實現,比較忽視規范設計。”
眾人皆有同感。
“確實,在缺乏設計觀念的人看來,使用接口和脫褲放屁差不多。”冒號輕笑道,“特別需要注意一種常見的說法:接口是為了克服Java或C#中抽象類不能多重繼承的缺點。這句話具有相當大的誤導性,因為該處的多重繼承是指多重實現繼承,而接口甚至連單重實現繼承都做不到!許多人對接口與抽象類的認識之所以模糊不清,原因是他們習慣于從定義和語法中尋找表象的答案,不習慣從本源和語義上進行本質的分析。然而不可否認,畢竟接口與抽象類提供了相似的抽象機制,在實踐中往往確難抉擇。因此光從語法上對比二者的差別是遠遠不夠的,需要進一步在語義上進行對比(如表10-2所示)——”
表 10-2. Java/C#的抽象類與接口在語義上的區別
|
關系 |
共性 |
特征 |
聯系 |
重用 |
實現 |
重點 |
演變 |
接口 |
can-do |
相同功能 |
邊緣特征 |
橫向聯系 |
規范重用 |
多種實現 |
可置換性 |
新增類型 |
抽象類 |
is-a |
相同種類 |
核心特征 |
縱向聯系 |
代碼重用 |
多級實現 |
可擴展性 |
新增成員 |
冒號展開敘述:“先從本性上看:接口是一套功能規范集合,因此相同的接口代表相同的功能,多表示‘can-do’關系,常用后綴為‘-able’的形容詞命名,如Comparable、Runnable、Cloneable等等。接口一般表述的是對象的邊緣特征[12],或者說一個對象在某一方面的特征,因此能在本質不同的類之間建立起橫向聯系。由于一個對象可擁有多方面的角色特征,故而可有多種接口。與之相對地,抽象類是一類對象的本質屬性的抽象,因此相同的抽象基類代表相同的種類,多表示‘is-a’關系,常用名詞命名。抽象類一般表述的是對象的核心特征,只能在本質相同的類之間沿著繼承樹建立起縱向聯系。由于一個對象通常只有一個核心,故而只能有一種基類。再從目的上看:接口是為了規范重用,讓一個規范有多種實現,看重的是可置換性;抽象類主要是為了代碼重用[13],能逐級分步實現基類的抽象方法,看重的是可擴展性。”
嘆號追問:“演變指的又是什么呢?”
冒號答道:“嚴格說來,演變不屬語義范疇,屬于語法規則的一個推論。在系統演變過程中,接口與抽象類的表現差異很大。接口由于是被廣泛采用的規范,相當于行業標準,一經確立不能輕易改動。一旦被廣泛采用,它的任何改動——包括增減接口、修改接口的簽名或規范——將波及整個系統,必須慎之又慎。抽象類的演變則沒有那么困難,一則它在系統中用得沒有接口那么廣泛,更多地是家庭身份而非社會身份;二則它可隨時新增域成員或有默認實現的方法成員[14],所有子類將自動得以擴充。這是抽象類的最大優點之一。不過接口也有抽象類所不具備的優點,雖然自身難以演化,但很容易讓其他類型演化為該接口的子類型。例如,JDK5.0之前的StringBuffer、CharBuffer、Writer和PrintStream本是互不相關的,在引進了接口Appendable并讓以上類實現該接口后,它們便有了橫向聯系,均可作為格式化輸出類Formatter的輸出目標。”
問號還留有一個疑點:“現在接口與抽象類之間的差異是越來越清晰了,我只是有一點一直沒想通:標記接口究竟有什么用?它一個方法都沒有,也就談不上規范,也無法利用多態機制,繼承這類接口又有何意義呢?”
逗號隨口說:“這就好比有些社會身份是光掛名頭不干事的虛銜,不足為奇。”
冒號回應道:“先需澄清一點,一個類型的規范不限于單個的方法,類型整體上也有規范,比如主要目的、適用場合、限定條件、類不變量等等。另外,接口的目的是為了產生多態類型,不能只看到‘多態’而忽略‘類型’。一個接口哪怕沒有一個方法,也是有意義的。首先,接口是一種類型,有嚴格的語法保障和明確的語義提示,這也是靜態類型的優勢所在。讓一個具體類繼承特定接口,既凸顯了設計者的用意,也授予用戶針對性地處理該類型的權力。比如java.util.EventListener接口為所有的事件監聽器提供了統一的根類型。其次,有時需要對某些類型提出特殊要求、提供特殊服務或進行特殊處理,而這些并不能通過公有方法來辦到,也沒有其他有效的語言支持。標記接口可擔此任,成為類型元數據(metadata)的載體。比如給一個類貼上一個java.io.Serializable的標簽,它的對象便能被序列化[15],具體工作由JVM來完成。用戶也可以通過自定義私有的writeObject 、readObject等方法來定制序列化方式。值得指出的是,當標記接口僅僅用于元數據時,更好的辦法是采用屬性導向式編程(@OP),Java中的annotation、C#中的attribute即作此用。”
逗號摸了摸后腦勺:“原來標記接口并非虛有其名,還是在偷偷地干實事呢。”
冒號見時候已到,準備落下帷幕:“至此,我們探討了OOP中最基本的機制——封裝、最獨特的機制——繼承、最重要的機制——多態。在今天的課結束之前,請大家每人用一個關鍵詞來形容自己眼中的OOP,并作簡要說明。”
引號說:“責任——在契約化的公民社會中,最重要的是對自己、對家庭、對社會的責任感。”
問號說:“變化——采用封裝以防個人之變,慎用繼承以防家庭之變,采用多態以防社會之變。”
逗號說:“分合——數據與運算結合,接口與實現分離。”
句號說:“抽象——無論是封裝、繼承還是多態,都是施諸眾對象之上的抽象機制。”
嘆號說:“虛偽——用封裝來掩蓋內心,用多態來掩蓋外表,提倡繼承責任卻不提倡繼承財富!”
冒號欣贊道:“不錯不錯,雖然角度各異,但均深中肯綮。我也大可安心下課了!”
眾人也樂得打道回府。