學過 C++ 的你,不得不知的這 10 條細節!(附思維導圖)

前言

我在閱讀 《Effective C++ (第三版本)》 書時做了不少筆記,從中收穫了非常多,也明白爲什麼會書中前言的第一句話會說:

在這裏插入圖片描述

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

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

針對這 10 細節我都用較簡潔的例子來加以闡述,同時也把本文所提及細節中的「小結」總結繪畫成了一副思維導圖,便於大家的閱讀。

後續有時間也會繼續分享後面章節的筆記,喜歡的小夥伴「點擊左上角」關注我~

溫馨提示:本文較長,建議收藏閱讀。

正文

1 讓自己習慣C++

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

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

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

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

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

#define PI 3.14

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

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

作爲一個語言變量,下面的 const 定義的常量 Pi 肯定會被編譯器看到,出錯的時候可以很清楚知道,是這個變量導致的問題:

const doule Pi = 3.14;

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

const char* const myName = "小林coding";

如果是定義常量 string,則只需要在最前面加一次 const,形式如下:

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

#define 不重視作用域,所以對於 calss 的專屬常量,應避免使用宏定義。

還有另外一點宏無法涉及的,就是我們無法利用 #define 創建一個 class 專屬常量,因爲 #define 並不重視作用域。

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

class Student {
private:
    static const int num = 10;
    int scores[num];
};

const int Student::num; // static 成員變量,需要進行聲明

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

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

class Student {
private:
    enum { num = 10 };
    int scores[num];
};

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

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

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

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

int a = 6, b = 5;
int max = MAX(a++, b);

std::cout << max << std::endl;
std::cout << a << std::endl;

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

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

要解釋出錯的原因很簡單,我們把 MAX 宏做簡單替換:

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

在上述替換後,可以發現 a 被累加了 2 次。我們可以通過改進 MAX 宏,來解決這個問題:

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

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

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

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

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

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

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

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

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


細節 01 小結 - 請記住

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

細節 02:儘可能使用 const

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

1. 面對指針,你可以指定指針自身、指針所指物,或兩者都(或都不)是 const:

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

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

std::vector<int> vec;

const std::vector<int>::iterator iter = vec.begin(); // iter 的作用像 T* const
*iter = 10; // 沒問題,可以改變 iter 所指物   
++iter;     // 錯誤! 因爲 iter 是 const     
 
std::vector<int>::const_iterator cIter = vec.begin(); // cIter 的作用像 const T*
*cIter = 10; // 錯誤! 因爲 *cIter 是 const           
++cIter;     // 沒問題,可以改變 cIter                        
  • 如果你希望迭代器自身不可被改動,像指針聲明爲 const 即可(即聲明一個 T* const 指針); —— 這個不常用
  • 如果你希望迭代器所指的物不可被改動,你需要的是 const_iterator(即聲明一個 const T* 指針)。—— 這個常用

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

1.函數返回一個常量值,往往可以降低因程序員錯誤而造成的意外。舉個例子:

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

爲什麼要返回一個 const 對象呢?原因是如果不這樣,程序員就能實現這一的暴力行爲:

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

如果 operator* 返回的 const 對象,可以預防這個沒意義的賦值動作。

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

理由 1 :

它們使得 class 接口比較容易理解,因爲可以得知哪個函數可以改動對象而哪些函數不行,見如下例子:

class MyString
{
public:
    const char& operator[](std::size_t position) const // operator[] for const 對象
    { return text[position]; }
    
    char& operator[](std::size_t position)  // operator[] for non-const 對象
    { return text[position]; }
private:
    std::string text;
};

MyString 的 operator[] 可以被這麼使用:

MyString ms("小林coding"); // non-const 對象
std::cout << ms[0];   // 調用 non-const MyString::operator[]
ms[0] = 'x';          // 沒問題,寫一個 non-const  MyString

const MyString cms("小林coding"); // const 對象
std::cout << cms[0];   // 調用 const MyString::operator[]
cms[0] = 'x';          // 錯誤! 寫一個 const  MyString

注意,上述第 7 行會出錯,原因是 cms 是 const 對象,調用的是函數返回值爲 const 類型的 operator[] ,我們是不可以對 const 類型的變量或變量進行修改的。

理由 2 :

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

class MyString
{
public:

    MyString(const char* str) : text(str)
    { 
        std::cout << "構造函數" << std::endl; 
    }
    
