學過 C++ 的你,不得不知的這 10 條細節!


每日一句英語學習,天天進步一點點:
  • 「Action may not always bring happiness; but there is no happiness without action.」
  • 「行動不見得必定帶來快樂,但沒有行動就沒有快樂。」

前言

我在閱讀 《Effective C++ (第三版本)》 書時作了很多筆記,從中收穫了很是多,也明白爲何會書中前言的第一句話會說:php

對於書中的「條款」這一詞,我更喜歡以「細節」替換,畢竟年輕的咱們在打 LOL 或 王者的時,總會說注意細節!細節!細節~ —— 細節也算伴隨咱們的青春的字眼nginx

針對書中的前兩個章節,我篩選了 10 個 細節(條款)做爲了本文的內容,這些細節也相對基礎且重要。c++

針對這 10 細節我都用較簡潔的例子來加以闡述,同時也把本文所說起細節中的「小結」總結繪畫成了一副思惟導圖,便於你們的閱讀。程序員

舒適提示:本文較長(萬字),建議收藏閱讀。
後續有時間也會繼續分享後面章節的筆記,喜歡的小夥伴「點擊左上角」關注我~web

正文

1 讓本身習慣C++

細節 01:儘可能以const,enum,inline 替換 #define

#define 定義的常量有什麼不妥?apache

首先咱們要清楚程序的編譯重要的三個階段:預處理階段,編譯階段和連接階段編程

#define 是不被視爲語言的一部分,它在程序編譯階段中的預處理階段的做用,就是作簡單的替換。數組

以下面的 PI 宏定義,在程序編譯時,編譯器在預處理階段時,會先將源碼中全部 PI 宏定義替換成 3.14安全

1#define PI 3.14

程序編譯在預處理階段後,才進行真正的編譯階段。在有的編譯器,運用了此 PI 常量,若是遇到了編譯錯誤,那麼這個錯誤信息也許會提到 3.14 而不是 PI,這就會讓人困惑哪裏來的3.14,特別是在項目大的狀況下。服務器

解決之道:以 const 定義一個常量替換上述的宏(#define)

做爲一個語言變量,下面的 const 定義的常量 Pi 確定會被編譯器看到,出錯的時候能夠很清楚知道,是這個變量致使的問題:

1const doule Pi = 3.14;

若是是定義常量字符串,則必需要 const 兩次,目的是爲了防止指針所指內容和指針自身不能被改變:

1const charconst myName = "小林coding";

若是是定義常量 string,則只須要在最前面加一次 const,形式以下:

1const std::string myName("小林coding");

#define 不重視做用域,因此對於 class 的專屬常量,應避免使用宏定義。

還有另一點宏沒法涉及的,就是咱們沒法利用 #define 建立一個 class 專屬常量,由於 #define 並不重視做用域。

對於類裏要定義專屬常量時,咱們依然使用 static + const,形式以下:

1class Student {
2private:
3    static const int num = 10;
4    int scores[num];
5};
6
7const int Student::num// static 成員變量,須要進行聲明

若是不想外部獲取到 class 專屬常量的內存地址,可使用 enum 的方式定義常量

enum 會幫你約束這個條件,由於取一個 enum 的地址是不合法的,形式以下:

1class Student {
2private:
3    enum { num = 10 };
4    int scores[num];
5};

#define 實現的函數容易出錯,而且長相醜陋不易閱讀。

另一個常見的 #define 誤用狀況是以它實現宏函數,它不會招致函數調用帶來的開銷,可是用 #define 編寫宏函數容易出錯,以下用宏定義寫的求最大值的函數:

1#define MAX(a, b) ( { (a) > (b) ? (a) : (b); } ) // 求最大值

這般長相的宏有着太的缺點,好比在下面調用例子:

1int a = 6, b = 5;
2int max = MAX(a++, b);
3
4std::cout << max << std::endl;
5std::cout << a << std::endl;

輸出結果(如下結果是錯誤的):

17 // 正確的答案是 max 輸出 6
28 // 正確的答案是  a  輸出 7

要解釋出錯的緣由很簡單,咱們把 MAX 宏作簡單替換:

1int max = ( { (a++) > (b) ? (a++) : (b); } ); // a 被累加了2次!

在上述替換後,能夠發現 a 被累加了 2 次。咱們能夠經過改進 MAX 宏,來解決這個問題:

1#define MAX(a, b) ({ \
2    __typeof(a) __a = (a), __b = (b); \
3    __a > __b ? __a : __b; \
4})

簡單說明下,上述的 __typeof 能夠根據變量的類型來定義一個相同類型的變量,如 a 變量是 int 類型,那麼 __a 變量的類型也是 int 類型。改進後的 MAX 宏,輸出的是正確的結果,max 輸出 6,a 輸出 7。

雖然改進的後 MAX 宏,解決了問題,可是這種宏的長相就讓人困惑。

解決的方式:用 inline 替換 #define 定義的函數

inline 修飾的函數,也是能夠解決函數調用的帶來的開銷,同時閱讀性較高,不會讓人困惑。

