Effective C++ 改善程式與設計的55個做法,總結筆記(上)
前言
最近在看《Effective C++》這本書,這部落格相當於是個濃縮版的總結吧。 在這裡你可以大致遊覽下在 C++ 開發中前人給了我們哪些建議,有機會我覺得最好還是可以看看原書,因為裡面會有不少具體的例子告訴你為什麼這麼做以及這麼做的好處。
一、讓自己習慣 C++
1. 視 C++ 為一個語言聯邦
我們可以視 C++ 為一個由相關語言組成的聯邦而非單一語言,例如:
- C:包括區塊 blocks,語句 statements,預處理 preprocessor,內建資料型別 build-in data types,陣列 arrays,指標 pointers 等。
- C++:包括類 classes,封裝 encapsulation,繼承 inheritance,多型 polymorphism,virtual 函式等。
- Template C++:泛型程式設計 generic programming。
- STL:標準模板庫 standard template library。
2. 儘量使用 const
,enum
,inline
替換 #define
- 對於常量,最好使用 const 或者 enum。
- 對於函式巨集,最好使用 inline 函式。
3. 儘可能使用 const
- 將某些東西宣告為 const 可幫助編譯器偵測出一些錯誤用法。
- 注意 const 和 non-const 是可以發生 過載 的,如果你要這麼做,那麼令 non-const 版本呼叫 const 版本可避免程式碼重複,如:
class TextBlock {
public:
const char& operator[] (std::size_t position) const {
// do something
return text[position];
}
char& operator[] (std::size_t position) {
// 呼叫 const 版本的過載函式,避免程式碼重複
return const_cast<char&>(static_cast<const TextBlock& >(*this)[position]);
}
private:
std::string text;
};
4. 確定物件在使用前初始化
其中建構函式最好使用使用成員初值列,而不是在建構函式中使用賦值操作。如:
Person::Person(const std::string& name, const int age)
:mName(name), mAge(age) // 成員初始列
{
// mName = name; // 賦值操作
// mAge = age; // 賦值操作
}
二、構造、析構、賦值運算
5. 瞭解 C++ 默默編寫和呼叫的函式
編譯器會自動給類建立 default 建構函式、copy 建構函式、賦值操作符(operator=)、解構函式。
class Empty {}; // 等同於下面寫法
class Empty {
public:
Empty() {} // default建構函式
Empty(const Empty& rhs) {} // copy建構函式
~Empty() {} // 解構函式
Empty& operator=(const Empty& rhs) {} // copy assignment 操作符
};
6. 若不想編譯器自動生成上述函式,就要明確拒絕
將相應的成員函式宣告為 private
,並不予實現即可。
7. 為多型基類宣告 virtual 解構函式
如果一個類帶有任何 virtual 函式,它都應該擁有一個 virtual 解構函式。
8. 別讓異常逃離解構函式
解構函式不要丟擲異常。如果解構函式中呼叫的函式可能丟擲異常,那麼也要捕捉併吞掉這個異常或結束程式。
9. 絕不在建構函式或解構函式中呼叫 virtual 函式
在父類構建的過程,virtual 函式還沒有下降到子類中去。
10. 賦值操作符都應返回一個 *this
的引用
operator=
,operator+=
,operator-=
等等賦值運算子,都返回一個 reference to *this。如:
Widget& operator=(const Widget& rhs) {
// do something
return *this;
}
11. 在 operator=
中處理 “自我賦值”
- 確保任一函式如果操作多個物件時,其中多個物件是同一物件時,其行為仍然正確。
- 確保當物件自我賦值時有良好的行為。例如:
Widget& operator=(const Widget& rhs) {
if (this == &rhs) {
return *this; // 如果是自我賦值,就不做任何事
}
// do something
return *this;
}
12. 複製物件時勿忘其每一個成分
- 複製函式應該確保複製 “物件內的所有成員變數” 以及 “呼叫基類適當的複製函式” 完成完整的複製。
- 不要嘗試在賦值運算子(operator=)中呼叫複製建構函式,亦或是在複製建構函式中呼叫賦值運算子。如果你的複製建構函式和賦值運算子程式碼基本一樣,消除重複程式碼的做法是寫一個 private 的 init() 方法供兩者呼叫。
三、資源管理
13. 以物件管理資源
- 把資源放進物件內,我們便可依賴 解構函式 自動呼叫的機制確保資源被釋放。
- 使用智慧指標(如
auto_ptr
和shared_ptr
)來管理資源類,避免你忘記 delete 資源類。
14. 在資源管理類中小心 copy 行為
- 複製資源類時必須一併複製它所管理的資源,資源的 copy 行為決定資源類的 copy 行為。
- 常見的 copy 行為有:禁止複製、使用引用計數法、進行深拷貝、轉移資源的擁有權。
15. 在資源管理類中提供對原始資源的訪問
例如提供一個 get()
方法獲取原始資源。
16. 成對使用 new
和 delete
時要採取相同形式
- 在 new 表示式中使用 [],就必須在 delete 的時候也使用 []。
- 在 new 表示式中不使用 [],也一定不要在 delete 的時候使用 []。
17. 以獨立的語句將 new 的物件置入智慧指標中
避免發生異常時,導致難以察覺的資源洩漏產生。如:
std::tr1::shared_ptr<Widget> pWidget(new Widget);
processWidget(pWidget);
// 不建議下面這樣做,如果 processWidget 發生異常可能造成洩漏
// processWidget(std::tr1::shared_ptr<Widget> (new Widget));
四、設計與宣告
18. 讓介面容易被正確使用,不易被誤用
- 保持 “一致性” 使得介面容易被正確使用。例如 STL 容器的介面就十分一致,都有一個名為
size
的成員函式返回目前容器的大小。 - “阻止誤用” 的方法包括建立新型別、限制類型上的操作、束縛物件值,以及消除客戶的資源管理責任。
19. 設計 class 猶如設計 type
- 新 type 的物件應該如何被建立和銷燬?
- 初始化和賦值該有什麼樣的差別?
- 新 type 如果被 passed by value(值傳遞)意味著什麼?即複製構造方法該怎麼實現。
- 新 type 存在哪些繼承關係?
- 新 type 需要什麼樣的轉換?即將其它型別轉換為 type 型別的行為。
- 函式或成員的訪問許可權?public、protected、private。
- 新 type 有多麼一般化?真的需要一個新 type 嗎?
20. 寧以 pass-by-reference-to-const 替換 pass-by-value
- 儘量使用 “引用傳遞” 引數而不是 “值傳遞” 引數。前者更加高效且可避免切割問題。
- 對於 int 等內建型別,以及 STL 的迭代器和函式物件,使用 “值傳遞” 更好。
21. 必須返回新物件時,別妄想返回其引用
當一個 “必須返回新物件” 的函式,我們就直接返回一個新物件,而不要返回引用或者指標。
22. 將成員變數宣告為 private
實現資料的封裝性。且可以更方便的控制資料的訪問。
23. 寧以 non-member、non-friend 替換 member 函式
- 這樣做可以增加封裝性、包裹彈性和機能擴充性。
- 非成員函式不會增加 “能訪問 class 內私有變數” 的函式數量,具有更大的封裝性。
24. 如果所有引數都需要型別轉換,請採用 non-member 函式
如果一個有理數的類 Rational,我們常常喜歡直接使用 int 型別的數和 Rational 物件進行混合運算。那麼使用 non-member 函式將是更好的選擇,它允許每一個引數都進行隱式型別轉換。
class Rational {
public:
Rational(int numerator = 0, int denominator = 1)
: mNumerator(numerator), mDenominator(denominator) {}
int numerator() const { return mNumerator; }
int denominator() const { return mDenominator; }
private:
int mNumerator; // 分子
int mDenominator; // 分母
};
const Rational operator*(const Rational& lhs, const Rational& rhs) {
return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}
int main() {
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2;
result = 2 * oneFourth; // 如果不是 non-member 的形式將不支援這種寫法
}
25. 考慮寫出一個不拋異常的 swap 函式
當系統的 std::swap
對你的型別效率不高時,提供一個 swap 成員函式,並確保這函式不會丟擲異常。
如果提供一個 member swap,也該提供一個 non-member swap 來呼叫前者。例如:
class WidgetImpl {
public:
// ...
private:
int a, b, c; // 可能有很多資料
std::vector<double> v; // 意味著複製時間很長
// ...
};
// 這個 class 使用 pimpl 手法
// pimpl 是 pointer to implementation 的縮寫
class Widget {
public:
Widget(const Widget& rhs) {}
Widget& operator=(const Widget& rhs) {
// ...
*pImpl = *(rhs.pImpl);
// ...
}
// 我們需要交換兩個 Widget 物件,但是直接使用 std::swap 將導致很多多餘的複製
// 好的做法是隻交換兩個 WidgetImpl* 指標物件即可,所以我們考慮寫一個 swap 方法
void swap(Widget& other) {
using std::swap; // 這個宣告至關重要,使得C++編譯器能夠找到正確的函式呼叫
swap(pImpl, other.pImpl); // 我們只需要交換 pImpl 即可
}
private:
WidgetImpl* pImpl;
};
// 提供一個 non-member swap 來呼叫 member swap
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) {
a.swap(b);
}
}
五、實現
26. 儘可能延後變數定義式的出現時間
將一些變數定義式放在常規的引數檢查後面,避免無用的構造方法帶來的耗費。有助於增加程式清晰度和改善程式效率。
27. 儘量少做轉型
- 如果可以,儘量避免轉型,特別是注重效率的程式碼中避免使用效率低的
dynamic_cast
。 - 如果轉型是必要的,儘量將其隱藏在函式背後。客戶呼叫該函式而不需將轉型放在自己程式碼內。
- 儘可能使用 C++ 風格的轉型,而不要使用舊式的 C 風格轉型。
28. 避免返回 handles 指向物件內部成分
- 引用、指標、迭代器 統統都是所謂的 handles。如果我們返回了物件的 handles,意味著物件被銷燬時這個 handles 將會變得空懸,這是比較危險的。
- 我們應該儘量避免這麼做,但有時候你必須這麼做,例如
operator[]
操作允許你獲取個別元素的引用。
29. 為 “異常安全” 而努力是值得的
- 基本承諾:如果異常丟擲,程式仍保持有效狀態,沒有物件和資料結構會因此被破壞。
- 強烈保證:如果函式成功,就是完全成功,如果函式失敗,程式會回覆到呼叫前的狀態。
- 不拋擲保證:承諾絕不丟擲異常,作用於內建型別身上的所有操作都提供 nothrow 保證。
- 異常安全函式即使發生異常也不會洩漏資源或允許資料結構敗壞。這樣的函式區分三種可能的保證:基本承諾、強烈型、不拋異常型。
- 強烈保證往往能夠以 copy-and-swap 實現出來,但它並非對所有函式都具備現實意義。 備註: copy-and-swap 是指拷貝並修改物件資料副本,函式呼叫完後,最後在一個不丟擲異常的步驟裡將修改後的資料和原件替換。
30. 瞭解 inline
裡裡外外
- 將大多數 inlining 限制在小型、頻繁呼叫的函式身上。
- 不要只因為模板方法出現在標頭檔案中就將它們宣告為 inline,除非你認為模板具現化出來的函式都應該被 inline。
31. 將檔案間的編譯依存關係降至最低
- 依賴關係複雜導致的問題就是你修改了某個實現卻需要編譯很多檔案,最好是 介面和實現分離。
- 支援 “編譯依存最小化” 的一般構想是:相依於宣告式,不相依於定義式。基於此構想的兩個手段是 Handle classes 和 Interface classes。
- 程式庫標頭檔案應該以 “完全且僅有宣告式” 的形式存在。