导读
學會一個程式語言,是一回事兒;學會如何以此語言設計並實作出有效的程式,
又是一回事兒。C++ 尤其如此,因為它很不尋常地涵蓋了罕見的威力和豐富的表
現力,不但建立在一個全功能的傳統語言(C)之上,更提供極為廣泛的物件導向
(object-oriented)性質,以及對templates 和exceptions(異常狀態)的支援。
假以適當運用,C++ 是個可以讓你感受愉悅的夥伴。各種不同的設計方式,包括
物件導向型式和傳統型式,都可以直接在這個語言中表現並有效地實作出來。你
可以定義新的資料型別,它們和語言內建的型別表面上無分軒輊,實質上則更具
彈性。明智地選用一些謹慎設計的classes — 自動完成記憶體管理、別名(aliasing)
處理、初始化動作與清理動作、型別轉換、以及軟體開發的其他難題與禍根— 可
以使程式設計更容易,更直觀,更有效,更少錯誤。是的,要寫出有效的C++ 程
式並不會太困難,如果你知道怎麼做的話。
如果沒有什麼訓練與素養,就冒然使用C++,會導至做出來的碼不易理解、不易
維護、不易擴充、缺乏效率、而且容易出錯。
關鍵在於找出C++ 可能絆倒你的狀況有哪些,然後學習如何避開它們。這正是本
書的目的。我假設你已經認識C++ 並對它有某種程度的使用經驗。我提供一些準
則,讓你更有效地使用這個語言,使你的軟體容易理解、容易維護、容易擴充、
效率高、而且行為如所預期。
我提出的忠告分為兩大類:一般性的設計策略,以及特殊的(比較難得一見的)
語言性質。
設計方面的討論集中在如何對不同的方法(俾得以C++ 達成某個目標)做取捨。
如何在inheritance(繼承)和templates(範本)之間做選擇?在templates 和generic
pointers(泛型指標)之間?在public inheritance(公開繼承)和private inheritance
(私有繼承)之間?在private inheritance 和layering(分層技術)之間?在function
overloading(函式多載化)和parameter defaulting(參數預設值)之間?在virtual
function(虛擬函式)和nonvirtual functions(非虛擬函式)之間?在pass-by-value
(傳值)和pass-by-reference(傳址)之間?一開始就做出正確的決定是很重要的,
因為不正確的選擇或許不會一下子就浮現影響,但是在開發過程的後期,矯正它
往往很困難、很花時間,很混亂,很令人沮喪,事倍功半,成本很高。
在你確切知道你要做什麼之後,把它做對,恐怕也不是件太容易的事。什麼是
assignment 運算子的適當傳回型別?當operator new 無法找出足夠的記憶體,
它該有怎樣的行為?destructor 何時應該被宣告為virtual?你應該寫一個member
initialization list(成員初值列)嗎?在如斯細節中努力,也頗具有決定性,因為如
果不這樣,常會導至意料之外或神秘難解的程式行為。更糟的是這類脫軌行為可
能不會立即浮現,這些恐怖的碼或能通過品管檢驗,卻仍然藏匿著許多未偵測出
來的臭蟲— 不定時炸彈正等待引爆。
這不是本得一頁頁讀下去才有感覺的書籍。你甚至不需要依序讀它。所有素材被
我分為50 個條款,每一個都相當獨立。不過條款之間會彼此參考,所以閱讀本
書的一種方法是先從感興趣的條款開始,然後遵循其參考指示,進一步讀下去。
所有條款被我分為七大類。如果你對某類主題特別感興趣,例如「記憶體管理」
或「物件導向設計」,可以從相關章節開始,一路讀下去,或是跳躍前進。不過
最後你會發現,本書的所有內容對於高實效的C++ 程式設計而言,都十分基礎而
重要,所以幾乎每個條款最後都會和其他條款互有牽連。
這並不是一本C++ 參考工具書,也不是一本讓你從頭學習C++ 的書。例如,雖
然我熱切告訴你一些有關「撰寫自己的operator new」的注意事項(條款7~10),
但是我假設你可以從其他地方獲知,operator new 必須傳回一個void*,其第
一引數的型別必須是size_t。許多C++ 語言書可以帶給你這樣的資訊。
這本書的目的是要強調那些其他書籍往往淺淺帶過(如果有的話)的C++ 程式設
計概念。其他書籍描述的是C++ 語言的各個成份,本書則告訴你如何將那些成份
組合起來,完成一個有效的程式。其他書籍告訴你如何讓程式順利編譯,本書則
告訴你如何避開編譯器不會告訴你的一些問題。
和大部份語言一樣,C++ 有著豐富的「傳統」,在程式員之間口耳相傳,形成這
個語言的偉大傳承的一部份。我企圖在這本書中以容易閱讀的型式記錄一些長久
累積而來的智慧。
然而在此同時,我必須告訴你,本書僅限於正統的、可移植的C++ 語言。只有明
列於ISO/ANSI 標準(見條款M35)中的性質,才會被本書採用。本書之中,移
植性是個關鍵考量。如果你想要尋找因編譯器而異的特殊技法,本書不適合你。
但是,啊呀,標準規格所描述的C++,與社區軟體商店所賣的編譯器(s) 的表現,
多少有點出入。所以當我指出某個新的語言特性頗有用處時,我也會告訴你如何
在缺乏那些特性的情況下產出有效的軟體。畢竟在確知未來即將如何如何之際,
卻忽略那些美麗遠景而儘做些低下的勞力工作,容我坦言是相當愚蠢的;但是反
過來看,你也不能在最新最偉大的C++ 編譯器(s) 降臨世界之前,空自等待而束
手無策呀。你必須和你手上可用的工具一起打拼,而本書正打算幫助你這麼做。
注意我說編譯器(s) — 複數。不同的編譯器對標準C++ 的滿足程度各不相同,所
以我鼓勵你至少以兩種編譯器(s) 來開發程式。這麼做可以幫助你避免不經意仰賴
某個編譯器專屬的語言延伸性質,或是誤用某個編譯器對標準規格的錯誤闡示。
這也可以幫助你避免使用過度先進的編譯器特殊技術,例如獨家廠商才做得出來
的某種語言新特性。如此特性往往實作不夠精良(臭蟲多,要不就是表現遲緩,
或兩者兼具),而且C++ 社群往往對這些特性缺乏使用經驗,無法給你應用上的
忠告。雷霆萬鈞之勢固然令人興奮,但當你的目標是要產出可靠的碼,恐怕還是
步步為營(並且能夠與人合作)得好。
你在本書中找不到C++ 的必殺秘笈,也看不到通往C++ 完美軟體的唯一真理。
50 個條款中的每一個帶給你的都只是準則,包括如何完成較好的設計,如何避免
常見的問題,如何到達更好的效率,但任何條款都不可能放之四海皆準。軟體的
定義和實作是極為複雜的工作,常會受到硬體、作業系統、以及應用軟體的束縛,
所以我能夠做的最好事情就是提供一些準則,讓你可以依循產生出比較好的程式。
如果任何時候你都奉行每一個條款,應該不太可能掉進最常見的一些C++ 陷阱。
不過準則畢竟只是準則,可能存在例外情況。那正是為什麼每個條款都帶有一堆
解釋的原因。這些解釋是本書最重要的資產。唯有徹底瞭解一個條款背後的基本
原理,你才能合理決定此條款是否適用於手上的專案,或你正艱苦奮鬥的難題上。
本書的最佳用途,就是增進你對C++ 行為的瞭解,知道它為什麼有那樣的表現,
以及如何將其行為轉化為你的利益。盲目運用本書所列的條款並不適當,不過話
說回來,你或許不應該在缺乏好理由的情況任意違反任何一個條款。
這樣性質的書籍中,專用術語的解釋並非重點所在。那樣的工作頂好是留給語言
界的「律師」去做。然而有少量C++ 辭彙是每個人都應該要懂的。以下術語一再
出現,所以有必要確定你我之間對它們有共同的認知。
所謂宣告(declaration),用來將一個object、function、class 或template 的
型別名稱告訴編譯器。宣告式並不帶有細目資訊。下面統統都是宣告:
extern int x; // object declaration
int numDigits(int number); // function declaration
class Clock; // class declaration
template
class SmartPointer; // template declaration
所謂定義(definition),用來將細目資訊提供給編譯器。對object 而言,其定
義式是編譯器為它配置記憶體的地點。對function 或function template 而言,其
定義式提供函式本體(function body)。對class 或class template 而言,其定義
式必須列出該class 或template 的所有members:
int x; // 這是物件的定義式 int numDigits(int number) // 這是函式的定義式 { // 此函式傳回其參數的數位(digits)個數 int digitsSoFar = 1; if (number < 0) { number = -number; ++digitsSoFar; } while (number /= 10) ++digitsSoFar; return digitsSoFar; } class Clock { // 這是class 的定義式 public: Clock(); ~Clock(); int hour() const; int minute() const; int second() const; ... }; template class SmartPointer { // 這是template 的定義式 public: SmartPointer(T *p = 0); ~SmartPointer(); T * operator->() const; T& operator*() const; ... };
上述程式碼把我們帶往所謂的constructors。default constructor 意指可以「不
需任何引數就被喚起」者。這樣的一個constructor 如果不是沒有任何參數,就是
每個參數都有預設值。通常當你需要定義物件陣列時,就會需要一個default
constructor:
class A { public: A(); // default constructor }; A arrayA[10]; // 呼叫constructors 10 次 class B { public: B(int x = 0); // default constructor }; B arrayB[10]; // 呼叫constructors 10 次, // 每次都給引數0。 class C { public: C(int x); // 這不是一個default constructor }; C arrayC[10]; // 錯誤!
或許有時候你會發現,某個class 的default constructor 有預設參數值,你的編譯
器卻拒不接受其物件陣列。例如某些編譯器拒絕接受上述arrayB 的定義,即使
它其實符合C++ 標準。這是存在於C++ 標準規格書和實際編譯器行為之間的一
個矛盾例子。截至目前我所知道的每一個編譯器,都有一些這類不相容缺點。在
編譯器廠商追上C++ 語言標準之前,請保持你的彈性,並安慰自己,也許不久後
的某一天,C++ 編譯器的表現就可以和C++ 標準規格書所描述的一致了。
附帶一提,如果你想要產生一個物件陣列,但該物件型別沒有提供default
constructor,通常的作法是定義一個指標陣列取而代之,然後利用new 一一將每
個指標初始化:
C *ptrArray[10]; // 沒有呼叫任何constructors ptrArray[0] = new C(22); // 配置並建構一個C 物件 ptrArray[1] = new C(4); // 同上 ...
這個作法在任何場合幾乎都夠用了。如果不夠,你或許得使用條款14 所說的更高層次(也因此更不為人知)的"placement new" 方法。
回到術語來。所謂copy constructor 係以某物件做為另一同型物件的初值:
class String { public: String(); // default constructor String(const String& rhs); // copy constructor ... private: char *data; }; String s1; // 呼叫default constructor String s2(s1); // 呼叫copy constructor String s3 = s2; // 呼叫copy constructor
或許copy constructor 最重要的用途就是用來定義何謂「以by value 方式傳遞和
傳回物件」。例如,考慮以下效率不佳的作法,以一個函式串接兩個String 物件:
const String operator+(String s1, String s2) { String temp; delete [] temp.data; temp.data = new char[strlen(s1.data) + strlen(s2.data) + 1]; strcpy(temp.data, s1.data); strcat(temp.data, s2.data); return temp; } String a("Hello"); String b(" world"); String c = a + b; // c = String("Hello world")
其中operator+ 需要兩個String 物件做為參數,並傳回一個String 物件做為運算結果。不論參數或運算結果都是以by value 方式傳遞,所以在operator+進行過程中,會有一個copy constructor 被喚起,用以將a 當做s1 的初值,再有一個copy constructor 被喚起,用以將b 當做s2 的初值,再有一個copyconstructor 被喚起,用以將temp 當做c 的初值。事實上,只要編譯器決定產生中介的暫時性物件,就會需要一些copy constructor 呼叫動作(見條款M19)。重點是:pass-by-value 便是「呼叫copy constructor」的同義詞。
順帶一提,你不能夠真的像上述那樣實作Strings 的operator+。傳回一個const String object 是正確的(見條款21 和23),但是你應該以by reference 方式(見條款22)傳遞那兩個參數。
其實,如果你有外援,並不需要為Strings 撰寫operator+。事實上你的確有
外援,因為C++ 標準程式庫(條款49)就內含有一個string 型別,帶有一個
operator+,做的事情幾乎就是上述operator+ 的行為。本書中我並用String
和string 兩者(注意前者名稱以大寫開頭,後者否),但方式不同。如果我只
是需要一般字串,不在意它是怎麼做出來的,那麼我便使用標準程式庫提供的
string。這也是你應該選擇的行為。然而如果我打算剖析C++ 的行為,並因而
需要某些實作碼來示範或驗證,我便使用非標準的那個String class。身為一個
程式員,只要必須用到字串,就應該儘可能使用標準的string 型別;那種「開
發自己的字串類別,以象徵具備C++ 某種成熟功力」的日子已經過去了(不過你
還是有必要瞭解開發一個像string 那樣的classes 所需知道的課題)。對「示
範或驗證」目的(而且可說只對此種目的)而言,String 很是方便。無論如何,
除非你有很好的理由,否則都不應該再使用舊式的char*-based 字串。具有良好
定義的string 型別如今已能夠在每一方面比char*s 更具優勢,並且更好— 包
括其執行效率(見條款49和條款M29~M30)。
接下來兩個需要掌握的術語是initialization(初始化)和assignment(指派)。
物件的初始化行為發生在它初次獲得一個值的時候。對於「帶有constructors」之
classes 或structs,初始化總是經由喚起某個constructor 達成。這和物件的
assignment 動作不同,後者發生於「已初始化之物件被指派新值」的時候:
string s1; // initialization(初始化) string s2("Hello"); // initialization(初始化) string s3 = s2; // initialization(初始化) s1 = s3; // assignment(指派)
純粹從操作觀點看,initialization 和assignment 之間的差異在於前者由constructor
執行,後者由operator= 執行。換句話說這兩個動作對應不同的函式動作。
C++ 嚴格區分此二者,原因是上述兩個函式所考慮的事情不同。Constructors 通
常必須檢驗其引數的有效性(validity),而大部份assignment 運算子不必如此,
因為其引數必然是合法的(因為已被建構完成)。另一方面,assignment 動作的
標的物並非是尚未建構完成的物件,而是可能已經擁有配置得來的資源。在新資
源可被指派過去之前,舊資源通常必須先行釋放。這裡所謂的資源通常是指記憶
體。在assignment 運算子為一個新值配置記憶體之前,必須先釋放舊值的記憶體。
下面是String 的constructor 和assignment 運算子的可能作法:
// 以下是一個可能的String constructor String::String(const char *value) { { if (value) { // 如果指標value 不是null data = new char[strlen(value) + 1]; strcpy(data,value); } else { // 處理null 指標 //此一「接受一個const char* 引數」的String constructor, //有能力處理傳進來的指標為null的情況。標準的string 可沒如此寬容。 //企圖以一個null 指標產生一個string,其結果未有定義。 //不過以一個空的char*-based 字串(例如"")產生一個string 物件, //倒是安全的。 data = new char[1]; } } // 以下是一個可能的String assignment 運算子 String& String::operator=(const String& rhs) { if (this == &rhs) return *this; // 見條款17 delete [] data; // 刪除(釋放)舊有的記憶體 data = // 配置新的記憶體 new char[strlen(rhs.data) + 1]; strcpy(data, rhs.data); return *this; // 見條款15 }
注意,constructor 必須檢驗其參數的有效性,並確保member data 都被適當地初
始化,例如一個char* 指標必須被適當地加上null 結束字元。亦請注意
assignment 運算子認定其參數是合法的,反倒是它會偵測諸如「自己指派給自己」
這樣的病態情況(見條款17),或是集中心力確保「配置新記憶體之前先釋放舊
有記憶體」。這兩個函式的差異,象徵物件初始化(initialization)和物件指派
(assignment)兩者的差異。順帶一提,如果delete [] 這樣的表示法對你而言
很陌生,條款5 和條款M8 應該能夠消除你的任何相關疑惑。
我要討論的最後一個術語是client(客戶)。Client 代表任何「使用你所寫的碼」
的人。當我在本書提及clients,我指的便是任何觀察你的碼並企圖理解它們的人。
我也是指閱讀你的class 定義並企圖決定是否可以繼承它們的人。我同時也是指
那些審查你的設計並希望洞察其中原理的人。
你或許還不習慣去想到你的clients,但是我會儘量說服你設法讓他們的生活愉快
一些。畢竟,你也是他人所開發的軟體的client,難道你不希望那些人讓你的生活
愉快一些嗎?此外,也許有一天你會發現你必須使用自己所寫的碼(譯註:指那
些classes 或libraries),那時候你的client 就是你自己。
我在本書用了兩個你可能不甚熟悉的C++ 性質,它們都是晚近才加入C++ 標準
之中。第一個是bool 型別,其值若非true 就是false(兩者都是關鍵字)。
語言內建的相對關係運算子(如, ==)的傳回型別都是bool,if, for, while,
do 等述句的條件判斷式的傳回型別也是bool。如果你的編譯器尚未實作出bool
型別,你可以利用typedef 模擬bool,再以兩個const 物件模擬true 和false:
typedef int bool; const bool false = 0; const bool true = 1;
這種手法相容於傳統的C/C++ 語意。使用這種模擬作法的程式,在移植到一個支
援bool 型別的編譯器平台後,行為並不會改變。如果你想知道另一種bool 模
擬法,包括其優缺點討論,請參考More Effective C++ 的導讀部份。
第二個新特性其實有四樣東西,分別是static_cast, const_cast, dynamic_cast,
reinterpret_cast 四個轉型運算子。傳統的C 轉型動作如下:
(type) expression // 將expression 轉為type 型別
新的轉型動作則是這樣:
static_cast(expression) // 將expression 轉為type 型別 const_cast(expression) dynamic_cast(expression) reinterpret_cast(expression)
這些不同的轉型運算子有不同的作用:
const_cast | 用來將物件或指標的常數性(constness)轉型掉,我將在條款21驗證這個主題。 |
dynamic_cast | 用來執行「安全的向下轉型動作(safe downcasting)」,這是條款39 的主題。 |
reinterpret_cast | 的轉型結果取決於編譯器— 例如在函式指標型別之間做轉型動作。你大概不常需要用到reinterpret_cast。本書完全沒有用到它。 |
static_cast | 是個「雜物袋」:沒有其他適當的轉型運算子可用時,就用這個。它最接近傳統的C 轉型動作。 |
傳統的C 轉型動作仍然合法,但是新的轉型運算子比較受歡迎。它們更容易在程
式碼中被識別出來(不論是對人類或是對諸如grep 等工具而言),而且愈是縮小
範圍地指定各種轉型運算子的目標,編譯器愈有可能診斷出錯誤的運用。例如,
只有const_cast 才可以用來將某物的常數性(constness)轉換掉。如果你嘗試
使用其他轉型運算子來轉換物件或指標的常數性,一定會踢到鐵板。
欲知這些新式轉型動作的更多資訊,請看條款M2,或查閱較新的C++ 語言書籍。
M 代表More Effective C++,是我的另一本書。本書最後附有一份該書摘要。
本書的程式範例中,我設法為objects, classes, functions 取一些有意義的名稱。許
多書籍在選用識別名稱時,都喜歡恪守一句箴言:簡短是智慧的靈魂,但是我不,
我喜歡一切都交待得清清楚楚。我努力打破傳統,堅不使用那種隱秘而不易為人
識破天機的名稱。但偶爾我會被誘惑所屈服,使用兩個我最歡迎的參數名稱。其
意義可能並不淺顯易懂,特別是如果你從未在任何編譯器開發團隊待過的話。
這兩個參數名稱是lhs 和rhs,分別意味"left-hand side"(左端)和"right-hand
side"(右端)。我以它們做為二元運算子各函式的參數名稱,尤其是operator==
和算術運算子如operator*。舉個例子,如果a 和b 代表兩個分數(rational
numbers)物件,而如果分數可經由一個non-member function operator* 相乘,
那麼算式:
a * b
等於這款形式的函式呼叫:
operator*(a, b)
我將宣告operator* 如下(一如你在條款23所見):
const Rational operator*(const Rational& lhs,const Rational& rhs);
如你所見,左運算元a 成為函式中的lhs,右運算元b 成為函式中的rhs。
我也利用縮寫字來為指標命名,規則如下:「指向型別T 之物件」的指標,我稱
為pt,意思是"pointer to T"。下面是幾則例子:
string *ps; // ps = ptr to string class Airplane; Airplane *pa; // pa = ptr to Airplane class BankAccount; BankAccount *pba; // pba = ptr to BankAccount
對於references,我亦採用類似習慣。也就是說,rs 大約就是一個reference-tostring,
ra 則可能是一個reference-to-Airplane。
當我談到member functions,偶而我會使用mf 這個名稱。
為避免任何混淆,任何時候我在書中提到「C 程式設計」時,我說的是ISO/ANSI
版的C 語言,而不是舊式的、沒那麼strongly-typed(強型式)的古典C 語言。