不同的人在談物件導向編程(OOP)時所指的含義並不相同。有人認為任何採用圖形介面的應用程式都是物件導向的。有人把它作為術語來描述一種特別的處理序間通訊機制。還有人使用這個詞彙是另有深義的,他們其實是想說:“來啊,買我的產品吧!”我一般不提OOP,但只要提到,我的意思是指使用繼承和動態綁定的編程方式。 --《C++沉思錄》
《C++沉思錄》說的是十幾年前的事了,現在大家對物件導向的回答已經是眾口一詞:封裝、繼承和多態。大家都知道,在物件導向中,一輛汽車是一個對象,汽車這個概念是一個類。汽車有漂亮的外觀,把各種內部原理都隱藏起來了,司機不必知道它的內部工作原理仍然能開車,即使汽車隨技術的進步不斷升級,對司機也沒有什麼影響,這就是封裝的好處。
汽車是交通工具的一種,汽車是一個類,交通工具也是一個類,而交通工具類包括了汽車類,從而具有更廣泛的意義。這種從抽象到具體的關係就是繼承關係,我們可以說汽車類繼承了交通工具類,汽車類是交通工具類的子類,交通工具類是汽車類的父類。
作為交通工具,它肯定可以運動(move),從甲地運動到乙地,就起到了交通的作用。輪船是一種交通工具,所以輪船類也是交通工具類的子類。同樣是運動,輪船的運動和汽車的運動方式肯定有所不同,這樣以不同的方式完成同樣的功能就叫多態。
關於對象:對象就是某一具體的事物,比如一個蘋果, 一台電腦都是一個對象。每個對象都是唯一的,兩個蘋果,無論它們的外觀有多麼相像,內部成分有多麼相似,兩個蘋果畢竟是兩個蘋果,它們是兩個不同的對象。對象可以是一個實物,也可能是一個概念,比如某個蘋果對象是實物,而一項政策可能就是一個概念性的對象了。
關於類:對象可能是一個無窮的集合,用枚舉的方式來表示對象集合不太現實。抽象出對象的特徵和功能,按此標準將對象分類,這就引入類的概念。類就是一類事物的統稱,類實際上就是一個分類的標準,符合這個分類標準的對象都屬於這個類。當然,為了方便起見,通常只需要抽取那些,對當前應用來說是有用的特徵和功能。
關於抽象類別:類是對對象的抽象,比如,蘋果是對所有具體的蘋果的抽象。如果我們對蘋果這個類進行一步抽象,可以得到一個水果類。這種對類本身進行抽象而得到的類,就是抽象類別。抽象類別不像普通類,它是沒有對象與之對應的。像蘋果類,你總是可以拿到一個叫蘋果的東西,而對於水果類,根本沒一個真正叫水果的東西。你可以說一個蘋果是一個水果,從邏輯上講沒有錯,但沒有什麼意義。一般在程式中,抽象類別是不能執行個體化的。
關於物件導向:物件導向就是以對象為中心。為什麼不說是面對類,而說是物件導向呢?類是對象的集合,考慮類實際上也是在考慮對象,有時甚至並不嚴格的區分它們。所以說物件導向一詞比面向類更確切。
既然以對象為中心,物件導向所考慮的內容自然是對象、對象間的協作、對象的分類、類之間的關係等等,由此引申了出幾個重要的概念。
1. 封裝
what:對象也有隱私,對象的隱私就是對象內部的實現細節。要想對象保持良好的形象就要保護好對象隱私,所謂的封裝其實就是保護對象隱私。當然,沒有人能完全隱藏自己的隱私,比如你去轉戶口時,你不得不透露自己的家庭資訊和健康情況。另外,在不同的場合所透露隱私的數量也不一樣,朋友和家人可能會知道你更多隱私,同事次之,其他人則知道得更少。物件導向也考慮了這些實際的情況,所以像C++之類的語言有public/private/protected/friend等關鍵字,以適應於不同的情況。
why:封裝可以隔離變化。據以往的經驗,我們知道內部實現是容易變化的,比如電腦在不斷的升級,機箱還是方的,但裡面裝的CPU和記憶體已是今非昔比了。變化是不可避免的,但變化所影響的範圍是可以控制的,不管CPU怎麼變,它不應該影響使用者使用的方式。封裝是隔離變化的好辦法,用機箱把CPU和記憶體等等封裝起來,對外只提供一些標準的介面,如USB插口、網線插口和顯示器插口等等,只要這些介面不變,內部怎麼變,也不會影響使用者的使用方式。
封裝可以提高易用性。封裝後只暴露最少的資訊給使用者,對外介面清晰,使用更方便,更具方便使用性。試想,如果普通使用者都要知道機箱內部各種晶片和跳線,那是多麼恐怖的事情,到現在為止我甚至還搞不清楚硬碟的跳線設定,幸好我沒有必要知道。
how:在C語言中,可以用結構+函數來類比類的實現,而用這種結構定義的變數就是對象。封裝有兩層含義,其一是隱藏內部行為,即隱藏內建函式,調用者只能看到對外提供的公用函數。其二是隱藏內部資訊,即隱藏內部資料成員。現在都建議不要對外公開任何資料成員,即使外部需要知道的資料成員,也只能通過函數擷取。
在C語言中要隱藏內建函式很簡單:不要它把放在標頭檔中,在C檔案中定義時,前面加static關鍵字,每個類放在獨立的檔案中。這樣可以把函數的作用範圍限於當前檔案內,當前檔案只有類本身的實現,即只有當前的類自己才能看到這些函數,這就達到了隱藏的目的。
在C語言中要隱藏資料成員較為麻煩,它沒有提供像C++中所擁有的public/protected/friend/private類似的關鍵字。只能通過一些特殊方法類比部分效果,我常用的方法有兩種。
其一是利用C的特殊文法,在標頭檔中提前聲明結構,在C檔案才真正定義它。這樣可以把結構的全部資料資訊都隱藏起來。因為外部不知道對象所佔記憶體的大小,所以不能靜態建立該類的對象,只能調用類提供的建立函數才能建立。這種方法的缺陷是不支援繼承,因為子類中得不到任何關於父類的資訊。如:
標頭檔:
struct _LrcPool; typeef struct _LrcPool LrcPool; LrcPool* lrc_pool_new(size_t unit_size, size_t n_prealloc_units); void* lrc_pool_alloc(LrcPool* thiz); void lrc_pool_free(LrcPool* thiz, void* p); void lrc_pool_destroy(LrcPool* thiz); C檔案: struct _LrcPool { size_t unit_size; size_t n_prealloc_units; }; LrcPool* lrc_pool_new(size_t unit_size, size_t n_prealloc_units) { LrcPool* thiz = LRC_ALLOC(LrcPool, 1); if(thiz != NULL) { thiz->unit_size = unit_size; thiz->n_prealloc_units = n_prealloc_units; } return thiz; } |
其二是把私人資料資訊放在一個不透明的priv變數中。只有類的實現代碼才知道priv的真正定義。如:
標頭檔:
struct _LrcBuilder { LrcBuilderBegin on_begin; LrcBuilderOnIDTag on_id_tag; LrcBuilderOnTimeTag on_time_tag; LrcBuilderOnLrc on_lrc; LrcBuilderEnd on_end; LrcBuilderDestroy destroy; char priv[1]; }; C檔案: struct _LrcDumpBuilder { FILE* fp; }; typedef struct _LrcDumpBuilder LrcDumpBuilder; LrcBuilder* lrc_dump_builder_new(FILE* fp) { LrcDumpBuilder* data = NULL; LrcBuilder* thiz = (LrcBuilder*)calloc(sizeof(LrcBuilder) + sizeof(LrcDumpBuilder), 1); if(thiz != NULL) { thiz->on_begin = lrc_dump_builder_on_begin; thiz->on_id_tag = lrc_dump_builder_on_id_tag; thiz->on_time_tag = lrc_dump_builder_on_time_tag; thiz->on_lrc = lrc_dump_builder_on_lrc; thiz->on_end = lrc_dump_builder_on_end; thiz->destroy = lrc_dump_builder_destroy; data = (LrcDumpBuilder*)thiz->priv; data->fp = fp != NULL ? fp : stdout; } return thiz; } |
2. 繼承
what: 繼承描述的是一種抽象到具體的關係。具體的東西繼承了抽象的東西的特性,比如說,水果這個概念比蘋果這個概念更抽象,其意義更具有一般性,而蘋果這個概念則更具體,其意義更狹窄一些,在物件導向裡,我們可以說蘋果類繼承了水果類。繼承是指繼承了父類的特性,繼承本質是源於分類學,細的分類繼承大分類的特性。
why: 繼承描述了抽象到具體的關係,所以能夠有效利用抽象這件武器來戰勝軟體的複雜性。抽象在實現中無處不在,類就是對事物的抽象,提到蘋果你就想到蘋果這一類事物,無需要關心其大小、顏色和成分,蘋果這兩個字就足夠了。名不正則言不順,言不順則事不成,看來老夫子已經領悟到了抽象的威力。
繼承不但利用了抽象的力量來降低系統的複雜性,它還提供了一種重用的方式。假設我們承認下列面這個繼承關係,蘋果繼承了水果,水果繼承了食物,如果我們已經知道什麼是食物,什麼是水果,在描述蘋果時,沒有必要去重複講解食物和水果的概念了,這就是重用,重用了對水果和食物兩個概念的理解。
how: 在C語言中實現繼承很簡單,可以用結構來類比。這種實現基於一個明顯的事實,結構在記憶體中的布局與結構的聲明具有一致的順序。我們知道在程式描述事物的特徵時,主要通過資料變數描述事物的屬性特徵,如顏色、重量和體積等,用函數來描述事物的行為特徵,和運動、成長和搏鬥等。在C語言中實現繼承的方式如:
struct _GObject { GTypeInstance g_type_instance; volatile guint ref_count; GData *qdata; }; struct _GtkObject { GObject parent_instance; guint32 flags; }; struct _GtkWidget { GtkObject parent_instance; guint16 private_flags; guint8 state; guint8 saved_state; gchar *name; GtkStyle *style; GtkRequisition requisition; GtkAllocation allocation; dkWindow *window; GtkWidget *parent; }; |
繼承
繼承在現實世界中應用很廣,在程式裡也是一樣,甚至可以說是過度使用了。多年以前一些大師已經提出,優先使用組合而不是繼承。主要原因有三點,首先是多級繼承和多重繼承太複雜了,失去了抽象帶來的簡潔性。其次是父類與子類之間共用太多資訊,它們的耦合太緊密。三是父類與子類之間的關係在編譯時間就靜態繫結了,很難做到在運行時多態。
現在一般都提倡,只繼承介面不繼承實現,通過組合達到代碼重用的目的。在《設計模式》中是這樣強調的,在MS的COM裡也是這樣做的。所以我基本上只使用介面繼承,很少遇到什麼麻煩,建議大家也遵循這一準則。
3. 多態
what: 儘管多態這個詞本身就表明了它所代表的意義,但還是讓初學者感到多少有些神秘。多態就是完成相同功能的多種方式,比如拿動物的運動來說吧,鳥的運動通常是飛,魚的運動通常是遊,陸上動物的運動通常是跑,同是運動,但方式不一樣,這就是多態。不少人對多態的回答是,允許同名函數存在。這種回答顯然沒有抓住多態的本質。
why: 關於動物運動這個例子,可能無法展示多態的好處。我們來考慮另外一個多態的例子,隨身碟。隨身碟的技術含量可能不是很高,有很多廠家都在設計和生產,就是說隨身碟有多種不同的實現,這就是隨身碟的多態。隨身碟的多態性對消費者來說是有好處的,選擇多了,你可以在價格、品質和外觀等方式做出平衡,選擇你中意的隨身碟。多態的前提是介面的一致性,否則多態造成的麻煩遠勝於它帶來的好處。不管隨身碟的體積、顏色和品質如何,它都必需遵循相應的USB標準,這些隨身碟在任何帶USB介面的電腦上都可以使用。
how: 多態在C語言中通常用函數指標來實現,函數指標定義了函數的原型,即它的參數和傳回值的描述,以及函數的意義,不同的函數可以有相同的函數原型,比如排序函數,無論是快速排序還是歸併排序,它們的實現不一樣,但函數原型可以一樣。在不同的情況下,讓函數指標到不同的函數實現上,這就實現了多態。下面的C語言例子:
介面:
struct _LrcBuilder; typedef struct _LrcBuilder LrcBuilder; typedef LRC_RESULT (*LrcBuilderBegin)(LrcBuilder* thiz, const char* buffer); typedef LRC_RESULT (*LrcBuilderOnIDTag)(LrcBuilder* thiz, const char* key, size_t key_length, const char* value, size_t value_length); typedef LRC_RESULT (*LrcBuilderOnTimeTag)(LrcBuilder* thiz, size_t start_time); typedef LRC_RESULT (*LrcBuilderOnLrc)(LrcBuilder* thiz, const char* lrc, size_t lrc_length); typedef LRC_RESULT (*LrcBuilderEnd)(LrcBuilder* thiz); typedef LRC_RESULT (*LrcBuilderDestroy)(LrcBuilder* thiz); struct _LrcBuilder { LrcBuilderBegin on_begin; LrcBuilderOnIDTag on_id_tag; LrcBuilderOnTimeTag on_time_tag; LrcBuilderOnLrc on_lrc; LrcBuilderEnd on_end; LrcBuilderDestroy destroy; char priv[1]; }; |
實現一:
LrcBuilder* lrc_dump_builder_new(FILE* fp) { LrcDumpBuilder* data = NULL; LrcBuilder* thiz = (LrcBuilder*)calloc(sizeof(LrcBuilder) + sizeof(LrcDumpBuilder), 1); if(thiz != NULL) { thiz->on_begin = lrc_dump_builder_on_begin; thiz->on_id_tag = lrc_dump_builder_on_id_tag; thiz->on_time_tag = lrc_dump_builder_on_time_tag; thiz->on_lrc = lrc_dump_builder_on_lrc; thiz->on_end = lrc_dump_builder_on_end; thiz->destroy = lrc_dump_builder_destroy; data = (LrcDumpBuilder*)thiz->priv; data->fp = fp != NULL ? fp : stdout; } return thiz; } |
實現二:
LrcBuilder* lrc_default_builder_new(void) { LrcDefaultBuilder* data = NULL; LrcBuilder* thiz = (LrcBuilder*)calloc(sizeof(LrcBuilder) + sizeof(LrcDefaultBuilder), 1); if(thiz != NULL) { thiz->on_begin = lrc_default_builder_on_begin; thiz->on_id_tag = lrc_default_builder_on_id_tag; thiz->on_time_tag = lrc_default_builder_on_time_tag; thiz->on_lrc = lrc_default_builder_on_lrc; thiz->on_end = lrc_default_builder_on_end; thiz->destroy = lrc_default_builder_destroy; data = (LrcDefaultBuilder*)thiz->priv; } return thiz; } |
類的三個層次:
類這個概念比較微妙,即使在軟體開發領域,不同的人提到這個概念所指的內容也不一樣。一些大師早就注意到了這一點,為了讓這個概念在不同情況下,具有較準確的意義,他們建議從三個層次看待類這個概念:
1. 概念層(Conceptual)
這是一個較高的層次,通常在進行領域分析時,為了建立概念性模型時使用。這時使用的術語是現實世界中的術語,而不是軟體開發中的術語。在這個層次,類只是一個概念,加上一些不太嚴謹的特徵說明,甚至只有一個名稱。儘管它往往與軟體開發中的類一一對應,便這種映射並不一定是直接的。
2. 規格層(Specification)
在這個層次,類已經是屬於軟體開發範疇了,但主要關注的是類的介面,而不是類的實現。此時你可能想到它的一組介面函數,而不關心這些函數是如何?的。
3. 實現層(Implementation)
在這個層次,我們才真正關注類的實現,此時你可能會想到一些用某種語言寫成的函數體,定義的成員變數等等。
物件導向的好處:
物件導向已經征服了軟體開發的絕大部分領域,近幾年來出現的面向方面的編程(AOP)、產生式編程(GP)和面向組件的開發等等,都提出了一些新的思維,在某些方面大提高了開發效率,但它們並非是取代了物件導向,相反是對物件導向的補充和完善,物件導向始終穩坐第一把交椅。
物件導向到底有何德何能,它憑藉什麼取代面向對程呢?封裝、繼承和多態到底有何種魔力,吸引眾多的高手去完善它,讓佈道者們不厭其煩的頌揚它呢?歸根結底,物件導向會帶來兩個好處,這兩個好處正是設計者們一直在追求的:
1. 降低系統的複雜度。
眾所周知,隨著硬體的飛速發展,電腦的計算能力越來越強大,人們對軟體期望也越來越高,而軟體複雜度又與它的規模成指數倍數增長。軟體複雜度可以說是軟體開發的第一大難題,我們可以輕而易舉寫出5000行代碼,而面對100萬行代碼規模的軟體,會有點讓人覺得人的智力是多麼有限。
而物件導向正是降低系統複雜度的好方法。首先它按類來組織系統,把系統分成幾個大的部分,每個部分又由更小的子類組成,如此細分下去直到我們能輕易實現它為此,這種分而治之的方法符合人類解決複雜問題的習慣。
其次是它採用從抽象到具體的順序來把握事物,抽象讓我們用少量精力先掌握事物的共性,然後再去研究事物更具體的特性,這種逐漸細化的方法也是符合人類解決複雜問題的習慣的。
2. 隔離變化。
需求變化和技術變化也是軟體開發所面臨的兩大難題。使用者似乎從來不知道什麼是他們真正的需求,或許他們真正的需求也是在變化的。技術可謂日新月異,我們不斷的發明新技術,這些技術幫我們提高了生產力。但有時新技術也會砸到自己的腳,為了運用這些新技術,我們要花更多時間學習和工作。或者說創新只是滿足了少數人的樂趣,讓多數人吃了苦頭。
只有變化才是永恒的,詛咒變化無濟於事,面對它並搞掂它才是正途。大師也建議我們去擁抱變化,而不是逃避變化。物件導向正是擁抱變化的銀彈之一,它不是嘗試阻止變化的發生(對此誰也無能為力),而是努力去隔離變化。與整體比較,變化的部分畢竟只佔很小的分量,如果這些變化被隔離開了,它不影響不變的部分的,變的部分獨立變化,不會牽一髮而動全身,這可以大大減少變化引起的麻煩。針對介面編程而不是針對實現編程,這是面對象的精髓,也是擁抱變化的良方。