總有些東西是基礎中的基礎,本章就是最基本的東西。
01、View C++ as a federation of language
C++ 是一個多重範型編程語言,同時支援程序形式(procedure)、物件導向形式(object-oriented)、函式形式(functional)、泛型形式(generic)、超編程形式(metaprogramming)的語言。
最簡單的理解方法是將 C++ 視為一個由相關語言組成的聯邦語言,其主要的次語言有四種:
- C,說到底 C++ 仍然以 C 為基礎,包括 區塊(blocks), 述句(statements), 前處理器(preprocessor), 內建資料型別(built-in data bytes), 陣列(arrays), 指標(pointers) 等,統統來自 C。
- Object-Oriented C++,這部分是 C with Classes 所訴求的,包含: classes(包含建構式與解構式), 封裝(encapsulation), 繼承(inheritance), 多型(polymorphism), virtual function(動態綁定) ...等等。
- Template C++,這是 C++ 的 generic programming 部分,事實上由於 templates 威力強大,它們帶來新的編程範型(programming paradigm),也就是所謂的 template metaprogramming。
- STL,是個 template 程式庫,它對 containers, iterators, algorithms 及 function objects 的規約有極佳的緊密配合與協調,STL 有自己特殊的辦事方式,當與 STL 一起工作時,必須要遵守它的規約。
記住這四個次語言,當從某個次語言切換到另一個,導致高效編程守則要求改變策略時,這是正常的現象。
例如:對內建型別而言 pass-by-value 通常比 pass-by-reference 高效,但當從 C part of C++ 移往 Object-Oriented C++ ,由於用戶自定建構式與解構式的存在,此時 pass-by-reference-to-const 往往更好,運用 Template C++ 時更是如此,因為此時甚至不知道所處理物件的型別,然而一旦跨入 STL ,iterator 及 function objects 都是在 C 指標上塑造出來的,所以對 STL 的 iterator 及 function objects ,舊式的 C pass-by-value 手則再次適用。
有關參數傳遞方式的選擇細節,可以參考條款20 。
C++ 高效編程守則視狀況而變化,取決於使用 C++ 的哪一部分
02、Prefer const, enum and inline to #define
這個條款,或許改為「寧可以編譯器取代前處理器」比較好,因為或許 #define 不被視為語言的一部分。
#define ASPECT_RATIO 1.653
在此段程式中,符號名號 ASPECT_RATIO 也許從為被編譯器看見,也許在編譯器開始處理源碼之前,它就被前處理器移走了,於是符號名稱 ASPECT_RATIO 有可能沒有進到符號表(symbol table)內。
於是在運用此常數但獲得一個編譯錯誤訊息時,可以會帶來困惑,因為此誤誤訊息也許會提到 1.653 而不是 ASPECT_RATIO,這個問題也可能出現在符號式除錯器(symbolic debugger)中,原因相同:所使用的名稱可能並未進入符號表。
解決的方式,是以一個常數取式上述的巨集(#define):
const double AspectRatio = 1.653
做為一個語言常數,AspectRatio 肯定會被編話器看到,當然也就會進入符號表。
在以常數取代 #define,有兩種特殊情況值得說說:
第一是定義常數指標(constant pointers),由於常數定義式通常被放在標頭檔,以便被不同的源碼含入,因此有必要將指檔宣告為 const。
第二是有關於 class 專屬常數,為了將常數的 scope 限制在 class 內,必須讓這個常數成為此 class 的 member,而為了確保此常數至多只有一份實體,則必須讓此常數成為一個 static member。
順帶一提,因為 #define 並不重視 scope,所以無法利用 #define 產生一個 class 的專屬常數,一旦巨集被定義,它就在其後的編譯過程中有效,除非在某處被 #undef。
所謂的 "the enum hack",其理論基礎是:「一個屬於 enumerated type 的數值,可以權充 ints 被使用」:
class GamePlayer {
private:
enum {NumTurns = 5};
int scores[NumTurns];
...
};
enum hack 的行為,就某方面來說,比較像 #define 而不像 const,例如取一個 const 的位址是合法的,但取一個 enum 的位址就不合法,而取一個 #define 的位址通常也不合法。
許多程式碼也都用了 enum hack,所以必須有所認識,而事實上, "enum hack" 是 template metaprogramming 的基礎技術,詳見條款 48。
另一個常見的 #define 誤用的情況是以它來實作巨集,巨集看起來像函式,但不會招致 function call 帶來的額外開銷:
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))
縱使已經為所有引數加上小括號,但:
int a = 5, b = 0;
CALL_WITH_MAX(++a, b); // a被累加一次
CALL_WITH_MAX(++a, b+10); // a被累加兩次
在這裡,a被累加的次數,竟取決於和誰作比較!
究竟要如何同時獲得巨集帶來的效率以及一般函式的所有可預期行為及 type safety?
只要寫出 template inline funcion 即可,詳見條款30:
template<typename T>
inline void callWithMax(const T&a, const T&b) {
f(a > b ? a : b);
}
這個 template 產生一整群函式,每個函式都接受兩個同型物件,並以最大者呼叫 f。
此外由於 callWithMax() 是個真正的函式,遵守 scope 和存取規則,所以絕對可以寫出一個「calss 內的 private inline function」。
有了 const, enum 和 inline ,對前處理器的需求降低了,但 #include 仍然是必需品,而 #ifdef / #ifndef 也繼續扮演控制編譯的重要角色。
對於單純常數,最好以 const 物件或 enum 取代 #define。
對於類似函式的巨集,最好改用 inline function 取代 #define。
03、Use const whenever possible
const 允許指定一個語意約束,也就是指定一個不該被改動的物件,而 compiler 會強制實施這項約束。
const 可以用在 classes 外部修飾 global 或 namespace 作用域的常數,也可以修飾檔案、函式、區塊作用域中被宣告為 static 的物件、或 classes 內部的 static 和 non-static 成員變數。
對於指標,也可以指出指標本身或指標所指物是或不是 const:
char greeting[] = "Hello";
char* p = greeting; // non-const pointer, non-const data
const char* p = greeting; // non-const pointer, const data
char const * p = greeting; // non-const pointer, const data
char* const p = greeting; // const pointer, non-const data
const char* const p = greeting; // const pointer, const data
char const * const p = greeting; // const pointer, const data
STL iterator 是以指標為根據模塑出來,所以 iterator 的作用就像個 T* 指標,宣告 iterator 為 const ,就像宣告指標為 const 一樣,表示這個 iterator 不能指向不同的東西,但它所指的東西是可以改動的,如果要希望這個 iterator 所指的東西不能被改動,就需要 const_iterator:
std::vector vec;
...
const std::vector::iterator iter = vec.begin();
// iter 的作用像個 T* const
*iter = 10; // 沒問題,改變 iter 所指物
++iter; // 錯誤!iter 是 const
std::vector::const_iterator cIter = vec.begin();
// cIter 的作用像個 const T*
*cIter = 10; // 錯誤!*cIter 是 const
++cIter; // 沒問題,改變 cIter
在一個宣告式內, const 可以和函式回傳值、各參數、成員函式本身產生關連。
令函式回傳一個常數值,往往可以降低意外造成的錯誤,又不至於放棄安全性和高效性:
class Rational { ... };
const Rational operator* (const Rational& lhs,
const Rational& rhs);
為什麼要回傳一個 const 物件的原因,是為了避免下列情況:
Rational a, b, c;
...
(a * b) = c;
這種情況有可能是邏輯錯誤,或是打字錯誤(以及一個可被隱式轉換為 bool 的型別):
if (a * b = c) ...
// 其實是想作 a*b 跟 c 的比較
而將 operator* 的回傳值宣告為 const ,就可以預防「沒意思的賦值動作」,這就是原因。
對於 const 參數,就跟 local const 物件一樣,除非有需要改動參數或 local 物件,否則宣告他們為 const ,則可以省下惱人的錯誤,像是「想要鍵入 '==' 卻意外鍵成 '='」。
將 const 實施在成員函式的目的,是為了確認該成員函式可作用於 const 物件身上,而且具有兩個好處:
1,使得 class 介面比較容易被理解,因為可以得知哪個函式可以改變物件內容,而哪個函式不行。
2,使得「操作 const 物件」成為可能。
在 C++ 中有一個重要的特性:
兩個成員函式中,就算只有常數性(constness) 的不同,還是可以被重載的:
class TextBlock {
public:
...
const char& operator[] (std:size_t position) const {
return text[position];
}
char& operator[] (std:size_t position) {
return text[position];
}
private:
std:string text;
};
TextBlock 的 operator[]s 可以被這個使用:
TextBlock tb("Hello");
std::cout // 呼叫 non-const TextBlock::operator[]
const TextBlock ctb("World");
std::cout // 呼叫 const TextBlock::operator[]
tb[0] = 'x'; // 沒問題- 寫一個 non-const TextBlock
ctb[0] = 'x'; // 錯誤!- 寫一個 const TextBlock
值得注意的是上述錯誤是因為 operator[] 的返回型別所致,起因於企圖對一個「由 const 版之 operator[] 傳回的 const char&」施行賦值的動作。
另一個要注意的是 non-const operator[] 的返回型別是 reference to char,如果 operator[] 是傳回一個 char ,則以下的句子就無法通過編譯:
tb[0] = 'x';
這是因為如果函式的返回型別是個內建型別,則改動函式的回返值從來就不合法,就算合法,則 C++ 以 pass by value 傳回物件這一事實(條款20),將意味著被改動的其實是 tb.text[0] 的一個副本,而不是 tb.text[0] 本身。
成員函式如果是 const ,則意味著什麼?這有兩個流行的概念:
bitwise constness (又稱 physical constness)
logical constness
bitwise constness 陣營的人相信,成員函式只有在不更改物件之任何成員變數(static 除外)時,才可以說是 const,也就是說不能更改物件的任何一個 bit。
這個論點的好處是很容易偵測違反點:只要尋找成員變數的賦值動作即可。
bitwise constness 正是 C++ 對 constness 的定義,因此 const 成員函式不可以更改物件內任何 non-static 成員變數。
然而許多成員函數雖然不十足具備 const 性質,但卻能通過 bitwise 測試。
具體來說是,一個改了「指標所指物」的成員函式雖然不算是 const,但如果只有指標隸屬於物件,則稱此函式為 bitwise const ,並不會引發編譯器異議。
這種情況下,導出所謂的 logical constness:
一個 const 成員函式可以修改它處理的物件內的某些 bits,但只有在客戶端偵測不出的情況下才能如此。
例如在 CTextBlock class 有可能快取(cache)文字區塊的長度以便應付詢問:
class CTextBlock {
public:
...
std:size_t length() const;
private:
char* pText;
mutable std:size_t textLength;
mutable bool lengthIsValid;
// mutable 的成員可能被修改,即使是在 const 成員函式內
};
std:size_t CTextBlock::length() const {
if (!lengthIsValid) {
textLength = std::strlen(pText); // 修改 mutable 成員
lengthIsValid = true; // 修改 mutable 成員
}
return textLength;
}
在 const 和 non-cost 成員函式中,也因為是重載的關係,將導致程式碼的大量重複,就算將所有程式碼移到另一個成員函式(往往是 private),並命令兩個 operator[] 呼叫過去,這是可行的作法,不過還是重複了函式呼叫,及兩次 return 述句等等。
此時,最好的作法是實作 operator[] 的機能一次,並使用它兩次,也就是說,必須令其中一個呼叫另一個,這會需要將常數性轉除(casting away constness)。
一般而言, casting 是一個糟糕的做法,在條款27 會有詳述,但,程式碼重複也不是那麼令人愉快。
class TextBlock {
public:
...
const char& operator[] (std::size_t position) const {
...
...
...
}
char& operator[] (std::size_t position) {
return const_cast<char&>( // 將 op[] 回返值的 const 移除
static_cast<const TextBlock&>(*this)
// 為 *this 加上 const
[position]); // 呼叫 const op[]
}
};
這份程式碼有兩個轉型的動作,第一個是用來為 *this 添加 const,使得接下來呼叫 operator[] 時,得以喚以 const 版本。
第二則是從 const operator[] 的返回值中移除 const。
const 成員函式承諾絕不變變其它物件的邏輯狀態(logical state),但 non-const 成員函式卻沒有,所以如果在 const 函式內呼叫 non-const 函式,就是冒了這樣的風險,因此「const 成員函式呼叫 non-const 成員函式」是一種錯誤的行為,而 non-cast 成員函式呼叫 const 成員函式則不會帶來風險。
將某些東西宣告為 const,可以幫助編譯器偵測出錯誤用法;const 可被施加於任何作用域內的物件、函式參數、函式返回型別、成員函式本體。
編譯器強制實施 bitwise constness ,但在編寫程式時,應該使用 conceptual constness。
當 const 和 non-const 成員函式有著實質相等的實作時,令 non-const 版本呼叫 const 版本可避免程式碼重複。
04、Make sure that objects are initialized before they're used
關於「將物件初始化」這件事,C++ 似乎反覆無常,例如:
int x;
在某些語境下 x 保証會被初始化(為0),但在其他語境中卻不保証,又如:
class Point {
int x, y;
};
...
Point P;
p 的成員變數有時會被初始化(為0),有時候卻不會。
讀取未初始化的值將導致不明確的程式行為,以及許多令人不愉快的除錯過程。
有一些規則可以得知「物件的初始他動作何時一定會發生,何時不會發生」:
通常如果使用 C part of C++ ,而且初始化可能會招致執行期成本,不保證發生初始化。
若進入 non-C parts of C++ ,則規則有些變化。
這也解釋了為什麼 array(C part of C++) 不保証其內容被初始化,而 vector(STL part of C++) 卻有此保証。
而最佳的處理辦法就是:永遠在使用物件之前,先將它初始化。
對於沒有任何成員的內建型別,須要手工完成初始化:
int x = 0;
const char* text = "A C-style String";
double d;
std::cin >> d; // 以讀取 input stream 的方式完成初始化
至於內建型別以外的任何其它東西,初始化的責任落在建構式(constructor)的身上,規則就是:確保每一個建構式將物件的每一個成員初始化。
要注意的是,別混淆了賦值(assignment)和初始化(initialization)的差別:
class Phone Number { ... };
class ABEntry {
public:
ABEntry(const std::string& name,
const std::string& address,
const std::list& phones);
private:
std::string theName;
std::string theAddress;
std::list thePhones;
int numTimesConsulted;
};
ABEntry::ABEntry(const std::string& name,
const std::string& address,
const std::list& phones) {
theName = name; // 這些都是賦值(assignments)
theAddress = address;
thePhones = phones;
numTimesConsulted = 0;
}
C++ 規定:物件的成員變數的初始化動作發生在進入建構式本體之前。
所以在 ABEntry 建構式內的程式碼,都是賦值。
初始化的發生在這些成員的 default 建構式被自動喚起的時候,比進入 ABEntry 建構式的時間點更早,除了 numTimesConsulted,因為它屬於內建型別,所以不保證在賦值的時間點之前就獲得初值。
ABEntry 建構式的較佳寫法是使用 member initialization list(成員初值列) 取代賦值:
ABEntry::ABEntry(const std::string& name,
const std::string& address,
const std::list& phones)
:theName(name), // 這些都是初始化(initialization)
theAddress(address),
thePhones(phones),
numTimesConsulted(0) {
}
雖然兩者結果相同,但是這個建構式具有較高的效率,因為在前一個建構式中,會先喚起 default 建構式對 theName, theAddress 及 thePhone 設初值,然後在立刻賦予新值,因為這算成員多作了 default 建構式的動作。
而 member initialization list 的作法,則避免了這個問題,因為 initialization list 中,針對各個成員所設的引數,會被拿來作為成員建構式的引數,如:
theName 以 name 為初值進行 copy constructor,the Address 以 address 為初值進行 copy constructor。
對大多數型別而言,比起先呼叫 default constructor 再呼叫 copy assignment operator,單只呼叫一次 copy constructor 是比較有效率的。
有些情況下,即使是成員變數屬於內建型別,也一定得使用初值列,如果成員變數是 const 或 references ,就一定需要初值,而且不能被賦值,詳情見條款5。
而為了避剝需要記住成員何時必須在 member initialization list 中初始化,何時不需要,最簡單的作法就是:總是使用 member initialization list。
C++ 有著十分固定的「member initialization order」:
base classes 更早於其 derived classes 被初始化,而 class 成員變數總是以其宣告次序被初始化,即使在 member initialization list 中以不同的次序出現,也不受影響,但為了減少日後的迷惑,也為了避免 bug,在 member inilization list 中的順序,最好以其宣告的次序為次序。
最後要說明的是「不同編譯單元內定義的 non-local static 物件」的初始化次序。
所謂 static 物件,它的壽命是從被建構出來為止,直到程式結束,因此 stack 及 heap-based 物件都被排除,static 物件包含:
global 物件、定義於 namespace 作用域的物件及在 classes 內、函式內、file 作用域內被宣告為 static 的物件。
在函式內的物件稱為 local static 物件,因為它們對函式而言是 local,而其它 static 物件,就是 non-local static 物件。
而編譯單元(translation unit) 是指產出單一目的檔(single object file) 的那些源碼,基本上是單一源碼檔加上其所有含入檔(#include files)。
而問題是,在某個編譯單元中的某個 non-local static 物件的初始化動作,會使用到另一個編譯單元內的某個 non-local static 物件,要如何確保這個被用到物件已經先進行初始化過了?
class FileSystem { // 來自程式庫
public:
...
std::size_t numDisks() const; // 眾多成員函式之一
...
};
extern FileSystem tfs; // 預備給客戶使用的物件
FileSystem 物件絕不是一個無關痛癢的物件,如果客戶在 tfs 物件完成建構前就使用它,就會得到慘重的災情。
class Directory { // 由客戶建立的程式庫
public:
Directory( params );
...
};
Directory::Directory( params ) {
...
std::size_t disks = tfs.numDisks(); // 使用 tfs 物件
...
}
Directory tempDir( params );
如此,初始化次序的重要性顯現出來了,除非 tfs 在 tempDir 之前先被初始化,否則 tempDir 的建構式,就會用到尚未初始化的 tfs,但要如何確定 tfs 會在 tempDir 之前先被初始化?
答案是:無法確定,因為 C++ 對「定義於不同編譯單元內的 non-local static 物件」的初始化相對次序並無明確定義。
不過,只要作個小小的改動,就可以消除這個問題,唯一需要做的是:
將每個 non-local static 物件搬到自己專屬的函式中,這些函式回傳一個 reference 指向它所含的物件,而用戶就呼叫這些函式,而不直接指涉這些物件。
這個手法的基礎在於:
C++ 保証函式內的 local static 物件會在「該函式被呼叫期間」「首次遇上該物件之定義式」時被初始化。
class FileSystem { ... };
FileSystem& tfs() { // 取代 tfs 物件
static FileSystem fs; // 定義並初始化一個 local static 物件
return fs; // 傳回一個 reference 指向上述物件
}
class Directory { ... };
Directory::Directory( params ) {
...
std::size_t disks = tfs().numDisks();
...
}
Directory& tempDir() {
static Directory td;
return td;
}
經修改後,這個系統程式將不再使用 static 物件本身,而是使用函式傳回的「指向 static 物件」的 references。
這種結構下的 reference-returning 函式往往十分單純:
第一行定義並初始化一個 local static 物件,第二行回傳它。
但從另一個角度看,這些函式「內含 static 物件」的事實,使它們在多執行緒系統中帶有不確定性。
再說一次,任何一種 non-const static 物件,不論是 local 或是 non-local ,在多執行緒環境下「等待某事發生」都會有麻煩,處理這種麻煩的一種作法是:
在程式的單緒啟動階段 (single-threaded startup portion) 手動喚起所有 reference-returning 函式,這可消除跟初始化有關的 race condition。
為了避免物件在初始化之前就被使用,需要做三件事:
一、手工初始化內建型 non-member 物件。
二、使用 member initialization list 對付物件的所有成員。
三、針對「跨編譯單元初始化次序不確定性」,加強程式的設計。
為內建型別進行手工初始化,因為 C++ 不保證初始化它們。
建構式最好使用 member initialization list,其排列次序應該和宣告次序相同。
為了避免「跨編譯單元之初始化次序」問題,以 local static 物件來取代 non-local static 物件。