    MyString(const MyString& myString) 
    {
        std::cout << "複製構造函數" << std::endl;
        (*this).text = myString.text;
    }

    ~MyString() 
    { 
        std::cout << "析構函數" << std::endl; 
    }

    bool operator==(MyString rhs) const      // pass by value 按值傳遞
    {
        std::cout << "operator==(MyString rhs) pass by value" << std::endl;
        return (*this).text == rhs.text;
    }
private:
    std::string text;
};

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

int main()
{
    std::cout << "main()" << std::endl;
    MyString ms1("小林coding");
    MyString ms2("小林coding");

    std::cout << ( ms1 == ms2) << std::endl; ;
    std::cout << "end!" << std::endl;
    return 0;
}

輸出結果:

main()
構造函數
構造函數
複製構造函數
operator==(MyString rhs)  pass by value
1
析構函數
end!
析構函數
析構函數

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

我們把 operator== 函數改成 pass by referenc-to-const 後,可以減少上面的副本開銷:

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

再次輸出的結果:

main()
構造函數
構造函數
operator==(const MyString& rhs)  pass by referenc-to-const
1
end!
析構函數
析構函數

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


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

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

class MyString
{
public:
    const char& operator[](std::size_t position) const 
    { 
        ...    // 邊界檢查
        ...    // 日誌記錄
        ...    // 校驗數據完整性
        return text[position]; 
    }
    
    char& operator[](std::size_t position)
    { 
        ...    // 邊界檢查
        ...    // 日誌記錄
        ...    // 校驗數據完整性
        return text[position]; 
    }
private:
    std::string text;
};

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

class MyString
{
public:
    const char& operator[](std::size_t position) const  // 一如既往
    { 
        ...    // 邊界檢查
        ...    // 日誌記錄
        ...    // 校驗數據完整性
        return text[position]; 
    }
    
    char& operator[](std::size_t position)
    { 
        return const_cast<char&>(
                static_cast<const MyString&>(*this)[position]
                ); 
    }
private:
    std::string text;
};

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

  • static_cast<const MyString&>(*this)[position],表示將 MyString& 轉換成 const MyString&,可讓其調用 const operator[] 兄弟
  • const_cast<char&>( ... ),表示將 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:確定對象被使用前先被初始化

內置類型初始化

如果你這麼寫:

int x;

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

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

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

構造函數初始化

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

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

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

class Student {
public:
    Student(int id, const std::string& name, const std::vector<int>& score)
    {
        m_Id = id;          // 這些都是賦值
        m_Name = name;      // 而非初始化
        m_Score = score;
    }
private:
    int m_Id;
    std::string m_Name;
    std::vector<int> m_Score;
};

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

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

    Student(int id,
            const std::string &name,
            const std::vector<int> &score)
            : m_Id(id),
              m_Name(name),  // 現在,這些都是初始化
              m_Score(score) 
     {}                      //  現在,構造函數本體不必有任何動作

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

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

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

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

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

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

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

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

class FileSystem
{
public:
    ...
    std::size_t numDisk() const; // 衆多成員函數之一
    ...
};

extern FileSystem tfs; // 預備給其他程序員使用對象

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

class Directory
{
public:
    Directory( params )
    {
        std::size_t disks = tfs.numDisk(); // 使用 tfs 對象
    }
    ...
};

使用 Directory 對象:

Directory tempDir( params );

那麼現在,初始化次序的重要性凸顯出來了,除非 tfsd 對象在 tempDir 對象之前被初始化,否則 tempDir 的構造函數會用到尚未初始化的 tfs, 就會出現未定義的現象

由於 C++ 對「定義於不同的編譯單元內的 non-local static 對象」的初始化相對次序並無明確定義。但我們可以通過一個小小的設計,解決這個問題。

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

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

class FileSystem
{
public:
    ...
    static FileSystem& getTfs() // 該函數作用是獲取 tfs 對象,
    {
        static FileSystem tfs;  // 定義並初始化一個 local static 對象,
        return tfs;             // 返回一個引用指向上述對象。
    }
    ...
};


class Directory
{
public:
   ...
    Directory( params )
    {
        std::size_t disks = FileSystem::getTfs().numDisk(); // 使用 tfs 對象
    }
    ...
};

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


細節 03 小結 - 請記住

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

