ANSI/ISO C++ Professional Programmer's Handbook(5)

  摘自:http://sttony.blogspot.com/search/label/C%2B%2B

5


面向對象的編程和設計


by Danny Kalev



簡介


在今天C++是廣泛使用的面向對象的程序設計語言。C++的成功是使面向對象程序設計成爲軟件工業標準的顯著因素。然而,與其他面向對象的程序設計語言不同(有些已使用了近30年了),C++不強迫使用面向對象的程序設計——它可以認爲是一種基於對象的“更好的C”,或是一種泛型程序設計語言。這種前所未有的彈性使的C++適用與各種領域——實時系統、嵌入式系統、數據處理、數值計算、圖形處理、人工智能或操作系統。


這章從介紹C++支持的多種程序設計風格開始。然後,集中於面向對象的編程和設計。


程序設計範例


程序設計範例定義設計和實現軟件的方法,包括用語言構件,數據結構和操作數據結構的方法的相互關係,程序結構,問題如何抽象,如何解決。一種程序設計語言提供了語言上的手段(關鍵字、預處理標誌、程序結構),以及擴展語言的能力——就是爲了支持特定程序設計範例的標準庫和程序設計環境。通常給定程序設計語言是爲了應用於特定的應用環境,例如字符串處理、數學應用程序、仿真、Web等等。但是C++不限定於特定應用環境。更恰當的說法,它支持許多有用的程序設計範例。現在討論C++支持的最常用的程序設計範例。


過程化的程序設計


C++是ISO C的超集。既然如此,它也能作爲過程化的程序設計語言使用,雖然它有更嚴格的類型檢查和許多改進設計和編碼的擴展:引用變量、內聯函數、默認參數和bool類型。過程化的程序設計基於函數和函數操作的數據分離。一般來說,函數依賴於他們操作數據的物理表示方法。這種依賴關係是維護和擴展過程化軟件所面對的主要問題。


過程化程序設計容易受設計變化的影響


只要類型的定義變化(移植軟件到另一個平臺上,顧客需求的改變等等,都會導致這種結果),引用數據的函數就不得不更着改變。反過來,也是這樣:當函數改變時,它的參數也受影響;例如,函數可能通過地址傳遞結構來代替值傳遞以取得最優的性能。考慮下面的例子:



struct Date //壓縮結構中的數據包
{
char day;
char month;
short year;
};
bool isDateValid(Date d); //值傳遞
void getCurrentDate(Date * pdate); //改變它的參數,用地址傳遞
void initializeDate (Date* pdate); //改變它的參數,用地址傳遞

數據結構,比如Date,和用來初始化、讀、測試它的一組函數在主要用C編程的軟件工程中是很普通的。現在假設修改了設計,要求Date也存儲當前的以秒記的時間戳。因此改變Date的定義如下:



struct Date
{
char day;
char month;
short year;
long seconds;
}; //現在比前面複雜了

所有使用了Date的函數不得不改變以適應Date的改變。另一個設計的變化又增加了一個存儲微秒的域,以作爲數據處理中唯一的時間戳。現在改變Date



struct Date
{
char day;
char month;
short year;
long seconds;
long millionths;
};

所有使用了Date的函數不得不再次改變。這一次,函數的接口都改變了,因爲Date現在至少佔用12字節。本來通過值傳遞Date的函數,現在改成接受Date的指針。



bool isDateValid(Date* pd); //爲了效率傳遞指針

過程化程序設計的缺點


這個例子不是假想的。在幾乎每一個軟件工程裏,設計改變是很平常的。預算和時間超支導致的結果是無法抗拒的;有時甚至導致工程的中止。爲了避免——至少是減小——這種超支的嘗試引出了新的程序設計範例。


過程化程序設計將代碼重用限制在很少的形式內,函數調用或使用公用的用戶定義數據結構。雖然如此,數據結構和使用函數之間有強耦合性,這大大限制了他們的可複用性。例如,計算double平方根的函數,不能適用用戶定義的用struct表示的複數。一般的,過程化程序設計語言依賴於靜態類檢查,這有比動態類檢查更好的性能——但是這也犧牲了軟件的擴展
性。


過程化程序設計語言提供了一套封閉的、不能擴展的內建數據類型。用戶定義類型不被支持或是“二等公民”。用戶不能重新定義內建運算符來支持用戶定義類型。另外,由於缺乏抽象和信息隱藏的機制,使得用戶不得不暴露實現細節。考慮標準C函數atof()atoi()atol()(他們分別將C字符串轉換成doubleintlong)。他們不僅要求用戶關注返回值的物理數據類型(現在在大多數機器上,intlong以同樣的方式表示),而且他們禁止適用其他數據類型。


爲什麼過程化的程序設計仍然在使用?


儘管有其顯而易見的缺點,過程化程序設計在一些特殊的應用環境仍然是首選,比如在嵌入式和時間緊迫系統中。過程化程序設計在機器生成的代碼中廣泛使用,因爲此時代碼的可重用性、可擴展性和維護費用沒有任何意義。例如,許多SQL解釋器將SQL語句翻譯成過程化的C代碼再編譯。


在高級語言中,過程化的程序設計語言——如C、Pascal或Fortran——產生的機器代碼是最有效率的。事實上,不願意使用面向對象的開發團隊常常將性能下降作爲主要原因。


C++的演化在程序設計語言中是獨特的。C++的創建者的工作可以有很多簡便,可以從一個草案中設計它而不需要考慮與C的兼容性。然而兼容性是它的優點之一:它使得組織和程序員可以不用廢棄幾億行用C寫的代碼就能從C++獲得好處。此外,C程序員在完全掌握面向對象程序設計之前就能容易的變成使用C++的生產者。


基於對象的程序設計


