類作為C++與面向對象思想結合的產物,作為面向對象思想在C++中的載體,它的身上流淌著面向對象的血液。從類成員的構成到類之間的繼承關系再到虛函數,到處都體現著面向對象封裝、繼承和多態的三大特征。
考慮這樣一個現實問題,學校中有多個老師,每個老師的名字、年齡等屬性都各不相同,但這些老師都會備課上課,具有相同的行為。那麼,我們如何在程序中表現這些老師呢?老師的具體個體雖多,但他們都屬於同一類事物——老師。在C++中,我們用類的概念來描述某一類事物,而抽象正是這個過程的第一道工序。抽象一般分為屬性抽象和行為抽象兩種。前者尋找一類事物所共有的屬性,比如老師們都有年齡、姓名等描述其狀態的數據,然後用變量將它們表達出來,比如用m_nAge變量表達年齡,用m_strName變量表達姓名;而後者則尋找這類事物所共有的行為,比如老師都會備課、上課等,然後用函數將它們表達出來,比如用PrepareLesson()函數表達老師的備課行為,用GiveLesson()函數表達其上課行為。從這裡也可以看出,整個抽象過程,是一個從具體(各個老師)到一般(變量和函數)的過程。
如果說抽象是將同類事物的共有屬性和行為提取出來並將其用變量和函數表達出來,那麼封裝機制則是將它們捆綁在一起形成一個完整的類。在C++語言中,我們可以使用6.2節中介紹的類(class)概念來封裝分析得到的變量和函數,使其成為類的成員,從而表現這類事物的屬性和行為。比如老師這類事物就可以封裝為:
// 用Teacher類封裝老師的屬性和行為 class Teacher { // 構造函數 public: // 根據名字構造老師對象 Teacher(string strName) { m_strName = strName; }; // 用成員函數描述老師的行為 public: void PrepareLesson(); // 備課 void GiveLesson(); // 上課 void ReviewHomework(); // 批改作業 // 其它成員函數… // 用成員變量描述老師的屬性 protected: string m_strName; // 姓名 int m_nAge; // 年齡 bool m_bMale; // 性別 int m_nDuty; // 職務 private: };
通過封裝,可以將老師這類事物所共有的屬性和行為緊密結合在Teacher類中,形成一個可重用的數據類型。從現實的老師到Teacher類,是一個從具體到抽象的過程,現在有了抽象的Teacher類,就可以用它來定義某個對象,進而用這個對象來描述某位具體的老師,這又是一個從抽象到具體的過程。例如:
// 定義Teacher類對象描述學校中的某位陳老師 Teacher MrChen("ChenLiangqiao"); // 學校中的某位王老師 Teacher MrWang("WangGang");
雖然MrChen和MrWang這兩個對象都是Teacher類的對象,但是因為它們的屬性不同,所以可以描述現實世界中的兩位不同的老師。
通過類的封裝,還可以很好地實現對事物的屬性和行為的隱藏。因為訪問控制的限制,外界是無法直接訪問類的隱藏信息的,對於類當中的一些敏感數據,我們可以將其設置為保護或私有類型,這樣就可以防止其被意外修改,實現對數據的隱藏。另外一方面,封裝好的類通過特定的外部接口(公有的成員函數)向外提供服務。在這個過程中,外界看到的只是服務接口的名字和需要的參數,而並不知道類內部這些接口到底是如何具體實現的。這就很好地對外界隱藏了接口的具體實現細節,而僅僅把外界最關心的服務接口直接提供給它。通過這種方式,類實現了對行為的隱藏,如圖6-10所示。
圖6-10 抽象與封裝
抽象與封裝,用來將現實世界的事物轉變成C++世界中的各個類,也就是用程序語言來描述現實世界。面向過程思想也有抽象這個過程,只是它的抽象僅針對現實世界中的過程,而面向對象思想的抽象不僅包括事物的數據,同時還包括事物的行為,更進一步地,面向對象利用封裝將數據和行為有機地結合在一起而形成類,從而更加真實地反映現實世界。抽象與封裝,完成了從現實世界中的具體事物到C++世界中類的過程,是將現實世界程序化的第一步,也是最重要的一步。
在理解了類機制是如何實現面向對象思想的封裝特性之後,繼續分析上面的例子。在現實世界中,我們發現老師和學生這兩類不同的事物有一些相同的屬性和行為,比如都有姓名、年齡、性別,都能走路、說話、吃飯等。為什麼不同的事物會有相同的屬性和行為呢?這是因為這些特征都是人類所共有的,老師和學生都是人類的一個子類別,所以都具有這些人類共同的屬性和行為。像這種子類別和父類別擁有相同屬性和行為的現象非常普遍。比如小汽車、卡車是汽車的某個子類別,它們都具有汽車的共有屬性(發動機)和行為(行駛);電視機、電冰箱是家用電器的某個子類別,它們都具有家用電器的共有屬性(用電)和行為(開啟)。
在C++中,我們用類來表示某一類別的事物。既然父子兩個類別的事物可能有相同的屬性和行為,這也就意味著父類和子類當中應該有大量相同的成員變量和成員函數。那麼,對於這些相同的成員,是否需要在父子兩個類中都定義一次呢?顯然不是。為了描述現實世界中的這種父類別和子類別之間的關系,C++提供了繼承的機制。我們把表示父類別的類稱為基類或者父類,而把從基類繼承產生的表示子類別的類稱為派生類或子類。繼承允許我們在保持父類原有特性的基礎上進行更加具體的說明或者擴展,從而形成新的子類。例如,可以說“老師是會上課的人”,那麼就可以讓老師這個子類從人這個父類繼承,對於那些表現人類共有屬性和行為的成員,老師類無需再次定義而直接從人類遺傳獲得,然後在老師子類中再添加上老師特有的表示上課行為的函數,通過繼承與發展,我們就獲得了一個既有人類的共有屬性和行為,又有老師特有行為的老師類。
所謂繼承,就是獲得從父輩傳下來的財富。在現實世界中,這個財富可能是金銀珠寶,也可能是淳淳家風,而在C++世界中,這個財富就是父類的成員變量和成員函數。通過繼承,子類可以輕松擁有父類的成員。而更重要的是,通過繼承可以對父類的成員進行進一步的細化或者擴充來滿足新的需求形成新的類。這樣,當復用舊有的類形成新類時,只需要從舊有的類繼承,然後修改或者擴充需要的成員即可。有了繼承機制,C++不僅能夠提高開發效率,同時也可以應對不斷變化的需求,因此它也就成為了消滅“軟件危機”的有力武器。
下面來看一個實際的例子,在現實世界中,有這樣一顆“繼承樹”,如圖6-11所示。
圖6-11 現實世界的繼承關系
從這棵“繼承樹”中可以看到,老師和學生都繼承自人類,這樣,老師和學生就具有了人類的屬性和行為,而小學生、中學生、大學生繼承自學生這個類,他們不但具有人的屬性和行為,同時還具有學生的屬性和行為。通過繼承,派生類不用再去重復設計和實現基類已有的屬性和行為,只要直接通過繼承就擁有了基類的屬性和行為,從而實現設計和代碼最大限度上的復用。
在C++中,派生類的聲明方式如下:
class 派生類名 : 繼承方式 基類名1, 繼承方式 基類名2…
{
// 派生類新增加的屬性和行為…
};
其中,派生類名就是我們要定義的新類的名字,而基類名是已經定義的類的名字。一個類可以同時繼承多個類,如果只有一個基類,這種情況稱為單繼承,如果有多個基類,則稱為多繼承,這時派生類可以同時得到多個基類的特征,就如同我們身上既有父親的特征,同時也有母親的特征一樣。但是,我們需要注意的是,多繼承可能會帶來成員的二義性,因為兩個基類可能擁有同名的成員,如果都遺傳到派生類中,則派生類中會出現兩個同名的成員,這樣在派生類中通過成員名訪問來自基類的成員時,就不知道到底訪問的是哪一個基類的成員,從而導致程序的二義性。所以,多繼承只在極少數必要的時候才使用,更多時候我們使用的是單繼承。
跟類成員的訪問控制類似,繼承方式也有public、protected和private三種。不同的繼承方式決定了派生類如何訪問從基類繼承下來的成員,反映的是派生類和基類之間的關系:
(1) public。
public繼承被稱為類型繼承,它表示派生類是基類的一個子類型,而基類中的公有和保護類型成員連同其訪問級別直接遺傳給派生類,不做任何改變。在基類中的public成員在派生類中也同樣是public成員,在基類中的protected成員在派生類中也是protected成員。public繼承反映了派生類和基類之間的一種“is-a”的關系,也就是父類別和子類別的關系。例如,老師是一個人(Teacher is-a Human),所以Teacher類應該以public方式繼承自Human類。 public所反映的這種父類別和子類別的關系在現實世界中非常普遍,大到生物進化,小到組織體系,都可以用public繼承來表達,所以它也是C++中最為常見的一種繼承方式。
(2) private。
private繼承被稱為實現繼承,它把基類的公有和保護類型成員都變成自己的私有(private)成員,這樣,派生類將不再支持基類的公有接口,它只希望可以重用基類的實現而已。private繼承所反映的是一種“用…實現”的關系,如果A類private繼承自B類,僅僅是因為A類當中需要用到B類的某些已經存在的代碼但又不想增加A類的接口,並不表示A類和B類之間有什麼概念上的關系。從這個意義上講,private繼承純粹是一種實現技術,對設計而言毫無意義。
(3) protected。
protected繼承把基類的公有和保護類型成員變成自己的protected類型成員,以此來保護基類的所有公有接口不再被外界訪問,只能由自身及自身的派生類訪問。所以,當我們需要繼承某個基類的成員並讓這些成員可以繼續遺傳給下一代派生類,而同時又不希望這個基類的公有成員暴露出來的時候,就可以采用protected繼承方式。
在了解了派生類的聲明方式後,就可以用具體的代碼來描述上面這棵繼承樹所表達的繼承關系了。
// 定義基類Human class Human { // 人類共有的行為,可以被外界訪問, // 訪問級別設置為public級別 public: void Walk(); // 走路 void Talk(); // 說話 // 人類共有的屬性 // 因為需要遺傳給派生類同時又防止外界的訪問, // 所以將其訪問級別設置為protected類型 protected: string m_strName; // 姓名 int m_nAge; // 年齡 bool m_bMale; // 性別 private: // 沒有私有成員 }; // Teacher跟Human是“is-a”的關系, // 所以Teacher采用public繼承方式繼承Human class Teacher : public Human { // 在子類中添加老師特有的行為 public: void PrepareLesson(); // 備課 void GiveLesson(); // 上課 void ReviewHomework(); // 批改作業 // 在子類中添加老師特有的屬性 protected: int m_nDuty; // 職務 private: }; // 學生同樣是人類,public繼承方式繼承Human類 class Student : public Human { // 在子類中添加學生特有的行為 public: void AttendClass(); // 上課 void DoHomework(); // 做家庭作業 // 在子類中添加學生特有的屬性 protected: int m_nScore; // 考試成績 private: }; // 小學生是學生,所以public繼承方式繼承Student類 class Pupil : public Student { // 在子類中添加小學生特有的行為 public: void PlayGame(); // 玩游戲 void WatchTV(); // 看電視 public: // 對“做作業”的行為重新定義 void DoHomework(); protected: private: };
在這段代碼中,首先聲明了人(Human)這個基類,它定義了人這類事物應當具有的共有屬性(姓名、年齡、性別)和行為(走路、說話)。因為老師是人的一種,是人這個類的具體化,所以我們以Human為基類,以public繼承的方式定義Teacher這個派生類。通過繼承,Teacher類不僅直接具有了Human類中公有和保護類型的成員,同時還根據需要添加了Teacher類自己所特有的屬性(職務)和行為(備課、上課),這樣就完成了對Human類的繼承和擴展,得到的Teacher類是一個“會備課、上課的人類”。
// 定義一個Teacher對象 Teacher MrChen; // 老師走進教室 // 我們在Teacher類中並沒有定義Walk()成員函數, // 這裡是通過繼承從基類Human中得到的成員函數 MrChen.Walk(); // 老師開始上課 // 這裡調用的是Teacher自己定義的成員函數 MrChen.GiveLesson();
同理,我們還通過public繼承Human類,同時增加了學生特有的屬性(m_nScore)和行為(AttendClass()和DoHomwork()),定義了Student類。進而,又根據需要,以同樣的方式從Student類繼承得到了更加具體的Pupil類來表示小學生。通過繼承,我們可以把整棵“繼承樹”完整清晰地表達出來。
仔細體會就會發現,整個繼承的過程就是類的不斷具體化、不斷傳承基類的屬性和行為,同時發展自己特有屬性和行為的過程。現實世界中的物種進化,通過子代吸收和保留部分父代的能力,同時根據環境的變化,對父代的能力做一些改進並增加一些新的能力來形成新的物種。繼承,就是現實世界中這種進化過程在程序世界中的體現。所以,類的進化也遵循著與之類似的規則:
(1) 保留基類的屬性和行為。
繼承最大的目的就是復用基類的設計和實現,保留基類的屬性和行為。對於派生類而言,不用自己白手起家,一切從零開始,只要通過繼承就直接成了擁有基類豐富屬性和行為的“富二代”。在上面的例子中,派生類Teacher通過繼承Human基類,輕松擁有了Human類的所有公有和保護類型成員,這就像站在巨人的肩膀上,Teacher類只用很少的代碼就擁有了基類遺傳下來的姓名、年齡等屬性和走路、說話等行為,實現了設計和代碼的復用。
(2) 改進基類的屬性和行為。
既然是進化,派生類就要有優於基類的地方,這些地方就表現在派生類對基類成員的修改。例如,Student類有表示“做作業”這個行為的DoHomework()成員函數,派生類Pupil本來直接繼承Student類也就同樣擁有了這個成員函數,但是,“小學生”做作業的方式是比較特殊的,基類定義的DoHomework()函數無法滿足它的需求。所以派生類Pupil只好重新定義了DoHomework()成員函數,從而根據自己的實際情況對它做進一步的具體化,對它進行改寫以適應新的需求。這樣,基類和派生類都擁有DoHomework()成員函數,但派生類中的這個函數是經過改寫後的更具體的更有針對性的,是對基類的一種改進。
(3) 添加新的屬性和行為。
如果進化僅僅是對原有事物的改進,那麼是遠遠不夠的。進化還需要一些“革命性”的內容才能產生新的事物。所以在類的繼承當中,派生類除了可以改進基類的屬性和行為之外,更重要的是添加一些“革命性”的新屬性和行為使其成為一個新的類。例如,Teacher類從Human類派生,它保留了基類的屬性和行為,同時還根據需要添加了基類所沒有的新屬性(職務)和行為(備課、上課),正是這些新添加的屬性和行為,使它從本質上區別於Human類,完成了從Human到Teacher的進化。
很顯然,繼承既很好地解決了設計和代碼復用的問題——派生類繼承保留了基類的屬性和行為,同時又提供了一種擴展的方式來輕松應對新的需求——派生類可以改變基類的行為同時根據需要添加新的屬性和行為,而這正是面向對象思想的魅力所在。
既然繼承可以帶來這麼多好處,不用費吹灰之力就可以復用以前的設計和代碼,那麼是不是可以在能夠使用繼承的地方就都使用繼承,而且越多越好呢?
當然不是。人參再好,也不能當飯吃。正是因為繼承太有用,帶來了很多好處,所以往往會被初學者濫用,最後導致設計出一些“四不像”的怪物出來。在這裡,我們要給繼承的使用定幾條規矩:
(1) 擁有相關性的兩個類才能發生繼承。
如果兩個類(A和B)毫不相關,則不可以為了使B的功能更多而讓B繼承A。也就是說,不可以為了讓“人”具有“飛行”的行為,而讓“人”從“鳥”派生,那得到的就不再是“人”,而是“鳥人”了。不要覺得類的功能越多越好,在這裡,要奉行“多一事不如少一事”的原則。
(2) 不要把組合當成繼承。
如果類B有必要使用類A提供的服務,則要分兩種情況考慮:
1) B是A的“一種”。若在邏輯上B是A的“一種”(a kind of),則允許B繼承A。例如,老師(Teacher)是人(Human)的一種,是對人的特殊化具體化,那麼Teacher就可以繼承自Human。
2) A是B的“一部分”。若在邏輯上A是B的“一部分”(a part of),雖然兩者也有相關性,但不允許B繼承A。例如,鍵盤、顯示器是電腦的一部分。
如果B不能繼承A,但A是B的“一部分”,B又需要使用A提供的服務,那又該怎麼辦呢?讓A的對象成為B的一個成員,用A和其他對象共同組合成B。這樣在B中就可以訪問A的對象,自然就可以獲得A提供的服務了。例如,一台電腦需要鍵盤的輸入服務和顯示器的輸出服務,而鍵盤和顯示器是電腦的一部分,電腦不能從鍵盤和顯示器派生,那麼我們就把鍵盤和顯示器的對象作為電腦的成員變量,同樣可以獲得它們提供的服務:
// 鍵盤 class Keyboard { public: // 接收用戶鍵盤輸入 void Input() { cout<<"鍵盤輸入"<<endl; } }; // 顯示器 class Monitor { public: // 顯示畫面 void Display() { cout<<"顯示器輸出"<<endl; } }; // 電腦 class Computer { public: // 用鍵盤、顯示器組合一台電腦 Computer( Keyboard* pKeyboard, Monitor* pMonitor ) { m_pKeyboard = pKeyboard; m_pMonitor = pMonitor; } // 電腦的行為 // 其具體動作都交由其各個組成部分來完成 // 鍵盤負責用戶輸入 void Input() { m_pKeyboard->Input(); } // 顯示器負責顯示畫面 void Display() { m_pMonitor->Display(); } // 電腦的各個組成部分 private: Keyboard* m_pKeyboard = nullptr; // 鍵盤 Monitor* m_pMonitor = nullptr; // 顯示器 // 其他組成部件對象 }; int main() { // 先創建鍵盤和顯示器對象 Keyboard keyboard; Monitor monitor; // 用鍵盤和顯示器對象組合成電腦 Computer com(&keyboard,&monitor); // 電腦的輸入和輸出,實際上最終是交由鍵盤和顯示器去完成 com.Input(); com.Display(); return 0; }
在上面的代碼中,電腦這個類由Keybord和 Monitor兩個類的對象組成(當然,在具體實踐中還應該有更多組成部分),它的所有功能都不是它自己實現的,而是由它轉交給各個組成對象具體實現,它只是提供了一個統一的對外接口而已。這種把幾個類的對象結合在一起構成新類的方式就是組合。雖然電腦沒有繼承鍵盤和顯示器,但是通過組合這種方式,電腦同樣獲得了鍵盤和顯示器提供的服務,具備了輸入和輸出的功能。關於組合,還需要注意的是,這裡使用了對象指針作為類成員變量來把各個對象組合起來,是因為電腦是一個可以插拔的系統,鍵盤和顯示器都是可以更換的。鍵盤可以在這台電腦上使用,也可以在另外的電腦上使用,電腦和鍵盤的生命周期是不同的各自獨立的。所以這裡采用對象指針作為成員變量,兩個對象可以各自獨立地創建後再組合起來,也可以拆分後另作他用。而如果遇到整體和部分密不可分的情況,兩者具有相同的生命周期,比如一個人和組成這個人的胳膊、大腿等,這時就該直接采用對象作為成員變量了。例如:
// 胳膊 class Arm { public: // 胳膊提供的服務,擁抱 void Hug() { cout<<"用手擁抱"<<endl; } }; // 腳 class Leg { public: // 腳提供的服務,走路 void Walk() { cout<<"用腳走路"<<endl; } }; // 身體 class Body { public: // 身體提供的服務,都各自交由組成身體的各個部分去完成 void Hug() { arm.Hug(); } void Walk() { leg.Walk(); } private: // 組成身體的各個部分,因為它們與Body有著共同的生命周期, // 所以這裡使用對象作為類的成員變量 Arm arm; Leg leg; }; int main() { // 在創建Body對象的時候,同時也創建了組成它的Arm和Leg對象 Body body; // 使用Body提供的服務,這些服務最終由組成Body的Arm和Leg去完成 body.Hug(); body.Walk(); // 在Body對象銷毀的同時,組成它的Arm和Leg對象也同時被銷毀 return 0; }