《Effective C++》簡明筆記-上


在學習算法導論的過程中,我深深地震撼於自己筆下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 操作承諾絕不拋出異常。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM