1. 程式人生 > >學過 C++ 的你,不得不知的這 10 條細節!

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


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

前言

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

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

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

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

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

正文

1 讓自己習慣C++

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

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

首先我們要清楚程式的編譯重要的三個階段:預處理階段,編譯階段和連結階段。

#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 char* const myName = "小林coding";

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

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

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

還有另外一點巨集無法涉及的,就是我們無法利用 #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
4char* const p = myName;       // const pointer, non-const data
5const char* const 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 char& operator[](std::size_t position) const // operator[] for const 物件
5    { return text[position]; }
6
7    char& operator[](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 char& operator[](std::size_t position) const 
5    { 
6        ...    // 邊界檢查
7        ...    // 日誌記錄
8        ...    // 校驗資料完整性
9        return text[position]; 
10    }
11
12    char& operator[](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 char& operator[](std::size_t position) const  // 一如既往
5    { 
6        ...    // 邊界檢查
7        ...    // 日誌記錄
8        ...    // 校驗資料完整性
9        return text[position]; 
10    }
11
12    char& operator[](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 tf