過程化程序設計使得研究人員和開發者找到了更好的方法——分離實現細節和接口。面向對象的程序設計使他們可以創建象“一等公民”一樣的用戶定義類型。用戶定義類型可以將數據和操作數據的方法捆綁的一個單獨的實體class中。類也支持信息隱藏從而分離了類的實現細節和類的接口。類的用戶允許訪問其接口,但不能訪問它的實現細節。分離實現細節——可能由於設計
變化、可移植性和效率等原因經常變化——和相對穩定的接口有實質意義。這種分離保證了設計上的變化僅限制在一個實體之內——類的實現;另一方面,類的用戶不受影響。爲了評定基於對象程序設計的重要,考慮一個簡單的有代表性的Date
類:



class Date
{
private:
char day;
char month;
short year;
public:
bool isValid();
Date getCurrent();
void initialize();
};

基於對象的程序設計將變化限制在實現細節之內


現在假設你必需改變Date的定義以支持時間:



class Date
{
private:
char day;
char month;
short year;
long secs;
public:
bool isValid();
Date getCurrent();
void initialize ();
};

增加新的數據成員對Date的接口沒有影響。Date的用戶更本不知道增加了新的域;他們仍然象以前一樣接受類的同樣的服務。當然,Date的實現必須改變成員函數的代碼來反映變化。因此,Date::initialize()必須初始化增加的域。儘管如此,變化僅僅限制在Date::initialize()的定義,因爲用戶不能訪問Date接口之下的內容。但是在過程化程序設計中,用戶可以直接訪問Date數據成員。


抽象數據類型


Date這樣的類有時叫做concrete types(具體類型)抽象數據類型(不要與抽象類混淆;參見本章稍後的“抽象對象VS抽象類”)。


這些類可能遇到與接口分離的清晰、易於維護的大量變量。C++以類的形式爲數據抽象提供必要的機制,類將數據和處理數據的一整套操作捆在一起。通過private訪問控制字來完成信息隱藏,private限制只有類的成員函數才能訪問數據成員。


運算符重載


在基於對象的語言裏,用戶能擴展內建運算符的定義以支持用戶自定義類型(運算符在第三章:“運算符重載”中討論)。這種特性通過將用戶定義類型當作內建類型來提供一種更高層的抽象。例如



class Date
{
private:
char day;
char month;
short year;
long secs;
public:
bool operator < (const Date& other);
bool operator == (const Date& other);
//...其他成員函數
};

基於對象程序設計的特性


從某種意義來講,基於對象的程序設計是面向對象程序設計的子集;也就是說,一些普遍的規則適用於兩者。但是,與面向對象程序設計不使用繼承。就是說,每一個用戶定義類型是一個自包含的實體,他們既不從更一般的類型派生,也不作爲其他類的基類。這種範例缺乏繼承不是巧合。基於對象的支持者認爲:繼承使設計複雜化,並且基類的bug和不足可能會傳給子類。此外,繼承也意
味着多態,這是另一個使設計複雜化的源頭。例如,接受基對象爲參數的函數也必須知道如何處理任何一個基對象的公共派生對象。


基於對象程序設計的優點


基於對象的程序設計克服了過程化程序設計的大多數缺點。它限制了變化的影響,將接口與實現細節分開,也支持用戶定義類型。標準庫提供了豐富的抽象數據類型,包括stringcomplexvector。設計這些類是爲了爲非常特定的用處提供抽象,例如,字符處理和複數運算。他們不是從更普通的類派生的,也不想派生其他類。





抽象數據類型VS抽象類

抽象對象類型抽象類是兩種完全不同的概念,儘管因爲歷史原因兩者都使用abstract這個詞。抽象數據類型(也叫具體類型)是用戶定義的一種自包含類型,它將數據和相關的操作捆綁在一起。有些時候他象內建類型一樣。但是,他不是可擴展的也不展示動態多態性。與之相反,抽象類可以可以是除了抽象數據類型以外的任何東西。它不是數據類型(一般的,抽象類不包括任何數據成員),也不允許你產生它的實例。抽象類只是一種框架接口,它指定了其他(非抽象)類實現的一套服務或操作。不幸的是,兩種概念之間的區別經常被混淆。許多人在他們想表示抽象類的地方錯誤的使用抽象數據類型。



基於對象的程序設計的侷限性


對於特定的用途基於對象的程序設計是有利的。但是,它不能表現現實世界中對象的關係。例如,軟盤和硬盤的共性就不能用基於對象的設計直接表示。硬盤和軟盤都能存儲文件;他們都能包含目錄和子目錄等等。但是,基於對象的設計必須創建兩個截然不同的獨立的實體,不能共享兩者的共有特性。


面向對象的程序設計


面向對象的程序設計爲定義類層次提供了必要的構造,從而克服了基於對象程序設計的侷限。類層次抓住了相似——不同也可以——類型之間的共有特點。例如,MouseJoystick是兩個不同的實體,但是他們共有許多特性。這些共有特性可以用通用類PointingDevice,表示,這個類可作爲兩者的基類。面向對象程序設計的基礎也是基於對象程序設計的基礎:信息隱藏、抽象數據類型和封裝。另外它還支持繼承、多態和動態綁定。


面向對象程序設計的特性


面向對象的程序設計有時與其他語言的有很大區別。例如,轉到C++的Smalltalk程序員發現兩種語言的差別有點令人害怕。當然,轉到Smalltalk或Eiffel的C++程序員也會有同樣的想法。但是,面向對象的程序設計語言與非面向對象的語言相比有幾個通用的特性。這些特性在下面的章節中展示。


繼承


繼承使派生類可以重用基類的功能和接口。重用的優點是顯著的:更快的開發時間、更簡單的維護和更簡單的可擴展性。類層次抓住存在的相關類的一般的共有的特性。更一般的操作在繼承樹中更高的類中實現。一般的,設計考慮的是特定應用環境。例如,書店的在線訂購系統和大學語言系的計算機化圖書館的類ThesaurusDictionary有不同的處理方式。在書店的在線訂購系統中,類ThesaurusDictionary可以從更普通的基類Item繼承:



