1. 程式人生 > >More Effective C++ 35個做法

More Effective C++ 35個做法

前言

最近在看《More Effective C++》這個書,自己 C++ 基礎還是不行,有的地方看的有點懵,最後還是堅持看完了,做做筆記,簡短的 記錄一下有哪些改善程式設計與設計的有效方法。

推薦還是可以買一本原書的,書中例子比較豐富,更容易理解一些。

一、基礎議題

1. 仔細區別指標(pointer)和引用(reference)

指標可以指向 null,引用不允許指向 null。
指標可以重新被賦值,引用則不行,它總是指向最初獲得的那個物件,所以引用初始化時必須有初值。

2. 最好使用 C++ 轉型操作符

使用新式轉型符比較容易辨析,無論對於人還是工具而言。

  • static_cast 基本擁有和 C 舊式轉型相同的效果和威力,以及相同的限制。
  • const_cast 用於改變表示式中的 常量性(constness)易變性(volatileness)
  • dynamic_cast 用來執行繼承體系中 “安全的向下轉型或跨系轉型動作”。
  • reinterpret_cast 用來將一種型別重新解釋為另一種型別,而不關心它們是否相關。

3. 絕對不要以多型方式處理陣列

子類通常都比父類更大,所以進行指標算術時會發生不可預計的後果,陣列物件幾乎總是會涉及指標的算術。所以陣列和多型不要混用。

4. 非必要不提供預設建構函式(default constructor)

預設建構函式即沒有引數的建構函式。新增無意義的預設建構函式顯得畫蛇添足,也會影響效率。

二、操作符

5. 對定製的 “型別轉換函式” 保持警覺

  1. 隱式型別轉換操作符,是一個擁有奇怪名稱的成員函式:關鍵字 operator 之後加上一個型別名稱。如:
class Rational {
public:
    // ...
    operator double() const; // 將 Rational 轉換為 double
};

然而它們的出現可能導致錯誤(非預期)的函式被呼叫。為了避免發生這種情況,解決辦法是以功能對等的另一個函式取代型別轉換操作符,例如使用一個名為 asDouble

的函式取代它。

  1. 單自變數 constructor 是指能夠以單一自變數成功呼叫的建構函式。我們可以將構造方法宣告為 explicit 避免發生隱式轉換帶來的非預期錯誤。

6. 區分 ++-- 操作符的前置形式和後置形式

過載函式是以其引數型別區分彼此的,然而 ++-- 操作符前置式和後置式都沒有引數。為了填平這個語法上的漏洞,只好讓後置式有一個 int 自變數,並且在在它被呼叫時,編譯器默默地為該 int 指定一個 0 值。

前置遞增運算子通常如下:

Date& operator ++ () {
    // increment
    return *this; // 後取出
}

後置遞增運算子的返回值型別不同,且有一個輸入引數,通常如下:

const Date operator ++ (int) {
    Date copy (*this); // 先取出
    // increment
    return copy; // 返回先前取出的值
}

返回 const 型別是為了禁止 Date++++ 這樣的錯誤操作。

單從效率來看,前置式比後置式要好。

7. 千萬不要過載 &&||, 操作符

對於短路與(&&)、短路非(||)和逗號運算子(,)不管你多麼努力,都無法令其行為像它們應有的那樣,所以千萬不要過載它們。

8. 瞭解不同意義的 new 和 delete

如果希望物件產生於 heap,請使用 new operator。它不但分配記憶體還會呼叫一個建構函式進行初始化。
string *p = new string("Hello");

如果只是打算分配記憶體,請使用 operator new。它不會呼叫任何建構函式,你也可以寫一個自己的 operator new。
void *p = operator new(sizeof(string));

如果你打算在指定的記憶體位置構造物件(需要先分配),請使用 placement new。這常用於 shared memory 或 memory-mapped I/O。

#include <new>
void * operator new(size_t, void *location) {
    return location;
}

delete 會先呼叫解構函式,再執行 operator delete 釋放記憶體。
delete[] 會為陣列中的每個元素呼叫解構函式,再執行 operator delete[] 釋放記憶體。

三、異常

9. 利用 destructor 避免洩漏資源

堅持一個原則,將資源封裝在物件內,這樣即使發生異常,區域性物件在自動銷燬的時候也可以呼叫其解構函式釋放資源,避免洩漏。

10. 在 constructor 內阻止資源洩漏

注意: C++ 只會析構 已構造完成 的物件。也就是說在建構函式中發生異常的話,解構函式是不會執行的。

最好是使用 auto_ptr 物件來取代 pointer class members。

11. 禁止異常流出 destructor 之外

有兩個好處:

  • 第一是它可以避免 terminate 函式在異常傳播過程的棧展開機制中被呼叫,你的程式將被立即結束。
  • 第二是它可以協助確保 destructor 完成其應該完成的所有事情。