下面用用 template inline 的方式,實現上述宏定義的函數::

1template<typename T>
2inline T max(const T& a, const T& b)
3
{
4    return a > b? a : b;
5}

max 是一個真正的函數,它遵循做用域和訪問規則,因此不會出現變量被屢次累加的現象。

模板的基礎知識內存,可移步到個人舊文進行學習 --> 泛型編程的第一步,掌握模板的特性!


細節 01 小結 - 請記住

  • 對於單純常量,最好以 const 對象或 enum 替換 #define;
  • 對於形式函數的宏,最好改用 inline 函數替換 #define。

細節 02:儘量使用 const

const 的一件奇妙的事情是:它容許你告訴編譯器和其餘程序員某值應該保持不變

1. 面對指針,你能夠指定指針自身、指針所指物,或二者都(或都不)是 const:

1char myName[] = "小林coding";
2char *p = myName;             // non-const pointer, non-const data
3const char* p = myName;       // non-const pointer, const data
4charconst p = myName;       // const pointer, non-const data
5const charconst p = myName; // const pointer, const data
  • 若是關鍵詞const出如今星號(*邊,表示指針所指物是常量(不能改變 *p 的值);
  • 若是關鍵詞const出如今星號(*邊,表示指針自身是常量(不能改變 p 的值);
  • 若是關鍵詞const出如今星號(*邊,表示指針所指物和指針自身都是常量

2. 面對迭代器,你也指定迭代器自身或自迭代器所指物不可被改變:

1std::vector<int> vec;
2
3const std::vector<int>::iterator iter = vec.begin(); // iter 的做用像 T* const
4*iter = 10// 沒問題,能夠改變 iter 所指物   
5++iter;     // 錯誤! 由於 iter 是 const     
6
7std::vector<int>::const_iterator cIter = vec.begin(); // cIter 的做用像 const T*
8*cIter = 10// 錯誤! 由於 *cIter 是 const           
9++cIter;     // 沒問題,能夠改變 cIter                        
  • 若是你但願迭代器自身不可被改動,像指針聲明爲 const 便可(即聲明一個 T* const 指針); —— 這個不經常使用
  • 若是你但願迭代器所指的物不可被改動,你須要的是 const_iterator(即聲明一個 const T* 指針)。—— 這個經常使用

const 最具備威力的用法是面對函數聲明時的應用。在一個函數聲明式內,const 能夠和函數返回值、各參數、成員函數自身產生關聯。

1.函數返回一個常量值,每每能夠下降因程序員錯誤而形成的意外。舉個例子:

1class Rational { ... };
2const Rational operator* (const Rational& lhs, const Rational& rhs);

爲何要返回一個 const 對象呢?緣由是若是不這樣,程序員就能實現這一的暴力行爲:

1Rational a, b, c;
2if (a * b = c) ... // 作比較時,少了個等號

若是 operator* 返回的 const 對象,能夠預防這個沒意義的賦值動做。

2.const 實施於成員函數的目的,是爲了確認該成員函數可做用於 const 對象。理由以下兩個:

理由 1 :

它們使得 class 接口比較容易理解,由於能夠得知哪一個函數能夠改動對象而哪些函數不行,見以下例子:

 1class MyString
2{

3public:
4    const charoperator[](std::size_t position) const // operator[] for const 對象
5    { return text[position]; }
6
7    charoperator[](std::size_t position)  // operator[] for non-const 對象
8    { return text[position]; }
9private:
10    std::string text;
11};

MyString 的 operator[] 能夠被這麼使用:

1MyString ms("小林coding")// non-const 對象
2std::cout << ms[0];   // 調用 non-const MyString::operator[]
3ms[0] = 'x';          // 沒問題,寫一個 non-const  MyString
4
5const MyString cms("小林coding")// const 對象
6std::cout << cms[0];   // 調用 const MyString::operator[]
7cms[0] = 'x';          // 錯誤! 寫一個 const  MyString

注意,上述第 7 行會出錯,緣由是 cms 是 const 對象,調用的是函數返回值爲 const 類型的 operator[] ,咱們是不能夠對 const 類型的變量或變量進行修改的。

理由 2 :

它們使操做 const 對象成爲可能,這對編寫高效代碼是個關鍵,由於改善 C++ 程序效率的一個根本的方法是以 pass by referenc-to-const(const T& a) 方式傳遞對象,見以下例子:

 1class MyString
2{

3public:
4
5    MyString(const char* str) : text(str)
6    { 
7        std::cout << "構造函數" << std::endl
8    }
9
10    MyString(const MyString& myString) 
11    {
12        std::cout << "複製構造函數" << std::endl;
13        (*this).text = myString.text;
14    }
15
16    ~MyString() 
17    { 
18        std::cout << "析構函數" << std::endl
19    }
20
21    bool operator==(MyString rhs) const      // pass by value 按值傳遞
22    {
23        std::cout << "operator==(MyString rhs) pass by value" << std::endl;
24        return (*this).text == rhs.text;
25    }
26private:
27    std::string text;
28};

operator== 函數是 pass by value, 也就是按值傳遞,咱們使用它,看下會輸出什麼:

 1int main()
2
{
3    std::cout << "main()" << std::endl;
4    MyString ms1("小林coding");
5    MyString ms2("小林coding");
6
7    std::cout << ( ms1 == ms2) << std::endl; ;
8    std::cout << "end!" << std::endl;
9    return 0;
10}

輸出結果:

 1main()
2構造函數
3構造函數
4複製構造函數
5operator==(MyString rhs)  pass by value
61
7析構函數
8end!
9析構函數
10析構函數

能夠發如今進入 operator== 函數時,發生了「複製構造函」,當離開該函數做用域後發生了「析構函數」。說明「按值傳遞」,在進入函數時,會產生一個副本,離開做用域後就會消耗,說明這裏是存在開銷的。

咱們把 operator== 函數改爲 pass by referenc-to-const 後,能夠減小上面的副本開銷:

1bool operator==(const MyString& rhs)
2{
3    std::cout << "operator==(const MyString& rhs)  
4        pass by referenc-to-const"
 << std::endl;
5    return (*this).text == rhs.text;
6}

再次輸出的結果:

1main()
2構造函數
3構造函數
4operator==(const MyString& rhs)  pass by referenc-to-const
51
6end!
7析構函數
8析構函數

沒有發生複製構造函數,說明 pass by referenc-to-const 比 pass by value 性能高。


在 const 和 non-const 成員函數中避免代碼重複

假設 MyString 內的 operator[] 在返回一個引用前,先執行邊界校驗、打印日誌、校驗數據完整性。把全部這些同時放進 const 和 non-const operator[]中,就會致使代碼存在必定的重複:

 1class MyString
2{

3public:
4    const charoperator[](std::size_t position) const 
5    { 
6        ...    // 邊界檢查
7        ...    // 日誌記錄
8        ...    // 校驗數據完整性
9        return text[position]; 
10    }
11
12    charoperator[](std::size_t position)
13    { 
14        ...    // 邊界檢查
15        ...    // 日誌記錄
16        ...    // 校驗數據完整性
17        return text[position]; 
18    }
19private:
20    std::string text;
21};

能夠有一種解決方法,避免代碼的重複:

 1class MyString
2{

3public:
4    const charoperator[](std::size_t position) const  // 一如既往
5    { 
6        ...    // 邊界檢查
7        ...    // 日誌記錄
8        ...    // 校驗數據完整性
9        return text[position]; 
10    }
11
12    charoperator[](std::size_t position)
13    { 
14        return const_cast<char&>(
15                static_cast<const MyString&>(*this)[position]
16                ); 
17    }
18private:
19    std::string text;
20};

這份代碼有兩個轉型動做:

  • static_cast(*this)[position],表示將 MyString& 轉換成 const MyString&,可以讓其調用 const operator[] 兄弟
  • const_cast<char& style="font-size: inherit; color: inherit; line-height: inherit; margin: 0px; padding: 0px;">( … ),表示將 const char & 轉換爲 char &,讓其是 non-const operator[] 的返回類型

雖然語法有一點點奇特,但「運用 const 成員函數實現 non-const 孿生兄弟 」的技術是值得了解的。

須要注意的是:咱們能夠在 non-const 成員函數調用 const 成員函數,可是不能夠反過來,在 const 成員函數調用 non-const 成員函數調用,緣由是對象有可能所以改動,這會違背了 const 的本意。


細節 02 小結 - 請記住

  • 將某些東西聲明爲 const 可幫助編譯器探測出錯誤用法。const 能夠被施加於任何做用域內的對象、函數參數、函數返回類型、成員函數本體。
  • 當 const 和 non-const 成員函數有着實質等價的實現時,令 non-const 版本調用 const 版本可避免代碼重複。

細節 03:肯定對象被使用前先被初始化

內置類型初始化

若是你這麼寫:

1int x;

在某些語境下 x 保證被初始化爲 0,但在其餘語境中卻不保證。那麼可能在讀取未初始化的值會致使不明確的行爲。

爲了不不肯定的問題,最佳的處理方法就是:永遠在使用對象以前將它初始化。 例如:

1int x = 0;                    // 對 int 進行手工初始化
2const char* text = "abc";     // 對指針進行手工初始化

構造函數初始化

對於內置類型之外的任何其餘東西,初始化責任落在構造函數

規則很簡單:確保每個構造函數都將對象的每個成員初始化。可是別混淆了賦值初始化

考慮用一個表現學生的class,其構造函數以下:

 1class Student {
2public:
3    Student(int id, const std::string& name, const std::vector<int>& score)
4    {
5        m_Id = id;          // 這些都是賦值
6        m_Name = name;      // 而非初始化
7        m_Score = score;
8    }
9private:
10    int m_Id;
11    std::string m_Name;
12    std::vector<int> m_Score;
13};

上面的作法並不是初始化,而是賦值,這不是最佳的作法。由於 C++ 規定,對象的成員變量的初始化動做發生在進入構造函數本體以前,在構造函數內,都不算是被初始化,而是被賦值

初始化的寫法是使用成員初值列,以下:

1    Student(int id,
2            const std::string &name,
3            const std::vector<int> &score)
4            : m_Id(id),
5              m_Name(name),  // 如今,這些都是初始化
6              m_Score(score) 
7     {}                      //  如今,構造函數本體沒必要有任何動做

這個構造函數和上一個構造函數的最終結果是同樣的,可是效率較高,凸顯在:

  • 上一個構造函數(賦值版本)首先會先自動調用 m_Namem_Score 對象的默認構造函數做爲初值,而後在構造函數體內馬上再對它們進行賦值操做,這期間經歷了兩個步驟。
  • 這個構造函數(成員初值列)避免了這個問題,只會發生了一次複製構造函數,本例中的 m_Namename 爲初值進行復制構造,m_Scorescore 爲初值進行復制構造。

另一個注意的是初始化次序(順序),初始化次序(順序):

  1. 先是基類對象,再初始化派生類對象(若是存在繼承關係);
  2. 在類裏成員變量老是以聲明次序被初始化,如本例中 m_Id 先被初始化,再是 m_Name,最後是 m_Score,不然會出現編譯出錯。

避免「跨編譯單元之初始化次序」的問題

如今,咱們關係的問題涉及至少兩個以上源碼文件,每個內含至少一個 non-local static 對象。

存在的問題是:若是有一個 non-local static 對象須要等另一個 non-local static 對象初始化後,纔可正常使用,那麼這裏就須要保證次序的問題。

下面提供一個例子來對此理解:

1class FileSystem
2{

3public:
4    ...
5    std::size_t numDisk() const// 衆多成員函數之一
6    ...
7};
8
9extern FileSystem tfs; // 預備給其餘程序員使用對象

現假設另一個程序員創建一個class 用以處理文件系統內的目錄,很天然他們會用上 tfs 對象:

1class Directory
2{

3public:
4    Directory( params )
5    {
6        std::size_t disks = tfs.numDisk(); // 使用 tfs 對象
7    }
8    ...
9};

使用 Directory 對象:

1Directory tempDir( params );

那麼如今,初始化次序的重要性凸顯出來了,除非 tfsd 對象在 tempDir 對象以前被初始化,不然 tempDir 的構造函數會用到還沒有初始化的 tfs, 就會出現未定義的現象

因爲 C++ 對「定義於不一樣的編譯單元內的 non-local static 對象」的初始化相對次序並沒有明肯定義。但咱們能夠經過一個小小的設計,解決這個問題。

惟一須要作的是:將每一個 non-local static 對象搬到本身的專屬函數內(該對象在此函數內被聲明爲 static),這些函數返回一個引用指向它所含的對象。

沒錯也就是單例模式,代碼以下:

 1class FileSystem
2{

3public:
4    ...
5    static FileSystem& getTfs() // 該函數做用是獲取 tfs 對象,
6    
{
7        static FileSystem tfs;  // 定義並初始化一個 local static 對象,
8        return tfs;             // 返回一個引用指向上述對象。
9    }
10    ...
11};
12
13
14class Directory
15{

16public:
17   ...
18    Directory( params )
19    {
20        std::size_t disks = FileSystem::getTfs().numDisk(); // 使用 tfs 對象
21    }
22    ...
23};

這麼修改後,Directory 構造函數就會先初始化 tfs 對象,就能夠避免次序問題了。雖然內含了 static 對象,可是在 C++11 以上是線程安全的。


細節 03 小結 - 請記住

  • 爲內置類型進行手工初始化,由於 C++ 不保證初始化它們。
  • 構造函數最好使用成員初值列,而不要在構造函數本體內使用賦值操做。初值列列出的成員變量,其排列次序應該和它們在 class 中的聲明次序(順序)相同。
  • 爲避免「跨編譯單元之初始化次序」的問題,請以 local static 對象替代 non-local static 對象。

2 構造/析構/賦值運算

細節 04:瞭解 C++ 默默編寫並調用哪些函數

當你寫了以下的空類:

1class Student { };

編譯器就會它聲明,而且這些函數都是 public 且 inline:

  1. 複製構造函數
  2. 賦值操做符函數
  3. 析構函數
  4. 默認構造函數(若是沒有聲明任何構造函數)

就好像你寫下這樣的代碼:

1class Student 
2{
 
3    Student() { ... }                              // 默認構造函數
4    Student(const Student& rhs) { ... }            // 複製構造函數
5    Student& operator=(const Student& rhs) { ... } // 賦值操做符函數
6    ~Student() { ... }                             // 析構函數
7};

惟有當這些函數被須要調用時,它們纔會被編譯器建立出來,下面代碼形成上述每個函數被編譯器產出:

1Student stu1;         // 默認構造函數
2                      // 析構函數
3Student stu2(stu1);   // 複製構造函數
4stu2 = stu1;          // 賦值操做符函數

編譯器爲咱們寫的函數,來講說這些函數作了什麼?

  • 默認構造函數和析構函數主要是給編譯器一個地方用來放置隱藏幕後的代碼,像是調用基類和非靜態成員變量的構造函數和析構函數。注意,編譯器產出的析構函數是個 non-virtual,除非這個 class 的 base class 自身聲明有 virtual 析構函數。
  • 複製構造函數和賦值操做符函數,編譯器建立的版本只是單純地未來源對象的每個非靜態成員變量拷貝到目標對象。

編譯器拒絕爲 class 生出 operator= 的狀況

對於賦值操做符函數,只有當生出的代碼合法且有適當機會證實它有意義,纔會生出 operator= ,若萬一兩個條件有一個不符合,則編譯器會拒絕爲 class 生出 operator=

舉個例子:

 1template<class T>
2class Student
3{

4public:
5    Student(std::string & name, const T& id); // 構造函數
6    ...                          // 假設未聲明 operator=
7priavte:
8    std::string& m_Name;    // 引用
9    const T m_Id; // const
10};

現考慮下面會發生什麼:

1std::string name1("小美");
2std::string name2("小林");
3
4Student<int> p(name1, 1);
5Student<int> s(name2, 2);
6
7p = s;            // 如今 p 的成員變量會發生什麼?

賦值以前, p.m_Names.m_Name 都指向 string 對象且不是同一個。賦值以後 p.m_Name 應該指向 s.m_Name 所指的那個 string 嗎?也就是說引用自身可被改動嗎?若是是,那就開闢了新天地,由於 C++ 並不容許「讓引用更改指向不一樣對象」

面對這個難題,C++ 的響應是拒絕編譯那一行賦值動做,本例子拒絕生成的 operator= 緣由以下:

  • 若是你須要在一個「內含引用的成員」(如本例的 m_Name )的class 內支持賦值操做,你必須本身定義賦值操做函數,這種狀況是編譯器不會爲你自動生成賦值操做函數的。
  • 還有面對「內含 const 成員」(如本例的 m_Id )的class,編譯器也是會拒絕生成 operator=,由於更改 const 成員是不合法的。

最後還有一個狀況:若是某個基類將 operator= 函數聲明爲 private ,編譯器將拒絕爲其派生類生成 operator= 函數。


細節 04 小結 - 請記住

  • 編譯器能夠暗自爲 class 建立默認構造函數(若是沒有聲明任何構造函數)、複製構造函數、賦值操做符函數,以及析構函數。
  • 編譯器拒絕爲 class 建立 operator= 函數狀況:(1) 內含引用的成員、(2) 內含 const 的成員、(3)基類將 operator= 函數聲明爲 private。

細節 05:若不想使用編譯器自動生成的函數,就該明確拒絕

在不容許存在如出一轍的兩個對象的狀況下,能夠把複製構造函數和賦值操做符函數聲明爲 private,這樣既可防止編譯器自動生成這兩個函數。以下例子:

1class Student
2{

3public:
4    ...
5private:
6    ... 
7    Student(const Student&);             // 只有聲明
8    Student& operator=(const Student&);  // 只有聲明
9};

這樣的話,Student 對象就沒法操做下面的狀況了:

1Student stu1;
2Student stu2(stu1);   // 錯誤,禁用了 複製構造函數
3
4stu2 = stu1;          // 錯誤,禁用了 賦值操做符函數

更容易擴展的解決方式是,能夠專門寫一個爲阻止 copying 動做的基類:

1class Uncopyale
2{

3protect:              // 容許派生類對象構造和析構
4    Uncopyale() {}                
5    ~Uncopyale() {}
6private:             // 禁止派生類對象copying
7    Uncopyale(const Uncopyale&);
8    Uncopyale& operater=(const Uncopyale&);
9};

使用方式很簡單,只須要 private 形式的繼承:

1class Student : private Uncopyale{  
2    ...  // 派生類不用再聲明覆制構造函數和賦值操做符函數
3};

那麼只要某個類須要禁止 copying 動做,則只須要 private 形式的繼承 Uncopyale 基類便可。


細節 05 小結 - 請記住

  • 若是不想編譯器自動生成函數,可將相應的成員函數聲明爲 private 而且不予實現。使用像 Uncopyale 這樣的基類也是一種作法。

細節 06:爲多態基類聲明 virtual 析構函數

多態特性的基礎內容,可移步到個人舊文進行學習 --> 掌握了多態的特性,寫英雄聯盟的代碼更少啦!

多態性質基類需聲明 virtual 析構函數

若是在多態性質的基類,沒有聲明一個 virtual 析構函數,那麼在 delete 基類指針對象的時候,只會調用基類的析構函數,而不會調用派生類的析構函數,這就是存在了泄漏內存和其餘資源的狀況

以下有多態性質基類,沒有聲明一個 virtual 析構函數的例子:

 1// 基類
2class A 
3{

4public
5    A()  // 構造函數
6    {
7        cout << "construct A" << endl;
8    }
9
10    ~A() // 析構函數
11    {
12        cout << "Destructor A" << endl;
13    }
14};
15
16// 派生類
17class B : public A 
18{
19public
20    B()  // 構造函數
21    {
22        cout << "construct B" << endl;
23    }
24
25    ~B()// 析構函數
26    {
27        cout << "Destructor B" << endl;
28    }
29};
30
31int main() 
32
{
33    A *pa = new B();
34    delete pa;    // 釋放資源
35
36    return 0;
37}

輸出結果:

1construct A
2construct B
3Destructor A 
4

從上面的結果,是發現了在 delete 基本對象指針時,沒有調用派生類 B 的析構函數。問題出在 pa 指針指向派生類對象,而那個對象卻經由一個基類指針被刪除,而目前的基類沒有 virtual 析構函數。

消除這個問題的作法很簡單:爲了不泄漏內存和其餘資源,須要把基類的析構函數聲明爲 virtual 析構函數。改進以下:

 1// 基類
2class A 
3{

4public
5    ....            // 如上
6    virtual ~A()   // virtual 析構函數
7    {
8        cout << "Destructor A" << endl;
9    }
10};
11...                // 如上

此後刪除派生類對象就會如你想要的那般,是的,它會銷燬整個對象,包括全部派生類成份。

非多態性質基類無需聲明 virtual 函數

當類的設計目的不是被當作基類,令其析構函數爲 virtual 每每是個餿主意

若類裏聲明瞭 virtual 函數,對象必須攜帶某些信息。主要用來運行期間決定哪個 virtual 函數被調用。

這份信息一般是由一個所謂 vptr(virtual table pointer —— 虛函數表指針)指針指出。vptr 指向一個由函數指針構成的數組,稱爲 vtbl(virtual table —— 虛函數表);每個帶有 virtual 函數的類都有一個相應的 vtbl。當對象調用某一 virtual 函數,實際被調用的函數取決於該對象的 vptr 所指向的那個 vtbl,接着編譯器在其中尋找適當的函數指針,從而調用對應類的函數。

既然內含 virtual 函數的類的對象必須會攜帶信息,那麼必然其對象的體積是會增長的。

  • 在 32 位計算機體系結構中將多佔用 4個字節(存放 vptr );
  • 在 64 位計算機體系結構則將多佔用 8 個字節(存放 vptr )。

所以,無故地將全部類的析構函數聲明爲 virtual ,是錯誤的,緣由是會增長沒必要要的體積。

許多人的心得是:只有當 class 內含至少一個 virtual 函數,才爲它聲明 virtual 析構函數。


細節 06 小結 - 請記住

  • 在多態性質的基類,應該聲明一個 virtual 析構函數。若是 class 帶有任何 virtual 函數,它就應該擁有一個 virtual 析構函數。
  • 類的設計目的若是不是爲基類使用的,或不是爲了具有多態性,就不應聲明 virtual 析構函數。

細節 07:毫不在構造和析構過程當中調用 virtual 函數

咱們不應在構造函數和析構函數體內調用 virtual 函數,由於這樣的調用不會帶來你預想的結果。

咱們看以下的代碼例子,來講明:

 1// 基類
2class CFather 
3{

4public:
5    CFather()
6    {
7        hello();
8    }
9
10    virtual ~CFather()
11    {
12        bye();
13    }
14
15    virtual void hello() // 虛函數
16    
{
17        cout<<"hello from father"<<endl
18    }
19
20    virtual void bye() // 虛函數
21    
{
22        cout<<"bye from father"<<endl
23    }
24};
25
26// 派生類
27class CSon : public CFather
28
29public:
30    CSon() // 構造函數
31    { 
32        hello(); 
33    }
34
35    ~CSon()  // 析構函數
36    { 
37        bye();
38    }
39
40    virtual void hello() // 虛函數
41    

42        cout<<"hello from son"<<endl;
43    }
44
45    virtual void bye() // 虛函數
46    
{
47        cout<<"bye from son"<<endl
48    }
49};

如今,當如下這行被執行時,會發生什麼事情:

1CSon son;

先列出輸出結果:

1hello from father
2hello from son
3bye from son
4bye from father

無疑地會有一個 CSon(派生類) 構造函數被調用,但首先 CFather(基類) 構造函數必定會更早被調用。 CFather(基類) 構造函數體力調用 virtual 函數 hello,這正是引起驚奇的起點。這時候被調用的 hello 是 CFather 內的版本,而不是 CSon 內的版本。

說明,基類構造期間 virtual 函數毫不會降低到派生類階層。取而代之的是,對象的做爲就像隸屬於基類類型同樣。

非正式的說法或許比較傳神:在基類構造期間,virtual 函數不是 virtual 函數。

相同的道理,也適用於析構函數。


細節 07 小結 - 請記住

  • 在構造和析構期間不要調用 virtual,由於這類調用不會降低至派生類。

細節 08:令 operator= 返回一個 reference to *this

關於賦值,又去的是你能夠把它們寫成連鎖形式:

1int x, y, z;
2x = y = z = 15;  // 賦值連鎖形式 

一樣有趣的是,賦值採用右結合律,因此上述連鎖賦值被解析爲:

1x = (y = ( z = 15 ));

這裏 15 先被賦值給 z,而後其結果再被賦值給 y,而後其結果再賦值給 x 。

爲了實現「連鎖賦值」,賦值操做必須返回一個 reference (引用)指向操做符的左側實參。這是咱們爲 classes 實現賦值操做符時應該遵循的協議:

 1class A
2{

3public:
4...
5    A& operator=(const A& rhs) // 返回類型是一個引用,指向當前對象。
6    {
7        ...
8        return *this;           // 返回左側對象
9    }
10...
11};

這個協議不只適用於以上標準賦值形式,也適用於全部賦值相關運算(+=, -=, *=, 等等),例如:

 1class A
2{

3public:
4...
5    A& operator+=(const A& rhs) // 這個協議適用於 +=, -=, *=, 等等。
6    {
7        ...
8        return *this;           
9    }
10...
11};

注意,這只是個協議,並沒有強制性。若是不遵循它,代碼同樣能夠經過編譯,可是會破壞本來的編程習慣。


細節 08 小結 - 請記住

  • 令賦值操做符返回一個 reference to *this。

細節 09:在 operator= 中處理「自我賦值」

「自我賦值」發生在對象被賦值給本身時:

1class A { ... };
2A a;
3...
4a = a;   // 賦值給本身

這看起來有點愚蠢,但它合法,因此不要認定咱們本身絕對不會那麼作。

此外賦值動做並不老是那麼一眼被識別出來,例如:

1a[i] = a[j]; // 潛在的自我賦值

若是 i 和 j 有相同的值,這即是個自我賦值。再看:

1*px = *py;  // 潛在自我賦值

若是 px 和 py 恰好指向同一個東西,這也是自我賦值,這些都是並不明顯的自我賦值。

考慮到咱們的類內含指針成員變量:

1class B { ... };
2class A
3{

4...
5private:
6    B * pb; // 指針,指向一個從堆分配而得的對象
7}

下面是operator = 實現代碼,表面上看起來合理,但自我賦值出現時並不安全:

1A& A::operator=(const A& rhs) // 一份不安全的operator = 實現版本
2{
3    delete pb;             // 釋放舊的指針對象
4    pb = new B(*rhs.pb);  // 生成新的地址
5    return *this;
6}

這裏的自我賦值的問題是, operator= 函數內的 *this(賦值的目的端)和 rhs 有多是同一個對象。果然如此 delete 就不僅是銷燬當前對象的 pb,它也銷燬 rhs 的 pb。

至關於發生了自我銷燬(自爆/自滅)過程,那麼此時 A 類對象持有了一個指向一個被銷燬的 B 類對象。很是的危險,請勿模仿!

下面來講說如何規避這種問題的方式。


方式一:比較來源對象和目標對象的地址

要想阻止這種錯誤,傳統的作法是在 operator= 函數最前面加一個 if 判斷,判斷是不是本身,不是才進行賦值操做:

1A& A::operator=(const A& rhs) 
2{
3    if(this == &rhs) 
4       return *this;    // 若是是自我賦值,則不作任何事情。
5
6    delete pb;             // 釋放舊的指針對象
7    pb = new B(*rhs.pb);   // 生成新的地址
8    return *this;
9}

這樣錯雖然行得通,可是不具有自我賦值的安全性,也不具有異常安全性:

  • 若是「 new B 」這句發生了異常(申請堆內存失敗的狀況),A 最終會持有一個指針指向一塊被刪除的 B,這樣的指針是有害的。

我舊文裏《C++ 賦值運算符'='的重載(淺拷貝、深拷貝)》在規避這個問題試,就採用的是方式 一,這個方式是不合適的。


方式二:精心周到的語句順序

把代碼的順序從新編排如下就能夠避免此問題,例如一下代碼,咱們只需之一在賦值 pb 所指東西以前別刪掉 pb :

1A& A::operator=(const A& rhs) 
2{
3    A* pOrig = pb;       // 記住原先的pb
4    pb = new B(*rhs.pb); // 令 pb 指向 *pb的一個副本
5    delete pOrig;        // 刪除原先的pb
6    return *this;
7}

如今,若是「 new B 」這句發生了異常,pb 依然保持原狀。即便沒有加 if 自我判斷,這段代碼仍是可以處理自我賦值,由於咱們對原 B 作了一份副本、刪除原 B 、而後返回引用指向新創造的那個副本。

它或許不是處理自我賦值的最高效的方法,但它行得通。


方式三:copy and swap

更高效的方式使用所謂的 copy and swap 技術,實現方法以下:

 1class A
2{

3...
4void swap(A& rhs) // 交換*this 和 rhs 的數據
5
{
6    using std::swap;
7    swap(pb, rhs.pb);
8}  
9... 
10private:
11    B * pb; // 指針,指向一個從堆分配而得的對象
12}
13};
14
15A& A::operator=(const A& rhs)
16{
17    A temp(rhs); // 爲 rhs 製做一份復件(副本)
18    swap(tmp);   // 將 *this 數據和上述復件的數據交換。
19    return *this;
20}

