在學習算法導論的過程中,我深深地震撼於自己筆下C++代碼的丑陋。於是我決定捧起這本《Effective C++》。本來打算看完這本書,寫一篇完整的筆記博文,但是剛剛看到一半,我已經躍躍欲試地想動手改善我的代碼了。所以,我將寫完的這部分筆記整理成單獨的一篇博文。
1. 視C++為一個語言聯盟。
- C++ 包括 C & OO C++ & Template C++ & STL
2. 使用 const,enum,inline 代替#define。
3. 盡可能使用 const
- const 修飾指針的不同含義
char* const p1 = "hello"; // 固定指針:不能使p2指向其他對象 const char* p2 = "hello"; // 固定數據:不能修改p2指向的對象
- const 修飾函數時的不同含義
class Text { public: const std::size_t length() const; // 返回文本的長度 // 第一個 const 表示 函數返回一個常量,不可作為左值使用 // 第二個 const 表示 函數不修改 Text 類中的數據成員(除了 static 和 mutable 修飾過的) private: char* data; };
當類的實例被聲明為 const 時,只能調用被第二個 const 修飾過的函數。
4. 保證使用對象前進行初始化
- 內置數據類型不進行初始化,所有對象類型都有默認初始化函數。
- 在構造函數中對類成員賦值並不是真正意義的初始化,進入構造函數體時,對象成員都已經調用過默認初始化函數了。應當使用初始值列進行初始化。
class Person { public: Person(): name(), // 調用 string 類默認構造函數 sex_isMale(true) // 內置類型,必須初始化 {}; Person(const std::string& tname, const bool& isMale): name(tname), // 調用 string 類的復制構造函數 sex_isMale(isMale) {}; private: std::string name; bool sex_isMale; };
- 不同編譯單元的 non-local static 對象(如全局對象)的初始化順序不可控,將對象放在一個全局函數中,並將對象其聲明為靜態成員。第一次調用該函數時必定會初始化該對象。
5. 了解C++默默做的事
- 如果沒有聲明任何構造函數,則編譯器自動為類實現默認構造函數。
- 如果你沒有實現,編譯器會自動為類實現復制構造函數,復制運算符(operator=)函數,析構函數。
- 如果類中包含引用類型的成員 或 const 成員,則編譯器不會實現復制運算符函數。因為更改 引用 或 const 成員是不允許的。
6. 如果不想使用編譯器自動生成函數,就該明確拒絕
- 將不想使用(如果你不聲明,編譯器就會自動生成)的函數 聲明 為 private,並且不實現它(防止友元類調用)。
- 聲明基類,並在基類中將不想使用的函數聲明為 private,且不實現。繼承基類的派生類,編譯器不會自動生成相應函數。
class Uncopyble { protected: Uncopyble(){} ~Uncopyble(){} private: // 聲明但不實現復制構造函數,其派生類無法調用基類的復制構造函數(由於private) // 因此編譯器無法自動生成派生類的復制構造函數(默認的邏輯上,該函數應當調用基類的復制構造函數) Uncopyble(const Uncopyble&); // 復制操作符函數同理 Uncopyble& operator=(const Uncopyble&); };
7. 為多態基類聲明virtual析構函數
- 如果基類的析構函數不是虛函數,那么通過基類指針引用的派生類對象,在其銷毀時,只能銷毀積累部分,而不能銷毀派生類部分。
8. 不讓異常逃離析構函數
- 析構函數往往並不由類的使用者親自調用,因此在析構函數中拋出的異常難以捕捉。
- 如果在對象的銷毀階段確實可能拋出異常(比如,由於網絡原因,關閉遠程數據庫失敗),應該另外實現一個使用者親自調用的銷毀函數如close(),在該函數中拋出異常,以此給用戶以機會處理異常。在析構函數中,檢查用戶是否調用了銷毀函數:如果用戶已經調用過,則不再調用該函數;如果用戶未曾調用過,則調用該函數,在出現異常的情況下,並吞下異常或直接停止程序(用戶沒有立場抱怨,因為我們已經給了他機會)。
9. 不在構造函數或析構函數中調用virtual函數
- 派生類初始化時,先對基類部分初始化,然后才是派生部分。基類的構造函數運行時,派生類還不存在,此時調用虛函數並試圖完成派生類中相應地邏輯:如果該虛函數有實現,就僅僅調用虛函數而不是派生類中的函數;如果沒有定義虛函數,會出現連接錯誤。
- 析構函數同理。
10. 令 operator= 返回一個對 this 的引用
- 這樣就可以使用連等式來賦值了。
11. 在 operator= 中處理自我賦值
- 在 operator= 中需要考慮參數就是自身的情況,也要注意語句順序,以保證“異常安全性”。
12. 復制對象時不要忘了對象的每一個部分
- 如果自己實現復制構造函數和復制運算符函數(而不使用編譯器自動生成的版本),一定要記得將所有的成員都復制過來,編譯器不會因為這是個復制構造函數或operater=而幫你檢查。
- 如果你在派生類中自己實現以上兩種函數,一定要記得顯式地調用基類的相應函數,編譯器不會幫你調用。
class Person{ public: Person(){} Person(const std::string& tname):name(tname){} private: std::string name; }; class Citizen:public Person{ public: Citizen():Person(),married(false){} Citizen(Citizen& pcitizen): Person(pcitizen), // 顯式調用基類的復制構造函數, // 注意傳入的是pcitizen而不是pcitizen.name, // 因為調用的是基類的復制構造函數而不是構造函數, // 而且基類的private也不允許你這樣做 married(pcitizen.married){} // 派生類部分的初始化 private: bool married; };
13. 以對象管理資源
- 所謂資源,往往是由 new 運算符產生的,由指針控制和管理的對象和數組。它們通常分配在堆(而不是棧)上,所以程序流程發生變化時,這些對象和數組不能自動銷毀(而分配在棧上的對象是可以的),需要手動銷毀。
- RAII:對象的取得時機就是最好的初始化時機,兩種常用的RAII對象(智能指針):std::auto_ptr<T>和std::tr1::shared_ptr<T>,前者的復制方案為“轉讓所有權”,后者的復制方案為“計數器”。
- 一個RAII對象示例
class FontHandle; class Font{ public: Font(FontHandle* ft): f(ft){} ~Font(){delete f;}
... private: FontHandle* f; };Font類的實例並不分配在堆上,但其指針成員 f 指向的對象 *f 分配在堆上。當流程變化時,Font 實例被正常銷毀,其析構函數被調用,析構函數中將指針成員指向的對象銷毀。這就保證了 *f 沒有泄露。
14. 在資源管理器中小心 copying 行為
- 資源管理器的資源:即指針指向的對象,由資源管理器維護。當自己實現智能指針對象時,考慮一下四種 copying 行為。
- 禁止復制
- 引用計數(如shared_ptr,需用到類的靜態成員)
- 深度復制
- 轉讓所有權(如auto_ptr)
- 考慮着四種 copying 行為的目的就是,避免在析構函數中多次試圖銷毀指針所指對象,或者完全不銷毀。
15. 在資源管理器中提供對原始資源的訪問
- 往往對 RAII 對象實現 operator-> 和 operator* 以實現對資源對象內部成員的訪問。
- 實現顯式轉換函數,如 Font.get() 返回資源對象。
- 實現隱式轉換函數,如 Font::operator FontHandle() 返回資源對象。此時,Font 對象 可 隱式轉換為 FontHandle 對象,但也會帶來部分風險。
class Font{ public: Font(FontHandle* ft): f(ft){} ~Font(){delete f;} operator FontHandle(){return *f;} FontHandle get(){return *f;} ... private: FontHandle* f; };
16. 成對使用 new 與 delete 時采取相同的形式
- 事實上,編譯器中實現了兩種指針,指向單個變量/對象 的 和指向變量/對象 數組的。使用 new 和 delete 時應當采取對應的形式。
std::string* s1 = new std::string("hello"); std::string* s2 = new std::string[100]; ... delete s1; delete [] s2;
17. 以獨立語句將 newed 對象置入智能指針中
- 考慮這樣做:
Font f1(new FontHandle);獨立語句的含義是:不將該語句拆開,也不將其合並到其他語句中,這樣可以確保資源不被泄露,如:
// 不將其拆開 FontHandle* fh1 = new FontHandle; ... // 發生異常怎么辦? Font f1(fh1); // 不將其合並 AnotherFunction(Font(new FontHandle), otherParameters /*發生異常怎么辦?*/);
18. 讓接口易於使用,難於誤用
- 讓接口易於使用,一般來說,就是盡量保持與內置類型(甚至STL)同樣的行為。比如,你應當為 operator+ 函數返回 const 值,以免用戶對計算結果進行賦值操作,內置類型不允許(對 int 型變量,語句 a+b=c 不能通過編譯,所以你的類型也應該盡量保持同樣的性質,除非你有更好的理由);又比如,對象的主要組成部分如果是一個數組,那么數組的長度的成員名最好使用 size 而不是 length,因為 STL 也這么做了。
- 讓借口難於誤用,包括在類中限制成員的值(比如 Month 類型不可能表示 13 月),限制類型上的操作,在工廠函數中返回智能指針。
19. 設計 class 猶如 設計 type
20. 用 pass-by-reference-const 替換 pass-by-value
- 為函數傳遞參數時,使用使用 const 引用傳遞變量。在定義函數時:
class Person{...}; class Citizen:public Person{...}; bool validatePerson(Person psn){...} // 值傳遞,盡量不要這樣做 bool validatePerson(const Person& psn){...} // const引用傳遞
- 使用const引用類型傳遞函數參數的好處在於:
- 免去不必要的構造開銷:如果使用值傳遞,實參到形參的過程調用了類型的復制構造函數,而引用不會。
- 避免不必要的割裂對象:如果函數的參數類型是基類,而函數中又調用了派生類中的某種邏輯(即調用了基類中的虛函數),那么值傳遞的后果就是,形參僅僅是個基類對象,虛函數也僅僅就調用了虛函數自己(而不是派生類中的函數)。
- 對於C++內置類型和STL迭代器,使用值傳遞,以保持一致性。
21. 必須返回對象時,不要試圖返回 reference
- 考慮一個有理數類:
class Rational{ public: Rational(int numerator=0, int denominator=1):n(numerator),d(denominator){} private: int n, d; };
-
任何有理數可用分數表示, n 和 d 分別為分子和分母,他們都是 int 型的。現在考慮為該類實現乘法,我們希望它能像內置類型一樣工作。
Rational x = 2; Rational y(1,3); Rational z = x*y*y; // z等於2/9
- 我們可能會令函數返回引用類型(尤其是意識到20條中關於值傳遞的種種劣跡后):
class Rational{ ... private: // 錯誤的代碼 friend const &Rational operator* (const Rational& lhs, const Rational& rhs){ Rational result(lhs.n*rhs.n, lhs.d*lhs.d); return result; } ... };
result對象在 operator* 函數結束后就銷毀了,但我們返回了它的引用!這個引用指向 result 對象原先的位置(編譯器往往用指針實現引用),而且該位置在棧上!不僅無效,而且危險。
- 我們也可能用new運算符建立一個新的對象(以防止在函數結束后被銷毀),並返回該對象的引用:
// 錯誤的代碼 friend const &Rational operator* (const Rational& lhs, const Rational& rhs){ Rational* result = new Rational(lhs.n*rhs.n, lhs.d*lhs.d); return *result; }
這次,*result 對象不會因為函數結束而銷毀了,它分配在堆上。但問題是,誰來負責銷毀它?尤其是上文 z=x*y*y 中,由 y*y 計算而得到的臨時變量,幾乎不可能正常銷毀。
- 正確的做法是:
// 正確的代碼 friend const Rational operator* (const Rational& lhs, const Rational& rhs){ return Rational(lhs.n*rhs.n, lhs.d*lhs.d); }
雖然產生了構造消耗,但這是值得的。返回的對象 z 分配在棧上,也就是說會在適當的時候銷毀,而原先函數中的臨時變量也正常銷毀了。
22. 將成員變量聲明為 private
23. 以 non-member 和 non-friend 函數替換 non-member 函數
- 類的 public 方法越多,其封裝性就越差,內部實現彈性就越小。設計類的時候應由其細心。對於一些便利函數(這些函數往往只調用函數的其他 public 方法),可考慮將其放置在類外。C++允許函數單獨出現在類外,即使在C#等語言中,也可以使其出現在 工具 對象中。
- 將類外的函數與類聲明在同一個命名空間中是不錯的選擇。
24. 如果函數的所有參數都需要類型轉換,采用 non-member 函數
- 第21條中的代碼已經體現出這一條的意思了。這一條大致就是希望 Rational 對象能像其他內置對象一樣,直接參與運算。比如,希望這樣:
Rational x(2,5); Rational y = x*2; Rational z = 2*x;
- 首先,Rational 構造函數沒有使用 explicit 修飾,這意味着 x*2 可以正常計算,因為這會調用 x.operator*(Rational& a),而整數 2 會隱式轉換成 Rational 對象。(等等,在第21條中我們好像沒有定義,x.operator*(Rational& a)函數?對,這是因為其中的代碼已經遵循了本條的忠告,定義了 non-member 函數。)
- 如果在 Rational 中定義了x.operator*(Rational& a),那么計算 z 時會遇到困難,因為系統會試圖調用 Int32.operator*(Rational& a),這根本沒有定義。所以,我們在代碼中並沒有定義成員函數,而是定義了友元函數 Rational operator*(Rational& a, Rational& b),正如在第21條的代碼中顯示的那樣。
25. 考慮寫一個不拋出異常的 swap 函數
- std::swap 函數采取復制構造的方法,效率比較低。
namespace std{ template <typename T> void swap(T& a, T& b){ T temp(a); a = b; b = a; } }
-
為自己的類實現 swap 方法並 特化 std::swap
class Person{ private: void* photo; }; namespace std{ template <> // 特化std::swap方法 void swap<Person>(Person& a, Person& b){ std::swap(a.photo, b.photo); } }
當自己的類較大時,可在類中定義swap方法,並在 std::swap<YourClass> 中調用該方法。
26. 盡量延后變量定義式的時間
- 僅當變量第一次具有“具有明顯意義的初值”時,才定義變量,以避免不必要的構造開銷。避免這樣做:
std::string s; // 調用默認構造函數 ... // 如果發生異常呢,如果含有某個return語句呢?第一次調用構造函數的開銷被浪費了 s = "Hello"; // 再一次調用構造函數,第一次調用構造函數的開銷依然被浪費了
應當這樣做:
std::string s("Hello"); // “hello”是具有明顯意義的初值,只調用了一次構造函數
27. 盡量少做轉型動作
- 四種轉型動作
- const_cast:消除對象的常量性
- dynamic_cast:動態轉換,開銷較大。使用的場合往往是:想要在派生類上執行派生類的某個函數,但是手頭上只有基類的指針指向該對象。
- reinterpret_cast:依賴於編譯器的低級轉型
- static_cast:強迫隱式轉換,類似於C風格的轉換,例如將int轉換為double等
- 不要試圖在派生類的成員函數中,通過dynamic_cast將(*this)轉換為基類對象,並調用基類成員函數。
class Person{ public: void showMessage(){} }; class Citizen:public Person{ public: void showMessage(){ dynamic_cast<Person>(*this).showMessage(); // 錯誤,這樣轉型得到的並不是期望的“基類對象 } };
而應當這樣做:
Person::showMessage(); // 這就對了
28. 避免返回 handles 指向對象內部部分
- handle 包括指針,引用,迭代器,用來獲取某個對象,以前被翻譯成句柄。
- 在函數的方法中返回對象內部成員的 handle 可能遭致這樣的風險:返回的 handle 比對象本身更長壽,當對象銷毀后,handle 所指向的區域就是不確定的。
- string 和 vector 類型的 operator[] 就返回了對象內部成員的 handle ,這只是例外。
29. 為“異常安全”而作的努力是值得的
- 函數異常安全類型:
- 基本承諾:如果異常拋出,程序內的所有元素仍然在有效狀態下,沒有任何元素受到損壞(如釋放了指針指向資源卻沒有為其指定新的資源,該指針通向了不確定性)。
- 強烈保障:如果異常拋出,程序內的所有元素保持函數調用前的狀態。
- 不throw異常:承諾絕不拋出異常。
- 一個函數異常安全的程度取決於所調用函數中異常安全程度最弱的。
- copy & swap 策略:為對象的數據制造一份副本,並對副本進行修改。如果發生異常,拋棄副本並返回;如果成功,則將對象數據與副本數據做 swap 操作,swap 操作承諾絕不拋出異常。