12. 瞭解 “丟擲一個異常” 與 “傳遞一個引數” 或 “呼叫一個虛擬函式” 之間的差異

“丟擲一個異常”,異常物件總是會被複制一次,如果以 by value 方式捕捉,則會發生兩次複製。

“傳遞一個引數”,如果是 by reference 方式則不會發生複製,如果是 by value 方式則發生複製。(簡單的說就是丟擲異常會比傳遞引數多發生一次複製)

丟擲的異常不會發生隱式轉型(即 int 型別不會默默轉為 double 型別而被捕獲)。只有兩種轉換可以發生,一種是繼承關係中的向上轉型,另一種是 “有型指標” 轉為 “無型指標”。const void* 指標可捕捉任何指標型別的異常。

異常的捕捉遵循 “最先吻合” 策略,即找到第一個匹配者執行。呼叫虛擬函式則是 “最佳吻合” 策略,即執行的是與物件型別最吻合的函式。

13. 以 by reference 方式捕捉 exception

上面已經說到了,這樣可以減少一次複製。

14. 明智的運用 exception specification

exception specification 即明確指出一個函式可以丟擲什麼樣的異常,例如:

void fun() throw(int); // 只丟擲型別為 int 的異常

然而它是一把雙刃劍,雖然可以用來規範異常的運用,但也可能帶來 unexpected 的異常。

15. 瞭解異常處理的成本

你必須知道,異常的支援會導致程式變大,執行效率也比較慢。

四、效率

16. 謹記 80-20 法則

80-20 法則 說:一個程式 80% 的資源用在 20% 的程式碼上。即軟體整體效能幾乎總是由其構成程式碼的一小部分決定。

我們可以藉助分析器得知程式不同區段花費時間的多少,然後 專注於特別耗時的地方 加以改善。

17. 考慮使用 lazy evaluation(緩式評估)

lazy evaluation 是一種拖延戰術,即當運算結果真正要被用到時才進行運算,通常這樣我們可以省掉部分運算消耗。

例如矩陣計算中我們場次只會使用到其中的部分結果。

18. 分期攤還預期的計算成本

超急評估(over-eager-evaluation) 是指在被要求前先把事情做下去。

通常有兩種策略:一種是 快取(cache),另一種是 預先取出(prefetching)。它們都是使用空間換取時間的策略。

19. 瞭解臨時物件的來源

臨時物件可能很耗成本,所以我們應該 儘量消除 它們。

任何時候只要看到物件以 by value(值傳遞) 方式傳遞,或是以一個 reference-to-const 引數方式傳遞,還有函式直接返回一個物件,這些都極可能產生一個臨時物件。

20. 協助完成 “返回值優化(Return Value Optimization)”

如果函式一定得以 by-value 方式返回物件,我們就無法消除臨時物件的建立和銷燬。

但是,如果我們像下面這麼做,編譯器可能 會幫我們將臨時物件優化掉,使它們不存在:

inline const Rational operator* (const Rational& lhs, const Rational& rhs) {
    return Rational(lhs.numerator() * rhs.numerator(),
                    lhs.denominator() * rhs.denominator());
}

21. 利用過載避免隱式型別轉換

通過過載函式指定不同的引數型別,這樣可避免傳入不同引數型別時,發生隱式轉換的過程。

但是增加一大堆過載函式也不是一件好事,除非你認為使用過載函式後程序整體效率可以得到較大的改善。

22. 考慮使用複合形式操作符取代獨身形式

所謂複合形式即 +=-= 等等。例如 x += y;
所謂獨身形式即 +- 等等。例如 x = x + y;

一般而言,複合形式 效率更高,因為它不會產生臨時物件,而獨身形式通常需要返回一個新物件,需要負擔一個臨時物件的構造和析構成本。

23. 考慮使用其它程式庫

兩個程式庫可能提供相同的機能,但卻有著不同的效能表現。

就比如 <iostream><stdio.h>,前者使用更直接方便,後者 I/O 效能更好。所以當我們找到程式的瓶頸時,也可以考慮是否存在另一個功能相近但在效率上有較高提升的程式庫。

24. 瞭解虛擬函式、多繼承、虛基類、執行時期型別辨別(RTTI)的成本

虛擬函式(virtual function) 意味著編譯器會幫你的類維護一個 virtual tables 和 virtua table pointers。常簡寫為 vtblsvptrs

但你存在大量擁有虛擬函式的類,或是每一個類中有大量的虛擬函式,你可能就會發現,vtbls 佔用不少記憶體。

多繼承 會讓事情變得更加複雜,往往還會導致虛基類(virtual base class)的需求,而針對 base class 形成特殊的 vtbls 進一步增大負擔。