當類裏 operator= 函數被聲明爲「以 by value 方式接受實參」,那麼因爲 by value 方式傳遞東西會形成一份復件(副本),則直接 swap 交換便可,以下:

1A& A::operator=(A rhs) // rhs是被傳對象的一份復件
2{
3    swap(rhs);        // 將 *this 數據和復件的數據交換。
4    return *this;
5}

細節 09 小結 - 請記住

  • 確保當對象自我賦值時,operator= 有良好行爲。其中技術包括比較來源對象和目標對象的地址、精心周到的語句順序、以及 copy-and-swap。
  • 確保任何函數若是操做一個以上的對象,而其中多個對象是同個對象時,其行爲忍然正常。

細節 10:複製對象時勿忘其每個成分

在如下我把複製構造函數和賦值操做符函數,稱爲「copying 函數」。

若是你聲明本身的 copying 函數,那麼編譯器就不會建立默認的 copying 函數。可是,當你在實現 copying 函數,遺漏了某個成分沒被 copying,編譯器卻不會告訴你。

確保對象內的全部成員變量 copying

考慮用一個 class 用來表示學生,其中自實現 copying 函數,以下:

 1class Student
2{

3public:
4    ...
5    Student(const Student& rhs);
6    Student& operator=(const Student& rhs);
7    ...
8private:
9    std:: string name;
10}
11
12Student::Student(const Student& rhs) 
13  : name(rhs.name)   // 複製 rhs 的數據
14{  }
15
16Student& Student::operator=(const Student& rhs)
17{
18    name = rhs.name; // 複製 rhs 的數據
19    return *this;
20}