2 構造/析構/賦值運算

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

當你寫了如下的空類:

class Student { };

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

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

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

class Student 
{ 
    Student() { ... }                              // 默認構造函數
    Student(const Student& rhs) { ... }            // 複製構造函數
    Student& operator=(const Student& rhs) { ... } // 賦值操作符函數
    ~Student() { ... }                             // 析構函數
};

唯有當這些函數被需要調用時,它們纔會被編譯器創建出來,下面代碼造成上述每一個函數被編譯器產出:

Student stu1;         // 默認構造函數
                      // 析構函數
Student stu2(stu1);   // 複製構造函數
stu2 = stu1;          // 賦值操作符函數

編譯器爲我們寫的函數,來說說這些函數做了什麼?

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

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

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

舉個例子:

template<class T>
class Student
{
public:
    Student(std::string & name, const T& id); // 構造函數
    ...                          // 假設未聲明 operator=
priavte:
    std::string& m_Name;    // 引用
    const T m_Id; // const
};

現考慮下面會發生什麼:

std::string name1("小美");
std::string name2("小林");

Student<int> p(name1, 1);
Student<int> s(name2, 2);

p = 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,這樣既可防止編譯器自動生成這兩個函數。如下例子:

class Student
{
public:
    ...
private:
    ... 
    Student(const Student&);             // 只有聲明
    Student& operator=(const Student&);  // 只有聲明
};

這樣的話,Student 對象就無法操作下面的情況了:

Student stu1;
Student stu2(stu1);   // 錯誤,禁用了 複製構造函數

stu2 = stu1;          // 錯誤,禁用了 賦值操作符函數

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

class Uncopyale
{
protect:              // 允許派生類對象構造和析構
    Uncopyale() {}                
    ~Uncopyale() {}
private:             // 禁止派生類對象copying
    Uncopyale(const Uncopyale&);
    Uncopyale& operater=(const Uncopyale&);
};

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

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

那麼只要某個類需要禁止 copying 動作,則只需要 private 形式的繼承 Uncopyale 基類即可。


細節 05 小結 - 請記住

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

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

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

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

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

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

// 基類
class A 
{
public: 
    A()  // 構造函數
    {
        cout << "construct A" << endl;
    }
    
    ~A() // 析構函數
    {
        cout << "Destructor A" << endl;
    }
};

// 派生類
class B : public A 
{
public: 
    B()  // 構造函數
    {
        cout << "construct B" << endl;
    }
    
    ~B()// 析構函數
    {
        cout << "Destructor B" << endl;
    }
};

int main() 
{
    A *pa = new B();
    delete pa;    // 釋放資源
    
    return 0;
}

輸出結果:

construct A
construct B
Destructor A 

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

消除這個問題的做法很簡單:爲了避免泄漏內存和其他資源,需要把基類的析構函數聲明爲 virtual 析構函數。改進如下:

// 基類
class A 
{
public: 
    ....            // 如上
    virtual ~A()   // virtual 析構函數
    {
        cout << "Destructor A" << endl;
    }
};
...                // 如上

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

非多態性質基類無需聲明 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 函數,因爲這樣的調用不會帶來你預想的結果。

我們看如下的代碼例子,來說明:

// 基類
class CFather 
{
public:
    CFather()
    {
        hello();
    }
    
    virtual ~CFather()
    {
        bye();
    }

    virtual void hello() // 虛函數
    {
        cout<<"hello from father"<<endl; 
    }
    
    virtual void bye() // 虛函數
    {
        cout<<"bye from father"<<endl; 
    }
};

// 派生類
class CSon : public CFather
{ 
public:
    CSon() // 構造函數
    { 
        hello(); 
    }
    
    ~CSon()  // 析構函數
    { 
        bye();
    }

    virtual void hello() // 虛函數
    { 
        cout<<"hello from son"<<endl;
    }
    
    virtual void bye() // 虛函數
    {
        cout<<"bye from son"<<endl; 
    }
};

現在,當以下這行被執行時,會發生什麼事情:

CSon son;

先列出輸出結果:

hello from father
hello from son
bye from son
bye from father

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

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

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

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


細節 07 小結 - 請記住

  • 在構造和析構期間不要調用 virtual,因爲這類調用不會下降至派生類。

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

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

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

同樣有趣的是,賦值採用右結合律,所以上述連鎖賦值被解析爲:

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

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

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

