面向對象程序設計-數據抽象和繼承
面向對象衍生
面向對象編程方法和編程語言一樣在不斷地演變發展。到了20世紀90年代,面向對象的方法在軟件設計和分析等軟件開發的上層領域中流行起來。1994年,當時主要的面向對象分析和設計方法Booth、OMT(Object Modeling Technique)以及 OOSE(Object Oriented Software Engineering)的發明人 Grady Booch、Jim Rumbaugh 和 Ivar Jacobson 合作設計了 UML(Unified Modeling Language)。UML 是用來描述通過面向對象方法設計的軟件模型的圖示方法,也是利用這種記法進行分析和設計的一種方法論。
UML提供了很多設計高可靠性軟件的面向對象設計方法。但是,UML整體上很復雜,用到的概念很多,會讓初學者覺得很難掌握。面向對象的基本概念的建立,催生了各種編程語言。
復雜性是面向對象的敵人
我們再回到面向對象的重要原則,來了解真正的面向對象編程。
軟件開發的最大敵人是復雜性。人類的大腦無法做太復雜的處理,記憶力和理解力也是有限的。
計算機上運行的軟件卻沒有這樣的限制,無論多麽復雜的計算機軟件,無論有多少數據,無論需要多長時間,計算機都可以處理。隨著越來越多的數據要用計算機來處理,對軟件的要求也越來越高,軟件也變得越來越復雜。
雖然計算機的性能年年在提高,但它的處理能力終究是有限的,而人類理解力的局限性給軟件生產力帶來的限制則更大。在計算機性能這麽高的今天,人們為了找到迅速開發大規模復雜軟件的方法,哪怕犧牲一些性能也在所不惜。
結構化編程
最初對這種復雜的軟件開發提出挑戰的是“結構化編程”。結構化編程的基本思想是有序地控制流程,即把程序的執行順序限制為順序、分支和循環這3種,把共通的處理歸結為例程。
在結構化編程出現之前,可以用goto語句來控制程序的流程,執行流可以轉移到任何地方。而結構化編程用如上文所述的3種語句控制程序的流程。這樣可以降低程序流程的復雜性,此外,還引入了較為抽象的處理塊(例程)的概念,也就是把基本上相同的處理抽象成例程,其中不同的地方由外部傳遞進來的參數來對應。
結構化編程的“限制”和“抽象化”,是人類處理復雜軟件的非常有效的方法。
通過限制大大降低了程序的自由度,減少了各種組合,使得程序不至於太過復雜。但是如果由於降低了程序的自由度而導致程序的實現能力低下,那是我們所不願意看到的。而結構化編程的順序、分支和循環可以實現一切算法,雖然降低了程序的復雜性和靈活性,但是程序的實現能力並沒有降低。
抽象化的目的是我們只需要知道過程的名字,而並不需要知道過程的內部細節,因此它也被稱為“黑盒化”。我們只需要知道“黑盒子”的輸入和輸出,而過程的細節是隱藏的。(計算器是黑盒子的一個例子。輸入數字後,計算結果在液晶屏上顯示出來,而內部是怎樣計算的我們並不知道。也有可能是裏面的小人在打算盤哦。)
例如,如果你知道了例程的輸入和輸出,那麽即使不知道處理的內部細節也可以利用這個例程。建立一個由黑盒子組合起來的系統,復雜的結構被黑盒子隱藏起來,這樣我們就可以更容易、更好地理解系統的整體結構。
如果把黑盒子內的處理也考慮上,整個系統的復雜性並沒有改變。但是如果不考慮黑盒子內部的處理,系統復雜性就可以降低到人類的可控範圍內。此外,黑盒子內部的處理無論怎麽變化,如果輸入和輸出不發生變化,那麽就對外部沒有影響,所以這種擴展特性是我們非常希望獲得的。
針對程序控制流的復雜問題,結構化編程采用了限制和抽象化的武器解決問題。結果證明,結構化程序設計是成功的,並且這種方法已經有了穩固的基礎。現在幾乎所有的編程語言都支持結構化編程,結構化編程已經成為了編程的基本常識。
數據抽象化
然而,程序裏面不僅包括控制結構,還包括要處理的數據。結構化編程雖然降低了程序流程的復雜性,但是隨著處理數據的增加,程序的復雜性也會上升。面向對象編程就是作為對抗數據復雜性的手段出現的。
世界上第一個面向對象的編程語言是Simula。隨著仿真處理的數據類型越來越多,分別管理程序處理內容和處理數據對象所帶來的復雜性也越來越高。為了得到正確的結果,必須保持處理和數據的一致性,這在結構化編程中是非常困難的。解決這一問題的方案就是數據抽象技術。
數據抽象是數據和處理方法的結合。對數據內容的處理和操作,必須通過事先定義好的方法來進行。數據和處理方法結合起來成為了黑盒子。
舉一個棧的例子。棧是先入後出的數據存儲結構。比如往快餐托盤中疊加地摞放食品。棧只有兩種操作方法:入棧(push),向棧中放入數據;出棧(pop),把最後放入的數據拿出來。
class Stack{ public: Stack(size_t sz); Stack(const Stack &t); Stack& operator=(const Stack &t); ~Stack(); public: bool isEmpty()const; bool isFull()const; boolean push(const Type &t); boolean pop(Type *t); boolean getTop(Type *t)const; void lookAllStack()const; private: Type *base; int count; int top; };
代碼中可以直接表現push這個操作。對數據進行操作的一方,並不需要知道代碼中的處理細節,而只對“要做什麽”感興趣。所以隱藏了處理細節的程序會變得更加明確,實現目的也更清晰。
不僅是操作方法容易理解,抽象數據也是能夠對特定的操作產生反應的智能數據。使用抽象數據可以更好地模擬現實世界中各種活生生的實體。
有了數據抽象,程序處理的數據就不再是單純的數值或文字這些概念性的東西,而變成了人腦容易想象的具體事物。而代碼的“抽象化”則是把想象的過程“具體化”了。這種智能數據可以模擬現實世界中的實體,因而被稱作“對象”,面向對象編程也由此得名。
雛形
出現在程序中的對象,通常具有相同的動作。以交通仿真程序為例,程序中有表示車和信號的對象。雖然同樣的對象具有相同的性質,但是位置、顏色等狀態各有不同。
從抽象的原則來說,多個相同事物出現時,應該組合在一起。這就是DRY原則(即Don‘t Repeat Yourself)。
我們已經看到,程序的重復是一切問題的根源。重復的程序在需要修改的時候,所涉及的範圍就會更廣,費用也就更高。當多個重復的地方都需要修改時,哪怕是漏掉其中之一,程序也將無法正常工作。所以重復降低了程序的可靠性。
進一步說,重復的程序是冗余的。人們解讀程序、理解程序意圖的成本也會增加。請記住,計算機是不管程序是否難以閱讀,是否有重復的。然而,開發人員要閱讀和理解大量的程序,所以程序的可讀性直接關系到生產力。重復冗長的程序會降低生產力。復制和粘貼程序會導致重復,應該盡量避免。
讓我們再回到對象的話題上。同樣的對象大量存在的時候,為了避免重復,可以采用兩種方法來管理對象。
一種是原型。用原始對象的副本來作為新的相同的對象。Self、Io等編程語言采用了原型。有名的編程語言用原型的比較少,很意外的是,JavaScript也是用的原型。
另外一種是模板。比方說我們要澆註東西的時候,往模板裏註入液體材料就能澆註出相同的東西。這種模板在面向對象編程語言中稱為類(class)。同樣類型的對象分別屬於同樣的類,操作方法和屬性可以共享。
跟原型不同,面向對象編程語言的類和對象有明顯區別,就像做點心的模具和點心有區別一樣,整數的類和1這個對象、 狗類和名字是poochy這條狗也都是有區別的。為了清晰地表明類和對象的不同,對象又常常被稱作實例(instance)。叫法雖有不同,但實例和對象是一樣的。
在C++面向對象編程語言中,類用關鍵字class來聲明。
在上面的代碼中,Stack就是一個類。
找出相似的部分來繼承
隨著軟件規模的擴大,用到的類的個數也隨之增加,其中也會有很多性質相似的類。這就違背了我們之前強調多次的DRY原則。程序會變得重復而且不容易理解。修改程序的代價也會變高,生產力則會降低。所以,如果有把這些相似的部分匯總到一起的方法就好了。
繼承就是這種方法。具體說來,繼承就是在保持既有類的性質的基礎上而生成新類的方法。原來的類稱為父類,新生成的類稱為子類。子類繼承父類所有的方法,如果需要也可以增加新的方法。子類也可以根據需要重寫從父類繼承的方法。
// 基類 class Shape { public: void setWidth(int w) { width = w; } void setHeight(int h) { height = h; } protected: int width; int height; }; // 派生類 class Rectangle: public Shape { public: int getArea() { return (width * height); } };
從自頂向下的方法來看,通過擴展一個類來生成新的類也是很自然的。
但是,從用自底向上的方法提取共通部分的角度來看,一個子類只能有一個父類的限制是太嚴格了。其實,在C++、Lisp等編程語言中,一個子類可以有多個父類,這稱為“多重繼承”。
多重繼承的缺點
上一節講解了面向對象編程的三大原則(多態性、數據抽象和繼承)中的繼承。如前所述,人們一次能夠把握並記憶的概念是有限的,為解決這一問題,就需要用到抽出類中相似部分的方法(繼承)。繼承是隨著程序的結構化和抽象化自然進化而來的一種方式。
但最後一句話嚴格來說並不完全正確。結構化和抽象化,意味著把共通部分提取出來生成父類的自底向上的方法。如果繼承是這樣誕生的話,那麽最初,有多個父類的多重繼承就會成為主流。
但實際上,最初引入繼承的Simula編程語言,只提供單一繼承。同樣,在隨後的很多面向對象編程語言中也都是這樣的。因此我認為,繼承的原本目的實際上是逐步細化。
為什麽需要多重繼承
單一繼承只能有一個父類。有時候,大家會覺得這樣的制約過於嚴格了。在現實中,一個公司職員同時也可能是一位父親,一個程序員同時也可能是一位作家。
正如上一節中說明的,如果把繼承作為抽離出程序的共通部分的一個抽象化手段來考慮,那麽從一個類中抽象化(抽出)的部分只能有一,這個假定會給編程 帶來很大的限制。因此,多重繼承的思想就這樣產生了。單一繼承和多重繼承的區別僅僅是父類的數量不同。多重繼承完全是單一繼承的超集,可以簡單地看做是單 一繼承的一個自然延伸。
可以使用多重繼承的編程語言,不受單一繼承的不自然的限制。例如,只提供單一繼承的 Smalltalk 語言,它的類庫因為單一繼承而顯得很不自然。
多重繼承和單一繼承不可分離
經過對多重繼承和單一繼承這樣一比較,單一繼承的特點就很明顯了。
繼承關系單純
單一繼承的繼承關系是單純的樹結構,這樣有利有弊。類之間的關系單純就不會發生混亂,實現起來也比較簡單。但是,如剛才的Smalltalk的Stream一樣,不能通過繼承關系來共享程序代碼,導致了最後要復制程序。
對需要指定算式和變量類型的Java這樣的靜態編程語言來說,單一繼承還有一個缺點,我們將在後面說明。
多重繼承的特點正好相反。多重繼承有以下兩個優點:
· 很自然地做到了單一繼承的擴展;
· 可以繼承多個類的功能。
單一繼承可以實現的功能,多重繼承都可以實現。但是,類之間的關系會變得復雜。這是多重繼承的一個缺點。
goto語句和多重繼承比較相似
前面我們講到了結構化編程,說明了與其用goto語句在程序中跳來跳去,還不如用分支或者循環來控制程序的流程。分支和循環可以用goto語句來實現,單純的分支和循環組合起來不能直接實現的控制也可以用goto語句來實現。goto語句具有更強的控制力。goto語句的控制能力雖然很強,但是我們也不推薦使用。因為用goto語句的程序不是一目了然的,結構不容易理解。這樣的流程復雜的程序被稱為“意大利面條程序”。
多重繼承也存在同樣的問題。多重繼承是單一繼承的擴展,單一繼承可以實現的功能它都可以實現。用單一繼承不能實現的功能,多重繼承也可以實現。
但是,如果允許從多個類繼承,類的關系就會變得復雜。哪個類繼承了哪個類的功能就不容易理解,出現問題時,是哪個類導致的問題也不容易判明。
這樣混合起來發展的繼承稱為“意大利面條繼承”。當然也不能說所有的多重繼承都是意大利面條繼承,但是使用時格外小心是必要的。多重繼承會導致下列3個問題。
· 結構復雜化
如果是單一繼承,一個類的父類是什麽,父類的父類又是什麽,都很明確,因為只有單一的繼承關系。然而如果是多重繼承的話,一個類有多個父類,這些父類又有自己的父類,那麽類之間的關系就很復雜了。
· 優先順序模糊
具有復雜的父類的類,它們的優先關系一下子很難辨認清楚。D 繼承父類方法的順序是 D、B、A、C、Object 還是 D、B、C、A、Object,或者是其他的順序,很不明確。確定不了究竟是哪一個。相比之下,單一繼承中類的優先順序是明確了然的。
· 功能沖突
因為多重繼承有多個父類,所以當不同父類中有相同的方法時就會產生沖突。比如在上圖中,當類 B 和類 C 有相同的方法時,D 繼承的是哪個方法就不明確了,因為存在兩種可能性。
後面作者還寫了很多關於多重繼承,來排除大家對於多重繼承的誤解,並說到了Java中的接口來實現多重繼承,我大致看了下,都寫得相當有水平,我就不再贅述了。
參考文獻:
松本幸弘的程序世界
C++面向對象入門
http://learn.jser.com/cplusplus/cpp-inheritance.html
面向對象程序設計-數據抽象和繼承