#include <string>
#include <list>
using namespace std;
class Review{/*...*/};
class Book
{
private:
string author;
string publisher;
string ISBN;
float list_price;
list<Review> readers_reviews;
public:
Book();
const string& getAuthor() const;
//...
};

DictionaryThesaurus定義如下:



class Dictionary : public Book
{
private:
int languages; //雙語,三種語言等等
//...
};
class Thesaurus: public Book
{
private:
int no_of_entries;
//...
};

然而,語言系的計算機化圖書館可能使用不同的繼承層次:



class Library_item
{
private:
string Dewey_classification;
int copies;
bool in_store;
bool can_be_borrowed;
string author;
string publisher;
string ISBN;
public:
Library_item();
const string& getDewey_classification() const;
//...
};
class Dictionary : public Library_item
{
private:
int languages;
bool phonetic_transciption;
//...
};
class Thesaurus: public Library_item
{
private:
int entries;
int century; //語言的歷史時代,例如莎士比亞時代
//...
};

兩種層次看上去不一樣,因爲他們爲不同的目標工作。但是,至關重要的一點是共有的功能和數據可以在基類中實現,然後由特定的類擴展。引如一個新類,例如Encyclopedia,到書店在線訂購系統和語言系的計算機化圖書館中在面向對象環境中是很容易實現。因爲不管它是什麼,它的大部分功能在基類中以實現了。換句話說,在面向對象的環境中,每一個新類可以從草稿開始。


多態


多態是不同對象以不同方式對相同消息相應的能力。多態在自然語言中廣泛使用。考慮動詞:它用於不同對象有不同的含義。關門,關閉銀行帳號或關閉程序窗口多時不同的動作;他們的確切含義依賴與執行動作的對象。同樣的在面向對象程序設計中多態意味着消息的解釋依賴與接受消息的對象。C++有三種靜態(編譯期)多態:運算符重載、模板和函數重載。


運算符重載


例如,運算符+=用於intstring的含義依賴於這些對象不同的解釋方式。但是,你可以直觀的
預測結果,並且你可以發現兩者的相似之處。支持運算符重載的面向對象的程序設計語言以一種限制的方式支持運算符重載,多態也一樣。


模板


vector<int>vector<string>有不同的響應;就是說,當他們接到一樣消息時執行一套不同的指令。但是,你可能期望兩者有相似的行爲(模板在第九章“模板”中詳細討論)。考慮下面的例子:



vector < int > vi; vector < string > names;
string name("Bjarne");
vi.push_back( 5 ); //在vector尾增加一個整數
names.push_back (name); //在vector尾增加一個整數一個字符串

函數重載


函數重載是多態的第三種形式。爲了重載函數,不同的重載版本有不同的形參表。例如,叫f() 的一套重載函數可能十分相
似,如下:



void f(char c, int i);
void f(int i, char c); //形參的順序也是一個重要的因素
void f(string & s);
void f();
void f(int i);
void f(char c);

注意,在C++種僅僅返回值不同的重載函數是錯誤的:



int f(); //錯誤;與void f();只有返回值不同
int f(float f); //正確——獨特的標誌

動態綁定


動態 綁定進一步體現的多態的思想。在動態綁定中,消息的含義依賴於接受消息的對象;但是,對象的確定類型只有在運行期才決定。虛函數是一個很好的例子。虛函數的指定版本在編譯期是不知道的。這時調用在運行期才決定,如下所示:



#include <iostream>
using namespace std;
class base
{
public: virtual void f() { cout<< "base"<<endl;}
};
class derived : public base
{
public: void f() { cout<< "derived"<<endl;} //overrides base::f
};
void identify(base & b) //參數可以是基類的實例
//也可以是派生類的實例
{
b.f(); //base::f or derived::f? resolution is delayed to runtime
}
//a separate translation unit
int main()
{
derived d;
identify; //參數是派生類對象
return 0;
}

函數identify可以接受任何從類base公共派生的任何對象——甚至在identify編譯之後定義的
子對象。


動態綁定有很多優點。在這個例子中,它使用戶可以不修改identify而擴展base的功能。在過程化和基於對象程序設計,這樣的彈性基本上是不可能的。此外,動態綁定的底層機制是自動的。程序員不必爲運行期查找和分派虛函數實現任何代碼,程序員也不需要檢查對象的動態類型。


面向對象程序設計的技巧


到現在爲止,討論的焦點在面向對象編程和設計的一般特性。這部分展示C++的特殊應用技術和麪向對象程序設計的指導方式。


類設計


在C++中類是主要的抽象單元。在面向對象軟件系統的生存週期中,最重要的階段可能就是在分析和設計階段確定正確的類。確定類的一般性指導規則是,類必須表現現實世界中的對象;另一個主張是自然語言中的名詞必須描述類。在一定的範圍內這一條是正確的,但是在特殊軟件工程中常常有一些只在程序設計領域存在的類。異常表示現實世界中的對象嗎?函數對象(在第十章“STL和泛型程序設計”中討論)和智能指針在程序設計環境之外有等價物嗎?顯然,現實實體和對象的關係不是1:1。


確定類


確定正確類的方法來源於應用領域的功能需求。換句話說,當他適合應用的需求時,設計者就可以決定用類來表現這種概念(而不是在不同類中的成員函數或全局函數)。這通常由CRC(類,責任,協作)卡片或其他方法來完成。


設計類的一般錯誤