class A
{
public:
...
    A& operator=(const A& rhs) // 返回類型是一個引用,指向當前對象。
    {
        ...
        return *this;           // 返回左側對象
    }
...
};

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

class A
{
public:
...
    A& operator+=(const A& rhs) // 這個協議適用於 +=, -=, *=, 等等。
    {
        ...
        return *this;           
    }
...
};

注意,這只是個協議,並無強制性。如果不遵循它,代碼一樣可以通過編譯,但是會破壞原本的編程習慣。


細節 08 小結 - 請記住

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

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

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

class A { ... };
A a;
...
a = a;   // 賦值給自己

這看起來有點愚蠢,但它合法,所以不要認定我們自己絕對不會那麼做。

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

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

如果 i 和 j 有相同的值,這便是個自我賦值。再看:

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

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

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

class B { ... };
class A
{
...
private:
    B * pb; // 指針,指向一個從堆分配而得的對象
}

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

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

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

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

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


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

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

A& A::operator=(const A& rhs) 
{
    if(this == &rhs) 
       return *this;    // 如果是自我賦值,則不做任何事情。
       
    delete pb;             // 釋放舊的指針對象
    pb = new B(*rhs.pb);   // 生成新的地址
    return *this;
}

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

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

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


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

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

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

現在,如果「 new B 」這句發生了異常,pb 依然保持原狀。即使沒有加 if 自我判斷,這段代碼還是能夠處理自我賦值,因爲我們對原 B 做了一份副本、刪除原 B 、然後返回引用指向新創造的那個副本。

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


方式三:copy and swap

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

class A
{
...
void swap(A& rhs) // 交換*this 和 rhs 的數據
{
    using std::swap;
    swap(pb, rhs.pb);
}  
... 
private:
    B * pb; // 指針,指向一個從堆分配而得的對象
}
};

A& A::operator=(const A& rhs)
{
    A temp(rhs); // 爲 rhs 製作一份復件(副本)
    swap(tmp);   // 將 *this 數據和上述復件的數據交換。
    return *this;
}

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

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

細節 09 小結 - 請記住

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

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

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

如果你聲明自己的 copying 函數,那麼編譯器就不會創建默認的 copying 函數。但是,當你在實現 copying 函數,遺漏了某個成分沒被 copying,編譯器卻不會告訴你。

確保對象內的所有成員變量 copying

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

class Student
{
public:
    ...
    Student(const Student& rhs);
    Student& operator=(const Student& rhs);
    ...
private:
    std:: string name;
}

Student::Student(const Student& rhs) 
  : name(rhs.name)   // 複製 rhs 的數據
{  }

Student& Student::operator=(const Student& rhs)
{
    name = rhs.name; // 複製 rhs 的數據
    return *this;
}

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

class Student
{
public:
    ... // 同前
private:
    std:: string name;
    int score;
}

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

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


確保所有 base class (基類) 成分 copying

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

class CollegeStudent : public Student // 繼承 Student
{
public:
...
    CollegeStudent(const CollegeStudent& rhs);
    CollegeStudent& operator=(const CollegeStudent& rhs);
...
private:
    std::string major;
};

CollegeStudent::CollegeStudent(const CollegeStudent& rhs)
 : major(rhs.major)
{ }

CollegeStudent& CollegeStudent::operator=(const CollegeStudent& rhs)
{
    major = rhs.major;
    return *this;
}

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

所以任何時候只要我們承擔起「爲派生類撰寫 copying 函數」的重則大任,必須很小心地也複製其 base class 成分:

CollegeStudent::CollegeStudent(const CollegeStudent& rhs)
 : Student(rhs),  // 調用 base class 的 copy構造函數
   major(rhs.major)
{ }

CollegeStudent& CollegeStudent::operator=(const CollegeStudent& rhs)
{
    Student::operator=(rhs); // 對 base class 成分進行賦值動作
    major = rhs.major;
    return *this;
}

所以我們不僅要確保複製所有類裏的成員變量,還要調用所有 base classes 內的適當的 copying 函數。


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

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

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

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


細節 10 小結 - 請記住

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

最後

思維導圖:
在這裏插入圖片描述


關注公衆號,後臺回覆「我要學習」,即可免費獲取精心整理「服務器 Linux C/C++ 」成長路程(書籍資料 + 思維導圖)
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章