多數情況下,適當提出 class 和 class template 定義式及 function 和 function template 宣告式是最花費心力的兩件事,正確完成它們之後,相應的實作大多直接了當。
但還是有些東西要小心處理:
- 太快定義變數可能造成效率上的延遲。
- 過度使用 cast 可能導致程式碼變慢又難維護,也可能招來 bug。
- 傳回物件「內部資料之 handle」可能會破壞封裝,也可能留給使用者 dangling handle。
- 如果沒考慮到 exception 的衝擊,則可能導致 memory leak 及資料敗壞。
- 過度熱心地 inline,可能引起程式碼膨脹。
- 過度 coupling 則可能導致令人不滿意的冗長 build time。
26、Postpone variable definitions as long as possible.
只要定義了一個變數,而其型別帶有一個 constructor 及 destructor,則當程式的 control flow 到達這個變數定義式時,就會喚起 constructor,當此變數離開作用域時,就會喚起 destructor,即使這個變數從未被使用:
std::string encryptPassword(const std::string& password) {
using namespace std;
string encrypted;
if (password.length()
如果有個 exception 被丟出,則 encrypted 就沒被用到,但仍會負出 construction 及 destruction 的成本,最好的作法是延後 encrypted 的定義式,直到確實需要它。
雖然延後了 encrypted 的定義式,但卻先喚起 default constructor,再喚起 copy assignment operator= 賦值,條款 4 說明「透過 default constructor construct 一個物件後,再對它賦值」比「直接在 construct 時指定初值」的效率差,所以最好的方式是直接以 password 做為 encrypted 的初值:
std::string encryptPassword(const std::string& password) {
...
std::string encrypted = password;
encrypt(encrypted);
return encrypted;
}
所以在定義變數時,要到非得使用該變數的情況下再定義,甚至應該嘗試延後定義式,直到可以給它初值。
如果變數是在迴圈內使用,那麼把變數定義於迴圈外比較好?還是迴圈內比較好?
Widget w; // 定義於迴圈外
for (int i = 0; i // 定義於迴圈內
...
}
以上兩種寫法的成本如下:
- 定義於迴圈外:1 次 constructor,1 次 destructor,n 次 copy assignment operator=
- 定義於迴圈內:n 次 constructor,n 次 destructor
如果 1 次 copy assignment operator= 的成本低於 1 次 constructor + destructor,則定義於迴圈外大致來說比較高效,尤其是當 n 很大時,否則將變數定義在迴圈內的作法或許較好。
此外,將定義式擺在迴圈外,其變數的作用域會變得比較大,而對程式的可理解性及易維護性造成衝突,因此,除非:
- 知道 copy assignment operator= 的成本低於「constructor + destructor」。
- 正在處理程式碼中 performance-sensitive 的部分。
否則應該將變數宣告於迴圈內。
儘可能延後變數定義式的出現,可以增加程式的清晰度及程式效率。
27、Minimize casting.
回顧一下轉型語法,通常有三種不同的型式:
C 風格的轉型語法:
(T)expression // 將 expression 轉型為 T
函式風格的轉型語法:
T(expression) // 將 expression 轉型為 T
這兩種形式並無差別,只是小括號的擺放位置不同而已,這邊稱為此兩種形式為 old-style cast。
C++ 另外提供四種 new-style cast 或稱為 C++-style cast:
- const_cast
( expression ) - dynamic_cast
( expression ) - reinterpret_cast
( expression ) - static_cast
( expression )
各有不同的目的:
- const_cast 通常用來將物件的常數性轉除(cast away the constness),它也是唯一有此能力的 C++-style 轉型運算子。
- dynamic_cast 主要用來執行「安全向下轉型」(safe downcasting),這是唯一無法由舊式語法執行的動作,也是唯一可能秏費重大執行成本的轉型動作。
- reinterpret_cast 意圖執行低階轉型,實際動作及結果可能取決於編譯器,這也就表示它不可移植。
- static_cast 用來強迫隱式轉換(implicit conversion)。
雖然舊式轉型仍然合法,但新式轉型較受歡迎,原因是:
- 新式轉型較容易在程式碼中被辨識出,因而得以簡化「找出型別系統在哪個地點被破壞」。
- 各轉型動作的目標愈窄化,編譯器愈可能診斷出錯誤的運用。
有些 programmer 錯誤地認為:轉型其實什麼都沒做,只是告訴編譯器把某種型別視為另一種型別。
其實任何一種型別轉換,不論是透過轉型操作而進行的顯式轉換,或是編譯器完成的隱式轉換,往往會令編譯器編譯出執行期間執行的碼,例如:
int x, y;
...
double d = static_cast(x) / y;
將 int x 轉型為 double,肯定會產生一些碼,因為在大部分計算機體系結構中,int 的底層表述不同於 double 的底層表述。
另一個較特別的例子是:
class Base { ... };
class Derived : public Base { ... };
Derived d;
Base *pb = &d; // 隱寓地將 Derived* 轉換為 Base*
這裡不過是建立一個 base class 指標指向一個 derived class 物件,但有時候上述兩個指標值並不相同
,這種情況下會有個偏侈量(offset),在執行期被施行於 Derived* 指標身上,用以取得正確的 Base* 指標。
上個例子顯示,單一物件可能擁有一個以上的位址,如:一個型別為 Derived 的物件,在用 Base* 指向它時的位址和用 Derived* 指向它時的位址可能不同,在 C, Jave, C# 都不可能發生這種事,但 C++ 卻可能。
這也意味著要避免做出「物件在 C++ 中如何佈局」的假設,也不應該以此假設為基礎執行任何轉型動作。
C++ 物件佈局方式和位址計算方式隨編譯器的不同而不同,這也意味著「由於知道物件如何佈局」而設計的轉型,在某一平台行得通,但在其它平台並不一定行得通。
另一件關於轉型有趣的事是:很容易寫出似是而非的程式碼,雖然在其它語言中,可能是對的:
class Window { // base class
public:
virtual void onResize() { ... }
...
};
class SpecialWindow : public Window { // derived class
public:
virtual void onResize() {
static_cast(*this).onResize(); // 錯誤
// 將 *this 轉型為 Window,然後呼叫其 onResize()
....
}
};
轉型動作是由 static_cast 完成,但就算是使用 old-style cast,也無法改變以下事實:
一如預期,這段程式會將 *this 轉型為 Window,對函式 onResize 的呼叫也會被喚起,但,它喚起的不是當前物件上的函式,而是稍早轉型動作所建立的一個「*this 物件之 base class 成分」的暫時副本身上的 onResize()!
再說一次,它是在「當前物件之 base class 成分」的副本身上呼叫 Window::onResize(),然後再當前物件身上執行 SpecialWindow 的專屬動作,如果在 Window::onResize() 修改了物件內容,當前物件其實沒被改動,只有副本被改動,然而若 SpecialWindow::onResize() 也改動了物件,則當前物件是真的會被改動。
解決之道是拿掉轉型動作:
class SpecialWindow : public Window { // derived class
public:
virtual void onResize() {
Window::onResize(); // 呼叫 Window::onResize()
....
}
};
這個例子也說明了,如果發現自己打算轉型,那就是個警告訊號,很可能將局面發展至錯誤的方向上,如果是使用 dynamic_cast 則更是如此。
dynamic_cast 的許多實作版本執行速度相當慢,例如至少有一個很普通的實作版本奠基於「class 名稱之字串比較」。
之所以需要 dynamic_cast,通常是因為想在一個認定為 derived class 物件身上執行 derived class 函式操作,但手上卻只有一個「指向 base」的 pointer 或 reference,有兩個一般性作法可以避免這個問題:
第一是使用容器,並在其中儲存直接指向 derived class 物件的指標,通常是 smart pointer:
class Window { ... };
class SpecialWindow : public Window { // derived class
public:
void blink();
...
};
typedef std::vector<:tr1::shared_ptr> > VPSW;
VPSW winPtrs;
...
for (VPSW::iterator iter = winPtrs.begin();
iter != winPtrs.end(); ++iter)
(*iter)->blink();
另一種作法是透過 base class 介面處理「所有可能之各種 Window 衍生類別」,也就是在 base class 內提供 virtual function 做想對各個 Window 衍生類別做的事:
class Window {
public:
virtual void blink(); // 條款 34 說明預設實作碼可能是個餿主意
...
};
class SpecialWindow : public Window { // derived class
public:
virtual void blink();
...
};
typedef std::vector<:tr1::shared_ptr> > VPW;
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin();
iter != winPtrs.end(); ++iter)
(*iter)->blink();
不論是「使用型別安全容器」或「將 virtual function 往繼承體系上方移動」都並不是絕對有效,但在許多情況下它們都提供一個可行的 dynamic_cast 替代方案。
絕對必須避免的一件事是所謂的「連串的(cascading) dynamic_cast」:
class Window { ... }
...
typedef std:vector<:tr1::shared_ptr> > VPW;
VPW winPtrs;
...
for (VPW::iterator iter = winPtrs.begin();
iter != winPtrs.end(); ++iter) {
if (SpecialWindow1 *psw1 =
dynamic_cast(iter->get())) { ... }
else if (SpecialWindow2 *psw2 =
dynamic_cast(iter->get())) { ... }
else if (SpecialWindow3 *psw3 =
dynamic_cast(iter->get())) { ... }
}
這樣產生出程式碼又大又慢,而且基礎不穩,每次 Window class 繼承體系一有改變,所有這類的程式碼都必須再次檢閱,看是否需要修改,所以這類的程式碼應該總是以某些「植基於 virtual function 呼叫」的東西取代。
優良的 C++ 程式碼很少使用轉型,但要完全擺脫卻有太過不切實際,但可以做的是:
儘可能隔離轉型動作,通常是把它隱藏在某個函式內,函式的介面會保護呼叫者不受函式內部任何不該有的舉動影響。
儘量避免轉型,特別是在注重效率的程式碼中避免 dynamic_cast。
如果轉型是必要的,試著將它隱藏在某個函式背後,讓使用者可以隨時呼叫該函式,而不需將轉型放進他們的程式碼內。
寧可使用 C++-style 轉型,不要使用舊式轉型,前者容易辨識,且有著分門別類的職掌。
28、Avoid returning "handles" to object internals.
假設程式涉及矩形,每個矩形由其左上角及右上角表示,為了讓一個 Rectangle 物件儘可能小,可能會決定把定義矩形的這些點放在一個輔助的 struct 內,再讓 Rectangle 去指它:
class Point {
public:
Point(int x, int y);
...
void setX(int newVal);
void setY(int newVal);
...
};
struct RectData {
Point ulhc; // upper left-hand corner
Point lrhc; // lower right-hand corner
};
class Rectangle {
public:
Point& upperLeft() const { return pData->ulhc; }
Point& lowerRight() const { return pData->lrhc; }
...
private:
std::tr1::shared_ptr pData;
};
藉由 class 所提供的 upperLeft() 及 lowerRight() function,Rectangle 的使用者可以計算 Rectangle 的範圍,而依據條款 20 的忠告,以 by reference 的方式傳遞自定型別往往比 by value 更有效率。
這樣的設計雖然可通過編譯,但卻是自我矛盾的,因為 upperLeft() 及 lowerRight() function 只是為了是供座標點給使用者,而不是讓使用者更改座標:
Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2);
rec.upperLeft().setX(50); // rec 的座標值將被修改
這裡給出了兩個教訓:
- 成員變數的封裝性最多只等於「傳回其 reference」的函式的存取層級。
- 如果 const 成員函式傳出一個 reference,reference 所指資料與物件本身有關聯,而所指的資料又被儲存於物件之外,那個這個函式的呼叫者就可以修改那筆資料。
上述所提都是由於「成員函式傳回 reference」造成,如果傳回的指指標或迭代器,則相同的情況還是會發生,原因也相同, Reference、指標、迭代器統統都是所謂的 handle,而傳回一個「代表物件內部資料」的 handle,隨之而來的便是「降低物件封裝性」的風險,同時,也可能導致「雖然呼叫 const 成員函式卻造成物件狀態被更改」。
只要對回返型別加上 const,上述的兩個問題即可輕鬆去除:
class Rectangle {
public:
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
...
};
如此,使用者將只能讀取矩形的 Point,而無法塗寫。
但即使如此,upperLeft() 和 lowerRight() 還是傳回了「代表物件內部」的 handle,這將可能導致 dangling handle 的問題:
也就是 handle 所指的東西不復存在:
class GUIObject { ... };
const Rectangle boundingBox(const GUIObject &obj);
// 以 by value 方式傳回一個矩形
GUIObject* pgo; // 讓 pgo 指向某個 GUIObject
...
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft());
// 取得一個指標指向外框左上點
對 boundingBox 的呼叫獲得一個新的、暫時的 Rectangle 物件,暫稱它為 temp,隨後 upperLeft 作於 temp 身上,傳回一個 reference 指向 temp 的內部成分,但在那個述句結束之後, temp 也將被銷毀,而導致 temp 被解構,最終 pUpperLeft 指向一個不再存在的物件,也就變成 dangling!
ps. 這個例子明顯有些牽強,因為以 by value 的方式傳回矩形本身就是有問題的。
這就是為什麼函式如何「傳回一個 handle 代表物件內部成分」總是危險的原因,唯一的關鍵就是:
有個 handle 被傳出去了,一旦如此,就是曝露在「handle 比其所指物件更長壽」的風險下。
不過有時候,讓成員函式傳回 handle 是必須的,例如:
operator[] 就允許「摘採」string 或 vector 的個別元素,而這些 operator[] 就是傳回 reference 指向「容器內的資料」,詳見條款 3。
避免傳回 handle 指向物件內部,將可增加封裝性、幫助 const 成員的行為像個 const、並將發生 dangling handle 的可能性降低。
29、Strive for exception-safe code.
假設有個 class 用來表現夾帶背景圖案的 GUI 功能表單,它有個 mutex 用來作為 multi-thread 下的 concurrency control 之用:
class PrettyMenu {
public:
...
void changeBackground(std::istream& imgSrc);
// 改變 background
private:
Mutex mutex;
Image* bgImage; // 目前 background
int imageChanges; // background 改變次數
};
void PrettyMenu::changeBackground(std::istream& imgSrc) {
lock(mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
unlock(mutex);
}
以「exception safety」的觀點來看,這個 function 很糟,因為它無法滿足「exception safety」的兩個條件:
- 不洩漏任何資源:不上程式碼一旦 "new Iimage()" 拋出 exception,則 unlock 就絕不會被執行,導致 mutex 永遠被鎖住。
- 不允許資料敗壞:如果 "new Iimage()" 拋出 exception,bgImage 就變成指向一個已被刪除的物件,且 imageChanges 還是會被累加。
解決資源洩漏的問題較為容易,條款 13 說過如何管理物件資源,條款 14 也導入 Lock class 做為一種「確保 Mutex 被及時釋放」的方法:
void PrettyMenu::changeBackground(std::istream& imgSrc) {
Lock ml(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}
在解進資料的敗壞之前,先瞭解何謂 exception-safe function, exception-safe function 提供以下三個保證之一:
- 基本承諾:如果 exception 被拋出,程式內的任何事物仍然保持在有效狀態下,沒有任何物件或資料結構會因此被破壞。
- 強烈保證:如果 exception 被拋出,則程式狀態將不會有任何改變,也就是回到呼叫 function 之前的狀態。
- 不拋擲(nothrow) 保証:承諾不拋出 exception。
如果有個 function 帶著「空白異常明細」(empty exception specification) ,這並不是說這個 function 不會拋出 exception,而是說如果這個 function 拋出 exception,將會是個嚴重的錯誤:
int doSomething() throw(); // empty exception spec
exception-safe code 必須提供上述三項保證之一,因為,該抉擇的是,該為所寫的每個 function 提供哪一種保證?
一般而言,最強列的保證是 nothrow,但很難在 C part of C++ 中完全沒有呼叫到可能拋出 exception 的 function,任何使動態記憶體的東西,如果沒有找到足夠的記憶體以滿足需求,通常就會拋出一個 bad_alloc exception,詳見條款 49,所以對大部分函式而言,抉擇往往是落在基本保證或強烈保證之間。
對 changeBackground 而言,提供強烈保證幾乎不困難,首先改變 PrettyMenu 的 bgImage 成員變數型別為「用於資源管理」的 smart pointer,再重新排列 changeBackground 內的述句次序:
class PrettyMenu {
...
private:
std::tr1::shared_ptr bgImage; // 目前 background
};
void PrettyMenu::changeBackground(std::istream& imgSrc) {
lock(mutex);
bgImage.reset(new Image(imgSrc));
++imageChanges;
}
以上修改幾乎足夠讓 changeBackground() 提供強烈的 exception safety 保證,但美中不足的是參數 imgSrc,如果 Image constructor 拋出 exception,則可能 input stream 的讀取記號(read marker) 已被移走,而這個搬移對程式其餘部分是一種可見的狀態改變,所以 changeBackground() 在解決這個問題之前,只能算是提供基本保證。
有個一般化的設計策略很典型地會導致強列保證,這個策略被稱為 copy and swap。
實作上通常是將所有「隸屬物件的資料」從原物件放進另一個物件內,然後賦予原物件一個指標,指向那個所謂的實作物件(implementation object),這個手法常被稱為 pimpl idiom,詳見條款 31:
struct PMImpl { // pretty menu implemenatation
std::str1::shared_ptr bgImage;
int imageChanges;
};
class PrettyMenu {
...
private:
Mutex mutex;
std::tr1::shared_ptr pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc) {
using std::swap;
lock(mutex);
std::tr1::shared_ptr pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc));
++pNew->imageChanges;
swap(pImpl, pNew);
}
"copy and swap" 是對物件狀能做出「全有或全無」改變的一個很好辦法,但就實作上,還是有困難處,以 someFunc() 為例,它使用 copy and swap 策略,但還另外包括 f1() 及 f2() 的呼叫:
void someFunc() {
... // 對 local 狀態做一份副本
f1();
f2();
... // 將修改後的狀態置換過來
}
很顯然,如果 f1() 或 f2() 的 exception safety 比「強烈」低,就很難讓 someFunc 成為「強烈 exception safety」。
而如果 f1() 及 f2() 都是「強烈 exception safety」,情況也不就此好轉,因為如果順利執行 f1() 完成,而在 f2() 拋出 exception,則此時程式狀態和 someFunc 被呼叫之前並不相同,就算 f2() 沒改變任保東西時也是如此。
問題出在「side effect」,如果 function 只操作 local state,便相對容易提供強烈保證,但當 function 對 non-local data 有 side effect 時,提供強烈保證就困難多了。
另一個阻止為函式提供強烈保證的主題是效率, copy and swap 會為每個即將被改動的物件做出一個副本,那得秏用時間及空間。
如果在狀況許可的情況下,應該儘可能提供「強烈保證」,但「強烈保證」並非在任何時刻都顯得實際,此時就必須提供「基本保證」。
當在撰寫新碼或修改舊碼時,應該想想如何讓程式具備 exception safety,首先「以物件管理資源」可以阻止資源洩漏,詳見條款 13。
然後是挑選三個「exception safety 保證」的某一個實施於所寫的每個函式身上。
exception-safe function 即使發生 exception 也不會洩漏資源或允許任何資料結構敗壞,這樣的函式分成三種保證:基本型、強烈型、nothrow 型。
「強烈保證」往往能以 copy and swap 實作出來,但並非對所有函式都可實現或具備實現意義。
function 所提供的「exception safety 保證」最高只等於其所呼叫之各個 function 的「exception safety 保證」中的最弱者。
30、Understand the ins and outs of inlining.
inline function:
它們看起來像 function,動作像 function,又比使用巨集好,可以呼叫它們又不需蒙受函式呼叫所招致的額外開銷。
inline function 其實不只如此,編譯器最佳化機制通常被設計用來濃縮那些「不含函式呼叫」的程式碼,所以當 inline 某個 function,或許 compiler 就因此有能力對函式本體執行語境相關最佳化。
inline function 背後的整體觀念是:將「對此 fuction 的每個呼叫」都以 function 本體取代,這將可能增加 object code 大小。
在一台記憶體有限的機器上,過度熱衷 inlining 會造成程式體積太大,而導致額外的換頁行為(paging)、降低指令快取擊中率(instruction cache hit rate)、以及伴隨而來的效率損失。
換個角度說,如果 inline function 的本體很小,則 compiler 針對「function 本體」所產生的碼可能比針對「函式呼叫」所產生的碼更小。
inline 只是對 compiler 的一個申請,不是強制指令,這項申請可以隱寓提出也可明確指出,隱寓方式是將 function 定義在 class 定義式內:
class Person {
public:
...
int age() const { return theAge; } // 隱寓的 inline 申請
private:
int theAge;
};
明確宣告 inline function 的作法則是在定義式前加上關鍵字 inline,例如標準的 max template (來自 <algorithm>)往往這樣實作:
template<typename T>
inline const T& std::max(const T&a, const T&b) {
// 明確申請 inline
return a
「max 是個 template」帶出了一項觀察:
inline function 和 template 兩者通常都被定義在 header file 內,但要注意的是 function template 卻未必一定要是 inline。
inline function 通常被置於 header file,因為大多數 build environment 在 compile 過程中進行 inlining,而為了將一個「函式呼叫」替換為「被呼叫函式的本體」,compiler 必須知道那個 function 長什麼樣子。
template 通常也被置於 header file 內,因為它一旦被使用,compiler 為了將它具現化,需要知道它長什麼樣子,不過這也不是統一的準則,某些 build environment 可以在連結期才執行 template 具現化。
template 的具現化與 inlining 無關,如果認為此 template 具現出來的 function 都應該 inlined,就將此 template 宣告為 inline,也就是上述 std::max 實作碼的作為,如果此 template 沒有理由要求它所具現的每個 function 都是 inlined,就應該避免將這個 template 宣告為 inline(不論顯示或隱示)。
大部分 compiler 拒絕將太過複雜的函式 inlining,而所有對 virtual function 的呼叫(除非是最平淡無奇的)也都會使 inlining 落空。
一個表面上看似 inline 的 function 是否真是 inline,取決於 build environment,主要取決於 compiler,幸運的是,如果 compiler 無法將所要求的 function inline 化,通常會給出 warning message,詳見條款 53。
事實上,constructor 及 destructor 往往是 inlining 的糟糕候選人:
class Base {
public:
...
private:
std::string bm1, bm2;
};
class Derived : public Base {
public:
Derived() { } // constructor 看似是空的?
...
private:
std::string dm1, dm2, dm3;
};
C++ 對於「物件被產生和被銷毀時發生什麼事」做了各式各樣的保證:
當使用 new ,動態產生的物件會被其 constructor 自動初始化,當使用 delete 對應的 destructor 會被喚起;當產生一個物件,其每個 base class 及每個成員變數都會被自動 construct,當銷毀時,反向程式的 destruct 亦會自動發生;如果有個 exception 在物件 construct 期間被拋出,該物件已 construct 好的部分會被自動銷毀。
這些情況中, C++ 描述了一定會發生,但沒說如何發生,因為「事情如何發生」是 compiler 實作者的權責,這些讓事情發生的程式碼,由 compiler 在 compile 期間代為產生並安插到程式的程式碼中,有時就放在 constructor 及 destructor 內。
如此將造成 constructor 內,增加一堆程式碼,而影響編譯器是否對此空白函式 inlining。
程式庫設計者必須評估「將函式宣告為 inline」的衝擊:
inline 函式無法隨著程式庫的升級而升級。
換句話說,如果 f 是程式庫內的一個 inline function,則一旦程式庫設計者決定改變 f,則所有用到 f 的使用者端程式都必須重新 compile,然而如果 f 是 non-inline function,則使用者端只要重新 link 就好,如果程式庫採用 dynamic link,升級版的 function 甚至可以不知不覺地被應該程式採納。
如果以實用的觀點來說,有個事實比其它因素重要:
大部分除錯器對 inline function 都束手無策。
畢竟要如何在一個並不存在的函式內設立 break point 呢?
在決定哪些 function 該被宣告為 inline 時,掌握一個策略:
一開始不要將任何 function 宣告為 inline,或至少將 inlining 施行範圍侷限在那些「一定成為 inline」(見條款 46) 或「十分平淡無奇」(如上例的 Person::age)的函式身上。
80 - 20 經驗法則:
平均一個程式,往往將 80% 的執行時間花費在 20% 的程式碼上。
做為一個軟體開發者,目標是找出這個可以有效增進程式整體效率的 20% 程式碼,然後將它 inline 或瘦身。
將大多數 inlining 限制在小型、被頻繁呼叫的 function 身上,這將使得日後的 debug 過程及 binary upgradability 更容易,也可使潛在的程式膨脹問題最小化,並使程式的速度提升機會最大化。
不要只因為 function template 出現在 header file,就將它們宣告為 inline。
31、Minimize compilation dependencies between files.
C++ 並沒有把「將介面從實作中分離」這事做得很好,在 Class 的定義式中,不只詳細敘述了 class 介面,可能還包括十足的實作細目:
class Person {
public:
Person(const std::string& name, const Date& birthday,
const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::string theName; // 實作細目
Date theBirthDate; // 實作細目
Address theAddress; // 實作細目
};
如果 compiler 沒有取得此 class 實作碼所用到的 classes: string, Date 和 Address 的定義式,則將無法通過 compile。
這樣的定義式通常由 #include 指令提供,所以 Person 定義檔最上方,很可能存在這樣的東西:
#include <string>
#include "date.h"
#include "address.h"
不幸的是,這樣一來便是在 Person 定義檔中和其所 include 的 header file 形成了一種 compilation dependency,如果這些 header file 有任何一個被改變,或這些 header file 所倚賴的其它 header file 有任何改變,則每一個含入 Person class 的檔案就得重新 compile,任保使用 Person class 的檔案也必須重新 compile,這樣的 cascading compilation dependency 將會對專案造成難以形容的災難。
為什麼 C++ 堅持將 class 的實作細目置於 class 定義式中?為什麼不這樣定義 Person?
namespace std {
class string; // 不正確的 foward declaration
}
class Date;
class Address
class Person {
public:
Person(const std::string& name, const Date& birthday,
const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
...
};
如果可以這麼作,Person 的使用者就只需要在 Person 介面被修改時,才重新編譯。
這個作法存在兩個問題:
- string 不是個 class,它是個 typedef(定義為 basic_string
),正確的 forward declaration 比較複雜,因為涉及額外的 template,但那不打緊,因為本來就不應該嘗試手動宣告一部分標準函式,應該僅僅使用適當的 #include 完成。 - 另一個,同時也是比較重要的是,compiler 必須在 compile 期間知道物件的大小,當 compiler 看到 Person 的定義式時,必須配置足夠空間以放置一個 Person,而要知道 Person 有多大,這時就需要列出實作細目。
所以正確來說,需要做的改變是「將物件實作細目隱藏於一個 pointer 背後」:
#include <string>
#include <memory>
class PersonalImpl; // person 實作類別的 forwoard declaration
class Date;
class Address
class Person {
public:
Person(const std::string& name, const Date& birthday,
const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::tr1::shared_ptr pImpl; // poiner,指向實作
};
main class 只內含一個指標成員,指向其實作類別,這般設計被稱為 pimpl idiom。
這樣的設計下, Person 的使用者就完全與 Date, Address 及 Person 的實作細目分離了,這些 class 的任保實作修改都不需要 Person 使用者端重新 compile。
這個分離的關鍵在於以「宣告的依存性」取代「定義的依存性」,這也正是 compilation dependency 最小化的本質:
讓 header file 儘可能自我滿足,如果做不到,則讓它與其它檔案內的宣告式相依。
- 如果使用 object reference 或 object pointer 可以完成任務,就不要使用 object;以避免需要使用到該型別的定義式。
- 如果可以,儘量以 class 宣告式取代 class 定義式;當宣告一個 function 而它用到某個 class 時,只需要該 class 的宣告式,縱使 function 以 by value 方式傳遞該型別的參數或返回值。
- 為宣告式和定義式提供不同的 header file;為了嚴守上述準則,需要兩個 header file,一個用於宣告式,一個用於定義式。
像 Person 這樣使用 pimpl idiom 的 class,往往被稱為 Handle class,這樣的 class 如何真正作事?
方法之一是將它們的所有 function 轉交給相應的 implementation class:
#include "Person.h"
#include "PersonImpl.h"
Person::Person(const std::string& name, const Date& birthday, const Address& addr)
:pImpl(new PersonImpl(name, birthday, addr)) {
}
std::string Person::name() const {
return pImpl->name();
}
Person constructor 以 new 喚起 PersonalImple constructor,並在 Person::name() 呼叫 PersonImpl::name(),讓 Person 改成 Handle class 並不會改變它做的事,只會改變做事的方法。
另一個製作 Handle class 的辦法是,令 Person 成為一種特殊的 abstract base class,稱為 interface class。
這種 class 的目的是詳細描述 derived class 的介面,詳見條款 34,所以通常不帶成員變數,也沒有 constructor,只有一個 virtual destructor 及一組 pure virtual function,用來敘述整個介面:
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
...
};
這個 class 的使用者必須以 Person 的 pointer 或 reference 來撰寫應用程式,因為不可能針對「內含 pure virtual function」的 Person class 具現出實體,所以就像 Handle class 的使用者一樣,除非 Interface class 的介面被修改,否則使用者不需重新 compile。
Interface class 的使用者通常會呼叫一個特殊 function來為這種 class 產生新物件,此 function 扮演「真正將被具現化」的那個 derived class 的 constructor 角色,這樣的 function 通常稱為 factory function 或 virtual function,它們傳回指向動態配置所得物件的指標,而該物件支援 interface class 的介面。
這樣的 function 在 interface class 內往往被宣告為 static:
class Person {
public:
static std::tr1::shared_ptr create(const std::string& name,
const Date& birthday, const Address& addr);
...
}
當然,支援 interface class 介面的那個 concrete class 必須被定義出來,而且真正的 constructor 必須被喚起:
class RealPerson : public Person {
public:
RealPerson(const std::string& name, const Date& birthday,
const Address& addr)
: theName(name), theBirthDate(birthday),
theAddress(addr) {
}
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
如此,就可以寫出 Person::create:
std::tr1::shared_ptr Person::create(nst std::string& name,
const Date& birthday, const Address& addr) {
return std::tr1::shared_ptr(
new RealPerson(name, birthday, addr));
}
RealPerson 示範實作 interface class 的兩個最常見機制之一:
從 Interface class 繼承介面規格,然後實作出介面函蓋的 function。
另一個實作法涉及多重繼承,在條款 40 中詳述。
Handle class 和 interface class 解除了介面和實作之間的耦合關係,進而降低檔案間的 compilation dependency。
在 Handle class 身上,除了成員函式必須通過 implementation pointer 取得物件資料,還要蒙受由動態記憶體配置及釋放 implementation object 的額外成面。
在 Interface class,由於每個 function 都是 virtual,所以每次 function 呼叫都要付出一個 indirect jump 成本(見條款 7),此外 Interface class 衍生的物件必須內結一個 vptr(見條款7),這會增加所需的記憶體數量。
對於 Handle class 及 Interface class ,應該考慮以漸進方式使用這些技術,以求在程式發展過程中,若實作碼有所變化,對使用者帶來最小衝擊。
支持「compilation dependency 最小化」的一般構想是:相依於宣告式,不要相依於定義式。
奠基於上述構想的兩個手段是 Handle class 及 Interface class。
程式庫 header file 應該以「完全且僅有宣告式」(full and declaration-only forms) 的型式存在。