這裏的每一件事情看起來都很好,直到另外一個成員變量加入戰局:

1class Student
2{

3public:
4    ... // 同前
5private:
6    std:: string name;
7    int score;
8}

這時候遺漏對新成員變量的 copying。大多數編譯器對此不作任何報錯。

結論很明顯:若是你爲 class 添加一個成員變量,你必須同時修改 copying 函數。


確保全部 base class (基類) 成分 copying

一旦存在繼承關係的類,可能會形成此一主題最黑暗肆意的一個潛在危機。試考慮:

 1class CollegeStudent : public Student // 繼承 Student
2{
3public:
4...
5    CollegeStudent(const CollegeStudent& rhs);
6    CollegeStudent& operator=(const CollegeStudent& rhs);
7...
8private:
9    std::string major;
10};
11
12CollegeStudent::CollegeStudent(const CollegeStudent& rhs)
13 : major(rhs.major)
14{ }
15
16CollegeStudent& CollegeStudent::operator=(const CollegeStudent& rhs)
17{
18    major = rhs.major;
19    return *this;
20}

CollegeStudent 的 copying 函數看起來好像複製了 CollegeStudent 內的每同樣東西,可是請再看一眼。是的,它們複製了 CollegeStudent 聲明的成員變量,但每一個 CollegeStudent 還內含所繼承的 Student 成員變量復件(副本),而哪些成員變量卻未被複制。