執行時期型別辨別(runtime type identification) 依存於類的 vtbl 實現,它的空間成本是在 class vtbl 內增加一個條目,再加上每個 class 所需的一份 type_info 物件空間。

五、技術

25. 將 constructor 和 non-member function 虛化

所謂 virtual constructor 並不是真的將 constructor 宣告為 viatual,而是某種函式,視其輸入而產生不同型別的物件。比較常用的一種場景就是從磁碟讀取物件資訊。

一種特別的 virtual constructor 即 virtual copy constructor,返回一個指向呼叫者新副本的指標,常以 copyclone 命名。例如:

class NLComponent {
public:
    virtual NLComponent * clone() const = 0;
};
class TextBlock: public NLComponent {
public:
    virtual TextBlock * clone() const {
        return new TextBlock(*this);
    }
};
class Graphic: public NLComponent {
public:
    virtual Graphic * clone() const {
        return new Graphic(*this);
    }
};

就像 constructor 無法真的被虛化一樣,non-member function 也是。當我們也可以讓其行為視其引數的動態型別而不同。例如:

class NLComponent {
public:
    virtual ostream& print(ostream& s) const = 0;
};
class TextBlock: public NLComponent {
public:
    virtual ostream& print(ostream& s);
};
class Graphic: public NLComponent {
public:
    virtual ostream& print(ostream& s);
};
 
inline ostream& operator<<(ostream& s, const NLComponent& c) {
    return c.print(s);
}

26. 限制某個 class 產生的物件數量

有時候我們希望 class 產生物件的數量是有限制的,方式就是將構造方法私有化,然後提供一個獲取 class 物件的方法,在這個方法裡我們去控制產生物件的數量。

27. 要求(或禁止)物件產生於 heap 中

我們可以讓解構函式成為 private 來要求物件產生於 heap 中。

operator new 宣告為 private 禁止讓物件產生與 heap 中。

28. 智慧指標(smart pointer)

智慧指標是看起來,用起來和感覺起來都像是內建指標,但提供更多功能的一種物件。通常包括資源管理、自動的重複寫碼工作。

29. 引用計數(reference counting)

引用計數允許多個等值物件共享同一個實值。

當物件運用了引用計數,一旦不再有任何人使用它,便自動銷燬自己,建構出垃圾回收機制的一個簡單形式。

同時許多物件有相同的值,將那個值儲存多次也是件愚蠢的事,共享一份實值不止節省記憶體,也使程式速度加快。

30. 代理類、替身類(proxy class)

大多情況下,代理類可以完美取代所代表的真正物件,將我們 “與真正物件合作” 轉移到 “與替身物件合作”。
代理類可以讓我們完成某些很困難的行為,例如多維陣列、左右值的區分、壓抑隱式轉換。

31. 讓函式根據一個以上的物件型別決定如何虛化

人們把 “虛擬函式呼叫動作” 稱之為一個 訊息分派(message dispatch)。某個函式呼叫如果根據兩個引數而虛化,稱之為 雙分派(double dispatch)

C++、Java 等語言並不直接直接雙分派,簡單的虛擬函式實現出來的是單分派(single dispatch),但我們可以通過一些策略實現雙分派。

32. 在未來時態下發展程式

身為開發人員,需要接受事情總是改變的事實,所以我們應該儘量寫出可移植的程式碼、可應對系統改變的程式碼。
提供完整的 class,即使某些功能暫時用不到,但當新的需求進來,你不太需要回頭去修改那些類。

設計你的介面,使有利於共同操作行為,阻止共同的錯誤。讓類能夠輕易的被正確的使用,難以被錯誤的使用。
儘量使你的程式碼一般化。

33. 將非尾端類設計為抽象類

一般性的法則: 繼承體系中的非尾端類應該是抽象類。堅持這個法則,有利於整個軟體的可靠度、健壯度、精巧度、擴充度。

34. 如何在同一個程式中結合 C 和 C++

  • 確定你的 C++ 和 C 編譯器產出相容的目標檔案。
  • 將雙方都使用的函式宣告為 extern "C"
  • 如果可能,儘量在 C++ 中撰寫 main
  • 總是以 delete 刪除 new 返回的記憶體,總是以 free 釋放 malloc 返回的記憶體。
  • 將兩個語言間的資料結構傳遞限制於 C 所能瞭解的形式;C++ struct 如果內含非虛擬函式,倒是不受此限。

35. 讓自己習慣於標準 C++ 語言

學習 C++ 標準程式庫,不僅可以增加你的只是,知道如何包裝完整的元件運用於自己的軟體上面,也可以使你學習如何更有效的運用 C++ 特性,並對如何設計更好的程式庫有所體會。