Effective C++讀書筆記~5 實現
條款26:儘可能延後變數定義式的出現時間
Postpone variable definitions as long as possible.
為什麼要延後變數定義?
因為定義一個變數會有構造成本和析構成本,而該變數可能從始至終並未使用過。因此,如果能儘量避免,就能減少這些成本。
如何延後變數定義?
1)不要過早定義變數,即將使用的時候定義
// 過早定義變數encrypted string encrypPassword(const string& password) { using namespace std; string encrypted; // 提前定義變數 encrypted, 儲存加密後的密碼 if (password.length() < MinimumPasswordLength) { // 如果丟擲異常, 就不會使用變數encrypted throw logic_error("Password is too short"); } ... return encrypted; } ==> 改進 // 延後定義變數encrypted string encrypPassword(const string& password) { using namespace std; if (password.length() < MinimumPasswordLength) { // 可能丟擲異常 throw logic_error("Password is too short"); } string encrypted; // 延後定義變數 encrypted, 儲存加密後的密碼 ... return encrypted; }
2)定義物件時,提供構造引數
如果不提供構造引數,意味著呼叫default建構函式。條款4解釋了“通過default建構函式構造物件,然後賦值”的效率比“直接在構造時指定初值”更低。
void encrpyt(string& s) // 對s加密 { ... } // default構造物件 string encryptPassword(const string& password) { ... string encrypted; // 已延後定義encrypted. default建構函式 構造encrypted encrypted = password; // 賦值給encrypted encrpyt(encrypted); return encrypted; } ==> 改進 // 帶引數構造物件 string encryptPassword(const string& password) { ... string encrypted(password); // 已延後定義encrypted. 引數構造encrypted (copy 構造) encrpyt(encrypted); return encrypted; }
3)迴圈體內 or 外定義變數?
對於迴圈,變數是定義在迴圈體外(方法A),還是迴圈體內(方法B)?
先看下面的例子,
// 方法A:定義於迴圈外
Widget w;
for (int i = 0; i < n; ++i) {
w = 取決於i的某個值;
...
}
// 方法B:定義於迴圈體內
for (int i = 0; i < n; ++i) {
Widget w(取決於i的某個值);
...
}
Widget函式內部定義變數的開銷:
方法A:1個建構函式 + 1個解構函式 + n個賦值操作;
方法B:n個建構函式 + n個解構函式;
當class的賦值成本明顯低於構造 + 析構成本時,特別n較大時,A方法較好;
否則,B方法較好,因為B的程式更容易理解。
也就是說,當1)明確知道賦值成本比“構造+析構”成本低,2)正在處理程式碼的效率高度敏感時,選擇方法A;否則選擇方法B。
小結
1)儘可能延後變了定義式的出現,因為可以增加程式的清晰度並改善程式效率。
[======]
條款27:儘量少做轉型動作
Minimize casting.
為什麼要減少轉型動作?
轉型(casts)破壞了型別系統(type system),可能導致任何種類的麻煩,難以辨識。
轉型語法
舊式轉型:
1)C風格轉型
(T)expression // 將expression轉型為T
2)函式風格轉型
T(expression) // 將expression轉型為T
C++新式轉型(稱為new-style或C++-style casts):
4種
const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)
各個轉型目的:
1)const_cast 通常被用來將物件的常量性移除(cast away the constness)。唯一有此能力的C++-style轉型操作符。
non-const member函式呼叫const member時,const_cast可用於去掉常量性。
2)dynamic_cast 主要用來執行“安全向下轉型”(safe downcasting),也就是用來決定某物件是否歸屬繼承體系中的某個型別。唯一無法由舊式語法執行的動作,也是唯一可能耗費重大執行成本的轉型動作。
一般情況下,不允許基類指標轉型為派生類指標或引用,因為編譯器並不知道執行時指標所指向的實際物件,是否為派生類物件。而dynamic_cast允許基類指標在執行時安全地轉型為派生類指標。
3)reinterpret_cast 意圖執行低階轉型,實際動作(及結果)可能取決於編譯器,意味著它不可移植。
慎用。
int *ip;
char *pc = reinterpret_cast<char *>(ip); // pointer from int* to char*
string str(pc); // 可能導致程式異常
4)static_cast 用來強迫隱式轉換,例如將non-const物件轉為const物件(條款3),或將int轉為double等。可以完成1)~3)的多種反向轉換。但無法將const轉為non-const(去掉常量性) -- 因為這是隻有const_cast才辦得到。
舊式轉型與新式轉型的選擇
舊式轉型仍然合法,但新式轉型更受歡迎。原因在於:
1)容易在程式碼中被辨識(人眼或工具如grep);
2)各轉型動作目標越窄化,編譯器越可能診斷出錯誤的應用;
除了explicit建構函式轉型,其他情況推薦使用新式轉型。
去掉轉型動作
有時,容易寫出某些似是而非的程式碼,在其他語言可能正確,但在C++則不正確。
1)去掉static_cast案例
class Windows { // base class
public:
virtual void onResize() {...} // base onResize實現
...
};
class SpecialWindow: public Window { // derived class
public:
virtual void onResize() { // derived onResize實現
static_cast<Window>(*this).onResize(); // 錯誤: 將*this轉型為Window(副本), 然後呼叫其onResize
... // SpecialWindow專屬行為, 作用於*this物件
}
};
乍一看,並沒有什麼問題。然而,仔細分析derived class的onResize中轉型行為,存在很大問題。"static_cast
解決辦法:去掉轉型動作,代之以你真正想說的話。
不要哄騙編譯器將*this視為一個base class物件,只是想呼叫base class版本的onResize函式,令其作用於當前物件身上。可以這麼寫:
class SpecialWindow: public Window {
public:
virtual void onResize() {
Window::onResize(); // 呼叫Window::onResize作用於*this身上
...
}
};
2)去掉dynamic_cast案例
dynamic_cast的需要實現版本執行速度相當慢,儘量減少使用次數。
為什麼需要dynamic_cast?
通常是因為你想在一個你認定為derived class物件身上執行derived class操作函式,但你手上只有一個“指向base”的pointer或reference,你只能靠它們來處理物件。有2個一般性做法可以避免該問題:
(1)使用容器,並在其中儲存直接指向derived class物件的指標(通常是智慧指標,條款13),以便消除“通過base介面處理物件”的需要。
假設之前的Windows/SpecialWindow繼承體系的例子中,只有SpecialWindow才支援閃爍效果。
// 使用dynamic_cast效率低下做法
class Window { ... };
class SpecialWindow: public Window {
public:
void blink();
};
typedef vector<shared_ptr<Window>> VPW; // vector存放Window物件的智慧指標
VPW winPtrs;
... // 往vector裝資料等操作
for (VPW::iterator iter = winPtrs.begin(); iter != winPtr.end(); ++iter) {
if (SpecialWindow *psw = dynamic_cast<SpecialWindow*>(iter->get()) // 使用了dynamic_cast
psw->blink();
}
==> 應該改成
// 去掉了dynamic_cast轉型, 更高效
typedef vector<shared_ptr<SpecialWindow>> VPW; // vector存放SpecialWindow物件的智慧指標
VPSW winPtrs;
... // 往vector裝資料等操作
for(VPSW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter) // 不使用了dynamic_cast
(*iter)->blink();
這種改動方式使用簡單,而且安全,但有一個缺點:無法在同一容器內 儲存指標以指向所有可能的各種Window派生類。如果要處理多種視窗型別(Window派生類),可能需要多個容器,並且它們都必須具備型別安全性(type-safe)。
(2)在base class內提供virtual函式,做想對各個Window派生類做的事情。因為指標指向的物件會在執行時呼叫virtual函式,以實現各自想做的事情。
// 為base class新增virtual函式
class Window {
public:
virtual void blink() { } // 預設程式碼“什麼都沒做”,可能是個糟糕的主意, 見條款34. 這裡是為了演示
...
};
class SpecialWindow: public Window {
public:
virtual void blink() { ... } // 在該class內, blink做符合當前class應該做的事情
};
typedef vector<shared_ptr<Window>> VPW; // vector元素還是使用指向base class Window物件的智慧指標
VPW winPtrs;
...// 往vector裝資料等操作
for (VPW::iterator iter = winPtrs.begin(); iter != winPtrs.end(); ++iter)
(*iter)->blink(); // 這裡沒有dynamic_cast, 執行時會自動呼叫derived class對應virtual函式
小結
1)如果可以,儘量避免轉型,特別是注重效率的程式碼中,應極力避免dynamic_casts。如果有個設計需要轉型動作,嘗試發展無需轉型的替代設計。
2)如果轉型是必要的,試著將它隱藏在某個函式背後。客戶呼叫該函式即可,而不需要將轉型放進他們自己程式碼中。(隔離轉型動作)
3)寧可使用C++-style(新式)轉型,不要使用舊式轉型。
[======]
條款28:避免返回handles指向物件內部成分
Avoid returning "handles" to object internals.
為什麼要避免返回handles指向物件內部成分?
先來看一個例子。假設程式涉及矩形,每個矩形(Rectangle)由左上角、右下角表示。為了讓Rectangle物件儘可能小,可能會決定不把定義這些矩形的點存放在Rectangle物件內,而是放在一個輔助的struct內,讓再Rectangle去指它:
class Point {
public:
Point(int x, int y);
...
void setX(int newValue);
void setY(int newValue);
...
};
struct RectData { // 點資料用來表現一個矩形
Point ulhc; // upper left-hand corner 左上角
Point urhc; // lower right-hand corner 右下角
};
class Rectangle {
...
private:
shared_ptr<RectData> pData; // 智慧指標指向RectData物件
};
返回reference指向物件內部成分的缺陷
Rectangle的客戶需要計算Rectangle的(四個頂點)範圍,所以該class提供upperLeft函式、lowerRight函式。Point是使用者自定義型別,
// 可以通過編譯, 但設計是錯誤的
class Rectangle {
public:
...
Point& upperLeft() const { return pData->ulhc; } // 左上頂點, by reference方式返回使用者自定義型別(根據條款20)
Point& lowerRight() const { return pData->urhc; } // 右下頂點, by reference方式返回使用者自定義型別(根據條款20)
...
};
上面的設計可以通過編譯,但卻是錯誤的。因為它是自我矛盾的。一方面,upperLeft, lowerRight宣告為const函式,表明提供Rectangle座標點資訊,而不是讓客戶修改Rectangle (條款3);另一方面,2個函式都返回reference指向了private內部資料,呼叫者可以通過這些reference修改內部private資料。
Point coord1(0, 0);
Point coord2(100, 100);
const Rectangle rec(coord1, coord2); // rec是const 矩形, 左上角(0,0) 右下角(100,100)
rec.upperLeft().setX(50); // rec現在變成了左上角(50,0)右下角(100,100)
// 也就是說, rec的private資料發生了改變, const 矩形不再是不可變的(const).
2個教訓:
1)成員變數的封裝性最多隻等於“返回其reference”的函式的訪問級別,而不再是簡單的宣告變數時的訪問級別。
比如,pData雖然是class Rectangle的private成員變數,但是class也提供了public member返回了其引用(upperLeft, lowerRight),這樣,pData的封裝性不再是private,而是public。
2)如果const成員函式傳出一個reference,後者所指資料與物件自身有關聯,而它又被儲存於物件之外,那麼這個函式的呼叫者可以修改那筆資料(reference所指資料)。
比如,const成員函式upperLeft和lowerRight各傳出一個reference,而 reference所指資料是左上、右下頂點,與Rectangle物件自身有關聯,而且儲存在Rectangle物件外的 Point物件,那麼upperLeft和lowerRight的呼叫者可以修改reference所指的資料(Point物件)了。
返回handles與返回references
reference、指標、迭代器統統都是所謂handles(控制代碼,用來取得某個物件),而返回一個“代表物件內部資料”的handle,這樣就會面臨“降低物件封裝性”的風險,同時可能導致“const成員函式卻造成物件狀態被修改”。
通常,物件的“內部”是指它的成員變數,不過,不被公開使用的成員函式(private或protected),也是物件“內部”的一部分。因此,也要留心不要返回它們的handles。如果確實這麼做了,它的實際訪問級別就會提高,因為客戶可以通過指標直接呼叫。
如何解決返回reference,被客戶修改的問題?
可以對返回型別加上const,這樣客戶可以讀取矩形的Points,但不能修改。也就意味著,客戶不能改變物件狀態。
class Rectangle {
public:
...
const Point& upperLeft() const { return pData->ulhc; } // 返回const物件, 客戶無法修改
const Point& lowerRIght() const { return pData->urhc; } // 返回const物件, 客戶無法修改
...
};
上述方案也不是完美的,存在空懸指標的問題。因為pData所指的物件可能會不存在,如果還使用該指標,就會產生“野指標”問題(記憶體已釋放)。
例如,boundingBox函式返回GUI物件的外框(bouding box),
// 外框採用矩形形式
// GUI物件
class GUIObject { ... };
// 以by value方式返回一個矩形
const Rectangle boundingBox(const GUIObject& obj); // 條款3談過為什麼返回型別是const
// 客戶可能這樣使用boundingBox函式
GUIObject* pgo;
... // 設定pgo指向的GUI物件
// 取得一個指標指向外框的左上角頂點
const Point* pUpperLeft = &(boundingBox(*pgo).upperLeft()); // 錯誤: boundingBox(*pgo)返回的是一個臨時物件, 語句結束後釋放物件, pUpperLeft成為空懸指標
boundingBox(*pgo)返回的將是一個Rectangle臨時物件,而在這行語句結束後,就會釋放該臨時物件。而pUpperLeft卻指向了其內部pData所指向的ulhc物件(Point),該物件是會隨臨時物件的析構而釋放的,因此pUpperLeft將成為空懸指標。
雖然member函式封handle可能產生風險,但有時必須這麼做,比如operator[],返回的就是物件的reference,指向內部資料。
小結
1)避免返回handle(包括reference、指標、迭代器)指向物件內部。遵守該條款可以增加封裝性,幫助const member函式的行為像const,並將“空懸指標”的可能性降到最低。
[======]
條款30:透徹瞭解inlining的裡裡外外
Understand the ins and outs of inlining.
inline函式
理念:將函式的每個呼叫,都以函式本體替換之。
inline只是對編譯器的一個申請,不是強制命令。申請分為兩種方式:隱喻提出,明確提出。
- 隱喻提出:將函式定義於class宣告中。
- 明確提出:在函式定義式前,加上關鍵字inline。
大部分編譯器拒絕將太過複雜(如帶有迴圈或遞迴)的函式inlining(內聯),而所有堆virtual函式的呼叫也都會使inlining落空,因為virtual意味著“等待,直到執行期才確定呼叫哪個函式”,而inline意味著“執行前,先將呼叫動作替換為被呼叫函式的本體”。
編譯器通常不對“通過函式指標進行的呼叫 ”實施inlining。
程式庫設計者必須評估“將函式宣告為inline”的衝擊:inline函式無法隨著程式庫升級而升級。也就是說,如果f是程式庫內的inline函式,客戶將函式f編程序序中,一旦程式庫設計者決定改變f,所有用到f的客戶端程式都必須重新編譯。而如果f是non-inline函式,只需要重新連結就好;如果是動態連結,升級後的函式可以直接被使用。
另外一個現實問題,大多數調速器對inline函式束手無策,因為無法為一個並不存在的函式設立斷點。
建議:一開始不要將任何函式宣告為inline,或者實行有限範圍內的函式稱為inline。待到有優化需求時,再改成inline。
小結
1)將大多數inlining限制在小型、被頻繁呼叫的函式身上。可使日後的除錯過程和二進位制升級(binary upgradability)更容易,也可使潛在的程式碼膨脹問題最小化,使程式的速度提升計劃最大化;
2)不要只因為function templates出現在標頭檔案,就將它們宣告為inline;
[======]
條款31:將檔案間的編譯依存關係降到最低
Minimize compilation dependencies between files.
問題引出
考慮C++ class:
// Person定義
/* 標頭檔案 */
#include <string>
#include "date.h"
#include "address.h"
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;
};
Person定義檔案include了其他檔案之間形成了一種編譯依存關係(compliation dependency)。如果這些標頭檔案中有一個被改變,或者這些標頭檔案所依賴的其他標頭檔案有改變,那麼每個include Person class的檔案就得重新編譯,任何使用Person class的檔案也必須重新編譯。這樣一連串的編譯依存關係會對許多專案造成難以形容的災難。
那麼,我們如何解決這個問題呢?
方法一:Handle class
可以把Person分割為2個class:一個只提供介面(Person),另一個負責實現該介面(PersonImpl)。
持有實現介面(PersonImpl物件)指標的類Person,稱為Handle class。
// 介面那部分
#include <string>
#include <memory>
/* 前置宣告, 前提是不需要知道其定義 */
// 宣告式 (Person定義需要用到)
class PersonImpl; // Person實現類前置宣告
class Date; // Person介面用到的class的前置宣告
class Address;
// Person定義(式)
class Person { // Handle class
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const; // string並不是class, 而是basic_string<char>的typedef
std::string birthDate() const;
std::string address() const;
// ...
private:
// pimpl idiom (pointer to implementation)
std::tr1::shared_ptr<PersonImpl> pImpl; // 智慧指標, 指向實現類(PersonImpl)(shared_ptr見條款13)
};
分離的關鍵在於以“宣告的依存性”替換“定義的依存性”,也就是分離 宣告所需要的東西 跟 定義所需要的東西。這也是編譯依存性最小化本質。現實中,讓標頭檔案儘可能自我滿足,萬一做不到,則讓它與其他檔案內的宣告式(class XXX;)而非定義式(class XXX{ };)相依。其他每件事源於這個簡單的設計策略:
-
如果使用object references或object pointers可以完成任務,就不要使用objects
因為如果定義某個型別的object,就要用到該型別的定義式。而reference和pointer只需要該型別的宣告式即可。 -
如果能夠,儘量以class宣告式替換class定義式
也就是說,如果只是宣告,可以不需要class定義式,用class宣告式即可。而定義裡面,能用class宣告式則儘量用。 -
為宣告式和定義式提供不同的標頭檔案
如,宣告式放到Person.h,實現式放到PersonImpl.h。
方法二:Interface class
令Person稱為一種特殊的abstract base class(抽象基類),稱為Interface class。類似於Java裡面的interface,專門提供derived class的介面,因此不帶成員變數,也沒有建構函式,只有一個virtual解構函式 + 一組pure virtual函式(純虛擬函式),用來描述整個介面。不過,C++並不禁止interface內實現成員變數或成員函式。
// interface class
// 沒有建構函式
class Person {
public:
virtual ~Person(); // virtual解構函式
virtual std::string name() const = 0; // pure virtual函式
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
// ...
// factory函式, 建立新物件
static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
};
思考:為什麼要有factory函式?
答:因為Interface class的客戶必須有辦法為這種class建立新物件,他們通常呼叫一個特殊函式,此函式扮演那個derived class的建構函式的角色。客戶持有的,通常是Interface class Person類指標,而非derived class,否則程式的設計不具有複用性。
如果有了factory函式(create),客戶可以這樣使用:
string name;
Date dateOfBirth;
Address address;
...
// 建立一個物件, 支援Person介面
shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
cout << pp->name()
<< " was born on "
<< " and now lives at "
<< pp->address();
...
客戶真正使用的,是實現Interface class介面的那個具象類(concrete class),因此該類必須被定義出來,而且建構函式必須被呼叫。
注意:interface class並沒有建構函式。
具象類實現:
// 實現interface class的具象類
class RealPerson: public Person {
public:
RealPerson(const std::string&name, const Date& birthday, const Address& addr)
: theName(name), theBirthDate(birthday), theAddress(addr)
{ }
virtual ~RealPerson();
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
// 一個具體的factory函式實現
shared_ptr<Person> Person::create(const string&name, const Date& birthday, const Address& addr)
{
return shared_ptr<Person>(new RealPerson(name, birthday, addr));
}
Handle class與interface class
在handle class的方案中,handle class和實現類是依賴關係(use-a):handle class依賴於實現類,持有指向實現類指標;
在interface class的方案中,interface class和實現類是實現關係:實現類實現了interface class的介面。
什麼時候考慮使用handle class和interface class?
當想要實現程式碼變化時,對客戶帶來最小衝擊,就應當考慮使用handle class和interface class解耦宣告與實現,以使編譯依存性最小。
小結
1)支援“編譯依存性最小化”的一般構想:相依於宣告,不要相依於定義式。基於此構想的2個手段是Handle class和interface class。
2)程式庫標頭檔案應該以“完全且僅有宣告式”(full and declaration-only forms)的形式存在。這種做法不論設計template都適用。
[======]