沒有兩種面向對象程序設計語言是相似的。程序設計語言同樣影響設計。就象你在第四章"特殊成員函:默認構造器,拷貝構造器,銷燬器和分配運算符特殊成員函數:默認構造器,拷貝構造器,銷燬器和賦值運算符”學到的,C++在構造器和銷燬器之間有明顯的對稱關係,而其他面向對象程序設計語言就沒有。C++中的對象能在自己“死亡”之後自動的作善後工作。C++也能使你創建的局域對象自動完成自己的數據存儲。在有些語言中,對象只能從堆內存中創建。C++也是少數幾個提供多繼承支持的語言之一。C++是有靜態類型檢測的強類型語言。設計大師們堅持將設計和實現的方法(這裏指具體語言的行爲)分離,同樣的具體語言的特性不會影響總體設計。但是,設計的錯誤當然也不是僅僅來源於其他語言的衝突。


面向對象不是萬能藥。一些一般性的缺陷能產生糟糕的應用程序,它需要經常維護,不能正常的工作,他們僅僅在最後或不能達到產品化。有些設計錯誤時很容易發現的。


巨類


沒有標準的方法來衡量類的大小。但是,許多小的類比那些包含上百個成員函數和數據成員的“巨”類要好。但是巨類也不是沒有用處。類std::string有超過100個成員函數的巨大的接口;顯然,這是規則的例外,一般來說,許多人認爲這是設計方法之間衝突的折中方案。雖然如此,普通程序很少用到所有的成員函數。不止一次我看見程序員用附加的成員函數和數據成員來擴展類
而不是用更加似是而非的面向對象技術,例如子對象。作爲一條規則,比20-30個成員函數更有價值的類是不可靠的。


巨類是不可靠的,有至少三條原因:使用這種類的用戶很少知道他們的確切用法;這種類的實現和接口需要很多修改和debug;他們也不適合重用,因爲巨大的接口和複雜的實現細節僅能適用於很少的方面。某種意義上講,巨類同大函數比較相似——他們是鬆散的並且難以維護。


暴露實現細節


申明所有的數據成員爲public 是一個設計缺陷。雖然如此,許多流行的frameworks廠家採用這種被反對的程序設計風格。使用公用數據類型是誘人的,因爲程序員可以省去寫不需要的accessorsmutators工作(對應的getterssetters)。但是這種方法是不推薦的,因爲它導致維護複雜並犧牲了一部分可靠性。這種類的用戶趨向於依賴類的實現細節;即使他們通常避免這種依賴,他們也可能以爲暴露實現細節意味着沒有假定類會改變。有時沒有其他選擇——類的實現沒有定
義存取類成員的任何方法。改變和擴展這種類成了維護的惡夢。基礎組件,比如Datestring類可能在一個源文件中使用很多次。不難想象,有一打程序員,每一個編寫了一打源文件,而你不得不檢查源文件中所有使用了這些類的行時,你的感受。比如,臭名昭著的2000年問題就是這樣。另一方面。如果數據成員申明爲private,用戶就不能直接存取它了。當類的實現細節改變時,僅僅是accessors和mutators需要改變,而代碼的其他部分保持不變。


暴露實現細節還有一個危險。由於任意的訪問數據成員和輔助函數,用戶能不經意的損害對象的內部數據成員。他們可能釋放內存(本來假定是由銷燬器釋放的),或者他們可能改變文件句柄的值造成可怕的後果,等等。因此,更好的設計選擇總是隱藏對象的實現細
節。


“Resource Acquisition Is Initialization”


許多不同類型的對象有一個相同的特性:獲得使用他們之前必須初始化;初始化之後才能使用,使用之後必須顯式的釋放。比如象FileCommunicationSocketDatabaseCursorDeviceContextOperatingSystem以及其他許多對象在使用之前必須申請、附加、初始化、構造或啓動。當他們的工作完成時,他們必須刷新、分開、關閉、釋放或註銷。一個常見的設計錯誤是顯式的接受用戶初始化和釋放的要求。更好的選擇是將所有的初始化工作放到構造器中,將所有的釋放工作放到銷燬器中。這種技術叫做resource acquisition is initializationThe C++ Programming Language第三版P365)。優點是簡化 了使用協議。用戶創建了對象之後就可以開始使用它,不用爲對象是否可用或有沒有其他的初始化工作必須做而擔心。此外,因爲銷燬器釋放了所有資源,用戶也可以不用管這些事情了。請注意,這種技術通常需要適當地異常處理代碼來處理構造對象的過程中可能拋出的異常。


類和對象


不像其他面向對象的程序設計語言,C++對於類和對象有明顯得區別:類是用戶定義的類型;對象是用戶定義對象的實例。有許多特性用於操作類的狀態,不是單個對象的。這些特性在下面章節討論。


靜態數據成員


靜態數據成員被類的所有實例共享。因爲這個原因,它有時被叫做類變量。靜態數據成員在同步對象時十分有用。例如,文件鎖可以用靜態數據成員來實現。試圖訪問這個文件的對象首先檢查文件是否被其他用戶處理。如果文件可用,對象將標誌改爲開,這樣用戶就可以安全的處理文件。其他用戶在標誌恢復成原樣之前不能訪問這個文件。當處理文件的對象完成了,它關閉標誌,其他對象就可以訪問文件了。



class fileProc
{
private:
FILE *p;
static bool Locked;
public:
//...
bool isLocked () const;
//...
};
bool fileProc::Locked;

靜態成員函數


類的靜態成員函數只能訪問類的靜態數據成員。不同於普通成員函數,靜態成員函數在沒有類的實例時也可以調用。例如



class stat
{
private:
int num;
public:
stat(int n = 0) {num=n;}
static void print() {cout <<"static member function" <<endl;
};
int main()
{
stat::print(); //不需要累得實例
stat s(1);
s.print();//靜態成員函數也能通過對象來調用
return 0;
}

在下列情況中使用靜態成員函數:




  • 當對象的其他數據成員也是靜態時




  • 當函數不依賴於其他對象成員時(print()就一例)