因此任什麼時候候只要咱們承擔起「爲派生類撰寫 copying 函數」的重則大任,必須很當心地也複製其 base class 成分:

 1CollegeStudent::CollegeStudent(const CollegeStudent& rhs)
2 : Student(rhs),  // 調用 base class 的 copy構造函數
3   major(rhs.major)
4{ }
5
6CollegeStudent& CollegeStudent::operator=(const CollegeStudent& rhs)
7{
8    Student::operator=(rhs); // 對 base class 成分進行賦值動做
9    major = rhs.major;
10    return *this;
11}

因此咱們不只要確保複製全部類裏的成員變量,還要調用全部 base classes 內的適當的 copying 函數。


消除 copying 函數之間的重複代碼

還要一點須要注意的:不要令複製「構造函數」調用「賦值操做符函數」,來減小代碼的重複。這麼作也是存在危險的,假設調用賦值操做符函數不是你指望的。—— 錯誤行爲。

一樣也不要令「賦值操做符函數」調用「構造函數」。

若是你發現你的「複製構造函數和賦值操做符函數」有近似的代碼,消除重複代碼的作法是:創建一個新的成員函數給二者調用


細節 10 小結 - 請記住

  • Copying 函數(複製構造函數和賦值操做符函數)應該確保複製「對象內的全部成員變量」及「全部 base class(基類) 成分」。
  • 不要嘗試以某個 copying 函數實現另一個 coping 函數。應該將共同地方放進第三個函數中,並由兩個 copying 函數共同調用。

最後

能看完或滑到這裏的小夥伴不容易,給大家點贊,感謝大家!

送上大家要的的思惟導圖:

 

 


關注公衆號,後臺回覆「我要學習」,便可免費獲取精心整理「服務器 Linux C/C++ 」成長路程(書籍資料 + 思惟導圖

相關文章
相關標籤/搜索