  • 用作全局函數的包裝時


成員指針不能引用靜態成員函數


將靜態成員函數的指針賦值給成員指針是非法的。但是,你可以將靜態成員函數的指針當作普通函數指針來處理。例如



class A
{
public:
static void f();
};
int main()
{
void (*p) () = &A::f; //OK,普通函數指針
}

因爲靜態成員函數本質上就是普通函數,它不接受隱含的this參數。


定義類的常量


當你在類中需要常量整數時,最簡單的方法就是使用const static整數成員;不像其他靜態數據成員,這種成員能在類的體中初始化(參見第二章“標準簡報:ANSI/ISO C++的最新附加部分”)。例如



class vector
{
private:
int v_size;
const static int MAX 1024; //MAX被所有vector對象共享
char *p;
public:
vector() {p = new char[MAX]; }
vector( int size)
{
if (size <= MAX)
p = new char[size] ;
else
p = new char[MAX];
}
};

設計類層次


在確定了應用程序需要的一套可能的類之後,重要的就是確定類之間的相互作用和關係:繼承、包含、還是從屬?類層次的設計不同於設計正確的類型,它需要另外的考慮,在下面的章節討論。


私有數據成員優於保護成員


類的數據成員經常是類實現的一部分。類的內部實現改變時,數據成員可能被替代;因此需要向其他類隱藏他們。如果派生類需要訪問數據成員,他們需要通過接口而不是直接訪問基類的數據成員。因此當基類改變時,派生類就不需要改變。


這是一個例子:



class Date
{
private:
int d,m,y //數據如何表示是實現細節
public:
int Day() const {return d; }
};
class DateTime : public Date
{
private:
int hthiss;
int minutes;
int seconds;
public:
//...其他成員函數
};

現在假設類Date一般用於現實設備,所以它必須提供將dmy轉換成可顯示字符串得方法。爲了提高性能,做了設計修改:代替三個整數,用一個string來表示數據。如果類DateTime依賴於
Date的內部實現細節,它將不得不也改變。但是因爲它僅通過接口訪問Date的數據成員,所需要的僅僅是小小的修改成員函數Date::Day()。請注意訪問數據的成員函數一般申明爲內聯,這樣可以避免額外的運行期開銷。


申明虛的基類的銷燬器


基類需要申明其銷燬器爲virtual。 這樣做,你可以確保總是調用正確的銷燬器,即使在下面的情況:



class Base
{
private:
char *p;
public:
Base() { p = new char [200]; }
~ Base () {delete [] p; } //不是虛銷燬器,問題將出現
};
class Derived : public Base
{
private:
char *q;
public:
Derived() { q = new char[300]; }
~Derived() { delete [] q; }
//...
};
void destroy (Base & b)
{
delete &b;
}
int main()
{
Base *pb = new Derived(); //分配200 + 300字節
//...使用pb
destroy (*pb); //糟糕!只有Base的銷燬器被調用了
//如果Base的銷燬器是虛函數,就會調用正確的銷燬器
return 0;
}

虛成員函數


虛函數使子類能擴展或覆蓋基類的行爲。決定類中那些成員是可以被派生類覆蓋並不是微不足道的。覆蓋虛函數的類只是忠實的秉承了被覆蓋函數的原型——而不是實現。一個普遍地錯誤是將所有的成員函數申明爲virtual“以防萬一”。在這方面,C++對提供純接口的抽象類和與之相反的提供實現也提供接口的基類有明顯得區別。


在派生類中擴展虛函數


你想要派生類擴展而不是完全覆蓋基類定義的虛函數時,有很多方法。你可以簡單的以下面的方法實現:



class shape
{
//...
public:
virtual void draw();
virtual void resize(int x, int y) { clearscr(); /*...*/ }};
class rectangle: public shape
{
//...
public:
virtual void resize (int x, int y)
{
shape::resize(x, y); //顯式的調用基類的虛函數
//增加功能
int size = x*y;
//...
}
};

派生類的替代版本必須以完整的名字調用基類的背代替的虛函數。


改變虛函數的訪問限制


基類定義virtual成員函數的訪問限制能被派生類改變。例如



class Base
{
public:
virtual void Say() { cout<<"Base";}
};
class Derived : public Base
{
private: //訪問限制改變了;合法但不是一個好主意
void Say() {cout <<"Derived";} //覆蓋Base::Say()
};

儘管這是合法的,當使用指針或引用時可能會產生意想不到的結果;任何Base的公共派生類都可以賦值給Base的指針和引用:



Derived d;
Base *p = &d;
p->Say(); //OK,調用Derived::Say()

因爲虛成員函數的實際綁定推遲到了運行期,編譯器不能檢測到調用了非公有函數;編譯器假設p是指向Base類型的指針,而在BaseSay()是公有函數。作爲一條規則,不要在派生類中改變虛成員函數的訪問限制。


虛成員函數不能是私有的



就像你在前面看到的,在派生類中擴展虛函數的習慣方法是先調用改函數的基類版本;在用附加功能擴展它。虛函數申明爲private時這種方法就不能使用。


抽象類和接口


抽象類是至少包含一個純虛成員函數的類。純虛成員函數就是沒有實現的一個佔位符,需要派生類來實現。不能創建抽象類的實例,因爲有意將他它當作派生具體類的設計骨架,而不是當作獨立的對象。看下面的例子:



class File //抽象類;作爲接口
{
public:
int virtual open() = 0; //純虛
int virtual close() = 0; //純虛
};
class diskFile: public File
{
private:
string filename;
//...
public:
int open() {/*...*/}
int close () {/*...*/}
};

使用派生代替類型域


假如你要實現一個國際化的幫助類,這個類要接受當前字處理軟件支持的每一種自然語言作爲參數。天真的一種實現方案是靠類型域來制定當前使用的語言種類(例如,在顯示菜單中區分語言)。



class Fonts {/*...*/};
class Internationalization
{
private:
Lang lg; //type field
FontResthisce fonts
public:
enum Lang {English, Hebrew, Danish}
Internationalization(Lang lang) : lg(lang) {};
Loadfonts(Lang lang);
};

Internationalization的每一個改變將影響它的所有用戶,即使用戶假定不被影響。當增加對新語言的支持已支持語言的用戶不得不重新編譯(或下載,這更糟)類的新版本。而且,隨着時間的推移以及要支持更多的語言,類變得越來越大而且更不容易維護,也有產生更多bug的趨勢。更好的實現方法是用派生代替類型域。例如



class Internationalization //基類
{
private:
FontResthisce fonts
public:
Internationalization ();
virtual int Loadfonts();
virtual void SetDirectionality();
};
class English : public Internationalization
{
public:
English();
Loadfonts() { fonts = TimesNewRoman; }
SetDirectionality(){}//不作任何事;默認的:左邊到右邊
};
class Hebrew : public Internationalization
{
public:
Hebrew();
Loadfonts() { fonts = David; }
SetDirectionality() { directionality = right_to_left;}
};

派生簡化了類結構而且可以將修改限制在於特定語言關聯的類中而不影響其他。


穿過類的邊界重載成員函數


類是一個命名空間。重載成員函數的範圍限制在類中,但不包括派生類。有時需要在派生類中重載相同的函數就像在類中一樣。
但是,在派生類中使用同樣的名字只是隱藏了它,而不是重載。考慮現面的代碼:



class B
{
public:
void func();
};
class D : public B
{
public:
void func(int n); //隱藏了B::f,而不是重載
};
D d;
d.func();//編譯器錯誤。B::f在d中不可見;
d.func(1); //OK, D::func接受類型爲int的參數

爲了重載——而不是隱藏——基類的成員函數,基類的函數名必須通過using declaration顯式的加入派生類的命名空間中。例如



class D : public B
{
using B::func; //將基類成員的名字加入到D範圍中
public:
void func(int n); //現在D有兩個func()的重載版本
};
D d;
d.func ( ); // OK
d.func ( 10 ); // OK

繼承還是包容


當設計類層次時,你必須面對繼承或is-a,和包容或has-a的關係。選擇並不是總那麼明顯。假設你設計類Radio,而且你已經在一些庫裏實現一下幾個類:DialElectricAppliance。顯然Radio派生於ElectricAppliance。但是,Radio也派生於Dial並不是那麼顯然。在這種情況下,檢查兩者之間是不是總是1:1關係。是不是所有的radios(無線電通信裝置)有且僅有一個dial(錶盤)?答案是否。一個radio可以沒有一個dial——比如,以固定頻率接受/發送的器件。此外,radios也可能有多個——FM和AM dials。因此,你的Radio類需要設計成有多個Dial而不是從Dial派生。注意到RadioElectricAppliance的關係是1:1,所以確定將
Radio設計成從ElectricAppliance派生。


Holds-a關係


所有權定義了構造和銷燬對象的責任。只有當對象既有構造也有銷燬資源責任時,對象纔是資源的擁有者。出於這種考慮,包含其他對象的對象也是所包含對象的所有者,因爲對象的構造器要爲調用了內部對象地構造器負責。同樣的。對象的銷燬器也要爲調用了內部對象的銷燬器負責。這是衆所周知的has-a關係。相似的關係是holds-a。由於所有權holds-a比has-a更突出。
一個類間接的包含——就是通過指針或引用——其他地獨立構造和銷燬的對象,這就叫擁有(hold)對象。這是一個例子:



class Phone {/*...*/};
class Dialer {/*...*/};
class Modem
{
private:
Phone* pline;
Dialer& dialer;
public:
Modem (Phone *pp, Dialer& d) : pline(pp), dialer {}
//Phone和Dialer對象
//獨立於Modem構造和銷燬
};
void f()
{
Phone phone;
Dialer dialer;
Modem modem(&phone, dialer);
//...使用modem
}

Modem使用PhoneDialer。但是,Modem沒有構造和銷燬他們的責任。



空類


不包含數據成員和成員函數的類是空類。例如



class PlaceHolder {};

空類可以作爲將要定義類的佔位符。假象類和接口類作爲其他類得基類,避免了等待一個完整的實現,可以在過渡期使用。另外,空類可用於迫使類的繼承層次成爲一顆嚴格的樹。(這是頂層設計)最後,空類可以用作區別重載函數的不同版本的無用參數。事實上標準運算符new的一個版本(參見第十一章”內存管理“)使用了這種技術:



#include <new>
using namespace std;
int main()
{
try
{
int *p = new int[100]; //拋出異常的new
}
catch(bad_alloc & new_failure) {/*..*/}
int *p = new (nothrow) int [100]; //不拋出異常的版本
if (p)
{/*..*/}
return 0;
}

參數nothrownothrow_t類型,這本生就是一個空類。


將structs作爲一個公用類的簡化版本使用


傳統的,structs作爲數據的集合。但是,在C++中struct可以有構造器、銷燬器和成員函數——完全是一個類。structs與類的基本區別是默認訪問限制不同:默認得,類的成員和其派生類都是私有的,然而結構都是公有的。因此,結構有時可以用作成員都是public的類的簡化。抽象類是所有成員都是公有的地一個很好的例子。



#include <cstdio>
using namespace std;
struct File //接口類。所有的成員隱含申明爲公有
{
virtual int Read() = 0;
File(FILE *);
virtual ~File() = 0;
};
class TextFile: File //隱含公有繼承;File是一個結構
{
private:
string path;
public:
int Flush();
int Read();
};
class UnicodeFile : TextFile //隱含私有繼承
{
public:
wchar_t convert(char c);
};

友元


通過申明外部類和函數爲友元,類可以允許他們訪問類的成員。友元有對允許者成員的完全訪問權限,包括私有何保護成員。有時批評友元暴露了實現細節。但是,這與申明成員爲公有有明顯區別,因爲友元使類可以顯式的申明那些客戶可以訪問的成員;與之對比,公有申明使得任何客戶都能訪問成員。這裏是一個例子:



bool operator ==( const Date & d1, const Date&amp;amp;amp; d2);
{
return (d1.day == d2.day) &&
(d1.month == d2.month) &&
(d1.year == d2.year);
}
class Date
{
private:
int day, month, year;
public:
friend bool operator ==( const Date & d1, const Date&amp;amp;amp; d2);
};

記住友元是不會繼承的,所以從Date繼承的任何成員函數對於運算符==都是不可見的。


非公有繼承


當派生類是非公有繼承時,派生對象和非公有基類之間的is-a關係就不存在了。例如:



class Mem_Manager {/*..*/};
class List: private Mem_Manager {/*..*/};
void OS_Register( Mem_Manager& mm);
int main()
{
List li;
OS_Register( li ); //編譯期錯;將
//List & 轉換成 Mem_Manager&的轉化運算符是不可見
return 0;
}

List有一個私有基類Mem_Manager,它爲必要的內存申請負責。但是,List 不是Mem_Manager。因此,私有繼承用於阻止上面這種濫用。私有繼承與包容相似。事實上,將Mem_Manager作爲List的一個成員可以取得相同的效果。Protected繼承用於同樣的目的。


通用根類


在許多frameworks和軟件工程裏,所有的類都必須是一個通用根類的派生類,這個根類一般叫Object。這種設計方針在其他OO語言裏十分流行,比如Smalltalk和Java中所有的類隱含的派生自類Object。但是在C++中模仿這種做法帶來了許多安全隱患和潛在的bug。它在沒有任何公共點的類之間人爲創造了血緣關係。Bjarne Stroustrup有句名言:“現在一個微笑、我的CD-ROM唱機、一張《唐璜》的唱片、一行文本、我的醫療記錄和一個實時時鐘之間有什麼共同點?當他們共享的唯一屬性是他們都是程序設計中的人造物(他們都是對象)時,將他們全部放到一個繼承層次中沒有什麼確切含義,而且可能帶來混亂。”(The C++ Programming Language第三版,732頁)。


如果你想追求泛型,就是說,如果你需要適應任何數據類型的算法/容器/函數,模板是你更好的選擇。此外,根類的設計方針也迫使你完全剋制住使用多繼承,因爲從有相同基類的不同多個類派生的類就要面對可怕的菱形繼承問題:它將基類嵌入了多次。最後,通用根類經常作爲實現異常處理和RTTI的一種手段,現在這兩者都是C++的完整的一部分了。


提前申明


考慮下面的類引用其他類的一般情況:



//file: bank.h
class Report
{
public:
void Output(const Account& account); //編譯期錯誤;
// Account還沒有申明
};
class Account
{
public:
void Show() {Report::Output(*this);}
};

試圖編譯這個頭文件將產生編譯期錯誤,因爲當Report編譯時編譯器不認爲標識符Account是一個類。即使你將類Account的申明放到Report的前面。你將遇到同樣的問題:Report引用了Account。爲了達到目的,需要提前申明。提前申明指示編譯器在掃描完整個文件之後再報告錯誤。例如



//file: bank.h
class Acount; //提前申明
class Report
{
public:
void Output(const Account& account); //正確
};
class Account
{
private:
Report rep;
public:
void Show() {Report::Output(*this);}
};

源文件開始處的提前申明使類Report能引用類Account儘管沒有見到它的定義。 注意,只有引用和指針能使用提前申明類。


局域類


類也可以在函數或塊中申明。此時,類在其他地方不可見,而且它的實例只能在它申明的範圍之內創建。當你需要隱藏不想讓其他地方使用和訪問的對象時,這一特性是十分有用的。例如



void f(const char *text)
{
class Display //局域helper類;只在f()中可見
{
const char *ps;
public:
Display(const char *t) : ps(t) {}
~Display() { cout<<ps; }
};
Display ucd(text); //類型爲Display的局域類
}

局域類沒有linkage。


多繼承


多繼承在1989年引入C++。毫不誇張的說它是加入C++的最有爭議的特性。多繼承的反對者認爲它給語言增加了不必要的複雜性,每一個使用了多繼承的設計模式都能用單繼承模式,而且多繼承使得編譯器更復雜。這三點中只有一點是正確的。多繼承是可選的。設計者認爲可以不用多繼承時,他就可以不用。將增加的複雜性歸結爲使用多繼承同樣是站不住腳的,因爲對於語言的其他特性也有同樣的批評,模板、運算符重載、異常處理等等。


多繼承使設計者能創建更貼近現實世界的對象。傳真modem卡本質上講是一個modem和傳真的結合物。同樣的 ,一個從faxmodem公有派生的fax_modem類比單一繼承模式能更好的表示傳真/modem的概念。例如,在Jave中實現Observer模式基本是不可能的,因爲Jave缺乏多繼承(Java vs. C++——A Critical Comparison," C++ Report, 一月 1997)。Observer不是唯一依賴多繼承的模式——AdapterBridge都是。


使用多繼承聯合各種特性


使用多繼承派生類可以聯合許多基類的功能。使用單一繼承達到同樣的效果十分困難。例如



class Persistent //抽象基類
{
//所有支持持久化的對象
public:
virtual void WriteObject(void *pobj, size_t sz) = 0;
virtual void* ReadObject(Archive & ar) = 0;
};
class Date {/*...*/};
class PersistentDate: public Date, public Persistent
{ /*..*/} //能被存儲和重建

虛繼承


多繼承可能導致著名的菱形繼承問題(dreadful diamond of derivation),下面演示了這種情況:



class ElectricAppliance
{
private:
int voltage,
int Hertz ;
public:
//...構造器和其他方法
int getVoltage () const { return voltage; }
int getHertz() const {return Hertz; }
};
class Radio : public ElectricAppliance {/*...*/};
class Tape : public ElectricAppliance {/*...*/};
class RadioTape: public Radio, public Tape { /*...*/};
int main()
{
RadioTape rt;
//下面的語句將是編譯期錯誤——不明確的調用。
//rt中有getVoltage()的兩個拷貝:一個來自Radio
//另一個來自Tape。而且返回那一個福特值呢?
int voltage = rt.getVoltage();
return 0;
}

問題是很顯然的:rt同時從兩個基類派生,而兩個基類都有ElecctricAppliance方法和數據的一份拷貝。結果,rtElectricAppliance的兩份拷貝。這就是菱形繼承。然而放棄多繼承使得設計變複雜。不需要公有基類方法和數據的重複拷貝時,使用虛繼承:



class Radio : virtual public ElectricAppliance {/*...*/};
class Tape : virtual public ElectricAppliance {/*...*/};
class RadioTape: public Radio, public Tape
{/*...*/};

現在類RadioTape只包含ElectricAppliance的一份拷貝,這份拷貝RadioTape共享;於是模糊不存在了也不需要放棄多繼承。



int main()
{
RadioTape rt;
int voltage = rt.getVoltage(); //OK
return 0;
}

繼承了多次之後,C++如何保證虛成員只有一份實例存在?這依賴於具體編譯器的實現。但是,現在的實現都是使用附加層間接訪問虛基類,一般是指針。



//注意:這是iostream類的簡化描述
class ostream: virtual public ios { /*..*/ }
class istream: virtual public ios { /*..*/ }
class iostream : public istream, public ostream { /*..*/ }

換句話說,iostream繼承層次中的每個對象都有一個指向共享的ios子對象的指針。附加的間接層會有很小的性能開銷。也就是說本地的虛子對象在編譯期是不知道的;因此在這種情況下可能需要RTTI來訪問對象(在第七章“運行期類型識別”討論)。


當使用多繼承時,多繼承對象的內存佈局是依賴編譯器的。編譯器可以重新排列繼承來的子對象來提高內存的使用效率。另外,虛基類也可能移到了不同的內存地址。因此使用多繼承時,不要做關於對象內存佈局的任何假設。


非虛多繼承


虛繼承用於避免在多繼承的對象中有一個基類的的多個拷貝,就像你看到的。但是在有些情況下,派生類需要基類的多個拷貝。在這種情況下,有意的避免虛繼承。例如,假設你有一個scrollbar(滾動條)類,這個類需要其他兩個類的基類:



class Scrollbar
{
private:
int x;
int y;
public:
void Scroll(units n);
//...
};
class HorizontalScrollbar : public Scrollbar {/*..*/};
class VerticalScrollbar : public Scrollbar {/*..*/};

現在假設一個窗口既有水平滾動條也有垂直滾動條。它可以用下面的方法實現:



class MultiScrollWindow: public VerticalScrollbar,
public HorizontalScrollbar {/*..*/};
MultiScrollWindow msw;
msw.HorizontalScrollbar::Scroll(5); // scroll left
msw.VerticalScrollbar::Scroll(12); //...and up

用戶即可以將窗口上下滾動也可以左右滾動。爲了這個目的,窗口對象必須有兩個不同的Scrollbar子對象。因此,有意避免多繼承。


爲成員函數選擇不同名字


多繼承中當兩個以上類作爲基類時,必須爲每個成員函數選擇不同的名字,不然就會出現歧義。考慮下面的具體例子:



class AudioStreamer //實時音頻播放類
{
public:
void Play();
void Stop();
};
class VideoStreamer //實時視頻播放類
{
public:
void Play();
void Stop();
};
class AudioVisual: public AudioStreamer, public VideoStreamer {/*...*/};
AudioVisual player;
player.play(); //錯誤:AudioStreamer::play()還是VideoStreamer::play()?

一個消除歧義的方法是用函數的全名調用:



Player.AudioStreamer::play(); //正確但是冗長

更好的解決方案是在基類中爲函數使用不同的名字:



class AudioStreamer
{
public:
void au_Play(); };
class VideoStreamer
{
public:
void vd_Play();
};
Player.au_play(); //OK

總結


今天C++在各種各樣的領域中使用嵌入式系統、數據庫引擎、Web引擎、金融系統、人工智能等等。這種多功能性歸結於C++程序設計風格的靈活性、對C的兼容性以及一個重要的事實——C++是現有的最有效率的面向對象程序設計語言。


作爲一種過程化語言,C++提供比C更嚴格的類型檢查。它也提供更好的內存管理、內聯函數、默認參數和引用變量,這些使C++可以作爲“更好的C”。


通過將數據和操作數據的一套函數封裝到一個單獨的實體中,基於對象的程序設計克服了過程化程序設計的許多明顯得缺點。設計中將實現細節和接口分離將修改限制在很小的範圍內,於是成生了功能更強更容易擴展的軟件。但是,它不支持類的繼承。


面向對象的程序設計依賴於封裝、信息隱藏、多態、繼承和動態綁定。這些特性使你能設計和實現類層次。面向對象程序設計與基於對象程序設計相比,面向對象有更快的開發速度、更容易的維護和更強的可擴展性。


C++支持面向對象程序設計的高級特性,如多繼承、靜態和動態多態和類和對象之間的明顯區別。面向對象設計的首要任務就是確定類和類的相互關係:繼承、包容還是所有。構造器和銷燬器的對稱性是一些有用設計習慣的基礎,比如“initialization is acquisition”和智能指針。


C++支持的另外一種程序設計範例——泛型程序設計與面向對象的程序設計沒有直接關係。事實上,過程化的程序設計語言也能很好的實現泛型程序設計。雖然如此,面向對象程序設計和泛型程序設計的結合使得C++是一種強大的語言,第十章將讓你充分體會到這一點。

發佈了23 篇原創文章 · 獲贊 4 · 訪問量 28萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章