摘要訊息 : 你所崇拜的《Effective C++》可能過時了?

其實《Effective C++》這本書我已經閱讀過一次了, 但是第一次只是略讀, 所以印象並沒有那麼深刻. 我一直想要針對這本書寫一篇文章, 但是一直沒有下手, 主要就是因為沒有想到以什麼樣的形式去寫. 如果只是照著書上抄一遍, 這沒有任何意義. 如今我將重新閱讀這一本書, 並且以一個新的形式展現我對這本書的理解

背景 : 首先閣下需要了解, C++ 在過去的幾十年裡一直在發展, 截止發文為止, C++ 20 即將發佈. 另外, 本篇文章僅僅對每個條款重要的地方進行講解, 剩餘內容閣下需要自行閱讀. 如果遇到了一些沒什麼好說的條款, 那麼我就只會寫下條款的簡要內容

條款 01 : 視 C++ 為一個語言聯邦

在文章《【C++ Template Meta-Programming】認識樣板超編程 (TMP)》開始, 我們已經提到過了這一條款中的內容, 在 C++ 中存在五種程式設計範式 :

  • 過程式程式設計
  • 物件導向式程式設計
  • 泛型程式設計
  • 函數式程式設計
  • 超編程式程式設計 (樣板超編程)

但是隨著 C++ 的發展, C++ 還至少增加了兩種程式設計範式 :

  • 狀態機超程式設計 (Stateful Meta-Programming)
  • 基於合約的程式設計 (Contract-Based Programming)

先來說一下狀態機超程式設計. 這個實際上是 C++ 11 至 C++ 17 標準中的一個漏洞, 其大致的原理是由於帶有 constexpr 標識的函式是否有定義會影響 noexcept 表達式對其計算的值 :

#include <iostream>

using namespace std;

constexpr void f1();
constexpr void f2() {}

int main(int argc, char *argv[]) {
    cout << noexcept(f1()) << endl;     //false
    cout << noexcept(f2()) << endl;     //true
}

也就是說, 若某個帶有 constexpr 標識的函式被定義, 那麼它就是帶有 noexcept 屬性的. 此時, 我們通過結合 constexprtemplatefriendnoexcept 實作一個編碼期的狀態機 :

#include <iostream>

using namespace std;

constexpr void f(int);

template <typename T>
struct Foo {
    friend constexpr void f(T) {}
};

int main(int argc, char *argv[]) {
    cout << noexcept(f(0)) << endl;     //false
    Foo<int> a;
    cout << noexcept(f(0)) << endl;     //true
}

上述程式碼中, 如果某個變數被宣告為 Foo<int> 型別, 就可以被檢測出來

準確地來說, 這應該是一個技巧, 不過這個技巧被 C++ 標準委員會認定為 ill-formed. 目前, GCC 9+ 對此已經作出了更正, Clang 完全不支援這樣的技巧. 未來 C++ 委員會很可能對這個漏洞進行封堵, 不過我倒是希望這樣的技巧可以被加入標準. 未來, 我也會在 C++ Template Meta-Programming 系列中, 詳細講述這個技巧

再來說說 Contract-Based Programming. 由於 Contract 還有一些問題沒有被解決, 它無緣 C++ 20. 不過它並沒有被拋棄, C++ 標準委員會那邊成立了一個專門的小組解決這些問題. 從目前來看, 雖然 C++ 20 中沒有 Contract 特性, 但是未來它很可能再次被加入 C++ 標準中, 看看 Concept 就知道了

Contract 是為了保證某些函式的引數合理, 因此基於 Contract, C++ 的基於合約的程式設計很可能得到發展. 試想一下現在的程式碼, 我們可能為了某些條件, 而大量編寫 if 陳述式. 而基於合約的程式設計允許我們將這些條件放入合約中, 從而減少函式中某些需要提前判斷的 if 陳述式

條款 02 : 儘量以 const, enuminline 替換 #define

這個條款其實有些過時. 在 C++ 11 下, 我們擁有更好的選擇 : constexprenum class

首先說一下 const. 針對編碼期已經知道的常數, 我們並不是使用 const 或著 enum 來替換, 而是應該使用 constexpr. 這有助於將某些計算提前到編碼期

再來說一說 enum, 不論 enum 是否具名, 列舉中的所有名稱都會污染當前的可視範圍, 而 enum class 並不會. 相比於全域可視範圍下, 使用 enum class 替換 enum 會更好. 而使用列舉定義的型別一般用於函式的參數, 以此來區別不同列舉值在函式中的行為

最後, #define 替換的功能在 C++ 中應該被遺棄. C 中實作一個泛型確實離不開 #define, 但是 C++ 有 template

條款 03 : 盡可能使用 const

我從作業系統和編碼器的角度來講述帶有 const 限定的變數. 首先, C++ 標準規定了對帶有 const 限定的變數進行更改是未定行為 (帶有 mutable 宣告的情形除外). 對於明確使用 const 限定的變數, 編碼器首先會將其放入到一個唯讀的區域, 這裡面的所有變數都不可被更改, 從而保證了 const 變數不變的屬性. 編碼器在對待任何從始至終都確定不變的變數可以進行更大膽的優化. 因此, 我們常常會看到對某些變數加以 const 限定, 程式的運作效率更高

但是如果有時候確實會因為外部原因, 需要對帶有 const 限定的變數進行更改呢? 考慮 volatile :

#include <iostream>

using namespace std;

int main(int argc, char *argv[]) {
    const auto a {0};
    volatile const auto b {1};
    *const_cast<int *>(&a) = 1;
    *const_cast<int *>(&b) = 2;
    cout << a << endl;      //0
    cout << b << endl;      //2
}

volatile 告訴編碼器不要對這個變數進行任何優化, 因為它隨時可能因為外部原因產生變化, 儘管它帶有 const 限定, 是一個常數. 此時, 變數 b 不再和變數 a 一樣被放入唯讀的記憶體區域, 而是存在於一般的記憶體區域. 並且編碼器針對普通變數進行的任何優化, b 都不再享有, 從而導致 b 可以被更改

不過不是特別的情況下, 不要去修改一個帶有 const 限定的變數

在 C++ 中, const 最大的威力莫過於常數參考能夠被繫結到任意值上, 不論它是左值還是右值

const 被用於類別成員函式的時候, 我們需要注意, 我們應該儘量讓成員函式的行為和 const 相符合. 在書中提到了這樣的情況 :

class Foo {
    char *p;
    //...
public:
    char &operator[](unsigned long n) const noexcept {
        return this->p[n];
    }
};

沒錯, 函式 operator[] 本身並沒有修改成員 p 的值, 但是, 外部很可能對 p[n] 的值進行修改, 這並不符合 const 的邏輯定義, 考慮

const Foo f;
f[0] = 'a';

上述程式碼本不應該通過編碼, 因為 fconst 所標識, 而我們確實對 f 進行了修改. 因此, 函式 operator[] 本身不應該被 const 標識. 正確的做法應該是這樣的 :

class Foo {
    char *p;
    //...
public:
    const char &operator[](unsigned long n) const noexcept {
        return this->p[n];
    }
    char &operator[](unsigned long n) noexcept {
        return this->p[n];
    }
};

請注意, 我們不僅僅要視 C++ 為語言聯邦, 它還能和哲學有一些聯繫. 上面這個實例在書中被稱為 bitwise constness

書中還提到了這樣的實例 :

class Foo {
    char *p;
    //...
public:
    const char &operator[](unsigned long n) const noexcept {
        //檢測 n
        //日誌紀錄
        //數據檢測
        //...
        return this->p[n];
    }
    char &operator[](unsigned long n) noexcept {
        //檢測 n
        //日誌紀錄
        //數據檢測
        //...
        return this->p[n];
    }
};

在這種情況下, 書中推薦使用這樣的方式編碼程式碼避免 const 與 non-const 函式的重複 :

class Foo {
    char *p;
    //...
public:
    const char &operator[](unsigned long n) const noexcept {
        //檢測 n
        //日誌紀錄
        //數據檢測
        //...
        return this->p[n];
    }
    char &operator[](unsigned long n) noexcept {
        return const_cast<char &>(static_cast<const Foo &>(*this)[n]);
    }
};

而我想告訴大家, 雖然這種方式絕對安全, 但是這種方式並不推薦. 首先, 它進行了兩次型別轉型, 顯得非常非常複雜, 程式碼也非常難看; 另外, 我想大家肯定都會複製和貼上這種簡單的操作. 而被 const 限定的成員函式中能做的, 在不帶有 const 限定的成員函式中必定也能做, 我們要做的只是進行複製和貼上就可以了. 當然, 如果 operator[] 所做的事情有幾十行甚至上百行之多, 那麼可以使用上述方式. 而一般來說, operator[] 函式非常簡單 :

class Foo {
    char *p;
    //...
public:
    const char &operator[](unsigned long n) const noexcept {
        return this->p[n];
    }
    char &operator[](unsigned long n) noexcept {
        return const_cast<char &>(static_cast<const Foo &>(*this)[n]);      //return this->p[n];
    }
};

相比於直接 return, 這不是更加複雜了嗎... 除非, 你見不得程式碼重複 (真的有的話, 你會非常痛苦...)

條款 04 : 確定物件被使用前已先被初始化

這個條款實際上是針對新手的, 有一定經驗的程式設計師一般都不會犯這種錯誤

C++ 11 的初始化也非常簡單 :

Type x {};

就可以了~ 除非這個型別沒有預設建構子, 沒有預設建構子的型別你想不初始化都不行. 因此, 當你宣告一個變數的時候, 儘量順手將其初始化. 下面這種情況除外 :

Type x;
if(...) {
    x = ...;
}else {
    x = ...;
}

書中提到, 如果某個類別存在多個建構子和數量不少的成員變數, 那麼這個時候程式設計師會有一些重複又無聊的工作. 在 C++ 11 中, 引入了委託建構子, 可以避免一些重複的工作

這個條款中提到了一個非常重要的點, 這個點我們未曾在《C++ 學習筆記》中提到, 即不同檔案中的靜態物件, 其初始化順序無法確定的問題. 書中提到的實例比較複雜, 我在這裡用一個簡單的實例介紹一下 :

1.cpp :

auto n {0};

2.cpp :

extern int n;       //在 2.cpp 中宣告 1.cpp 中的 n
auto m {n + 1};>

假定 1.cpp2.cpp 在同一編碼單位下. 那麼對於 n 先被初始化還是 m 先被初始化, 這沒有明確的順序. 可能是 n 先被初始化, 也可能是 m 先被初始化. 如果是 n 先被初始化, 那麼 2.cpp 中的 m 可以正確地被初始化; 否則, 就會產生未定行為

單例模式 (Singleton 模式) 可以很好地解決這個問題, 它保證了每個物件在被使用之前, 必定經過初始化, 配合 C++ 中的 static 物件僅會被初始化一次的特性, 我們可以這樣編寫程式碼來解決上面提到的問題 :

1.cpp :

inline int &n() noexcept {
    static auto n {0};
    return n;
}

2.cpp :

int &n() noexcept;
inline int &m() noexcept {
    static auto m {n() + 1};
    return m;
}

當然, 書上同樣提到, 在多執行緒的情況下, 上述程式碼不夠完善. 不過目前, Jonny'Blog 中還沒有文章是關於多執行緒的 (預計今年之內會有), 所以我們暫時不涉及關於多執行緒的內容

條款 05 : 了解 C++ 默默編寫並呼叫了哪些函式

這個條款中的內容也有一些過時. 編碼器除了會幫助類別合成預設建構子、複製建構子、解構子和複製指派運算子之外, 在 C++ 11 之後, 編碼器還會為其合成移動建構子和移動指派運算子. 當然, 具體的合成結果是取決於每個類別所具有的成員變數的

我在這裡要特別提醒的是, 如果你覺得某個建構子或著指派運算子可有可無, 你寫出來的和編碼器為你合成的沒有什麼差別, 那麼你就不要去實作它. 考慮這個實例 :

#include <iostream>

using namespace std;
class Foo {
public:
    Foo() {}
    Foo(const Foo &) {}
    ~Foo() {}
    Foo &operator=(const Foo &) {
        return *this;
    }
};
class Bar {};
int main(int argc, char *argv[]) {
    cout << noexcept(Foo {}) << endl;       //false
    cout << noexcept(Bar {}) << endl;       //true
}

也就是說, 編碼器對於某個建構子或著指派運算子所具有的屬性會作特別考慮, 而一般的程式設計師並不會考慮這麼多. 而一個函式是否具有 noexcept 屬性, 這將會影響到編碼器對這個函式的優化結果. 因此, 在可以讓編碼器合成的情況下, 儘量將這些任務交給編碼器

條款 06 : 若不想使用編碼器合成的函式, 就應該明確拒絕

這個條款已經過時了, 在 C++ 11 之後, 若不想讓某個成員函式被任何人呼叫 (甚至包括類別自身), 那麼將這個函式宣告為被刪除的函式即可

條款 07 : 為多型基礎類別宣告 virtual 解構子

這個條款中有提到繼承 C++ 標準樣板程式庫中的 string, 我的建議是如果你不了解標準樣板程式庫中的物件是如何實作的, 那麼就不要繼承或著特製化它. 否則, 特製化的後果就是行為不一致, 而繼承則更加嚴重, 可能會導致多個未定行為甚至嚴重錯誤

另外, 條款中提到 C++ 沒有禁止類別繼承的機制. 但在 C++ 11 後, final 標識已經可以做到類似的事情

條款 08 : 別讓例外情況逃離解構子

對於已知的型別來說, 有一部分例外情況幾乎是可以預測的. 像書中提到的資料庫連線來說, 有一種最常見的例外情況就是連線被斷開兩次, 而第二次呼叫斷開連線的函式, 可能會擲出例外情況. 對於這樣的例外情況, 我們可以選擇吞掉這個例外情況, 在日誌中紀錄一下就可以了. 但是如果遇到和資料庫的伺服器無法連線的情況, 我們就面臨兩種選擇, 是繼續等待還是強行斷線的問題. 此時, 可能會有一個伺服器無法正常連線的例外情況. 對於這樣的例外情況, 我們不可能直接吞掉. 一種比較好地解決方案是保持所有物件不變, 讓客戶來解決這個例外情況. 也就是書上所說的, 要求客戶先行呼叫斷開連線的函式, 然後在解構子中檢測. 如果客戶忘了, 那麼就在解構子中斷開, 那這個時候如果出現例外情況, 直接終結程式運作是合理的 (作業系統會負責回收那些沒有被回收的資源) :

class connection {
private:
    conn db_conn;
    //...
    void close();
    ~connection() {
        if(not db_conn.closed()) {
            try {
                this->close();
            }catch(...) {
                std::abort();
            }
        }
    }
};

但是在 C++ 11 之後, 如果如果確定解構子不會出現其它的例外情況, 那麼可以借用 noexcept 簡化上述程式碼 :

class connection {
private:
    conn db_conn;
    //...
    void close();
    ~connection() noexcept {
        if(not db_conn.closed()) {
            this->close();
        }
    }
};

帶有 noexcept 標識的函式保證一定不會擲出例外情況, 所以一旦有流出的例外情況, 程式會自動被終結

這個條款中是針對一般的程式設計而言的, 對於一般的程式設計, 遵循條款中的內容即可. 我要特別說的是在泛型程式設計情況下, 解構子 (或銷毀函式) 中的例外情況. 對於未知的型別來說, 我們無法預測其什麼時候發生例外情況, 發生什麼樣的例外情況. 考慮下面程式 :

template <typename Iterator>
typename iterator_traits<Iterator>::difference_type distance(Iterator begin, Iterator end) {
    typename iterator_traits<Iterator>::difference_type n {0};
    while(begin++ not_eq end) {
        ++n;
    }
    return n;
}

上面的函式用於計算疊代器之間的距離, 一般來說, 這個函式根本不會有例外情況, 特別是針對 T * 疊代器, 但是你一定能保證它沒有例外情況嗎? 不一定!

typename iterator_traits<Iterator>::difference_type 不一定是內建的型別, 它可能是用戶自訂型別. 可能針對變數 n 的初始化, 已經可以有一個例外情況. 說不定哪個傻子會這麼設計呢?

#include <exception>

class diff_t {
private:
    long n;
private:
    static long check_before_init(long n) {
        if(n == 0) {
            throw std::runtime_error("Argument 0 cannot be accepted!");
        }
        return n;
    }
public:
    diff_t(long n) : n {diff_t::check_before_init(n)} {}
};

不過幸運的是, 函式 distance 的設計者並不需要考慮它們, 因為這樣的例外情況我們不可能知道什麼時候會發生, 如何去處理, 只能夠交給 distance 的呼叫者來處理. 另外, 函式 distance 幾乎不會存在例外情況, 因為 difference_type 一般都是內建型別. 因此, distance 的呼叫者一般也不考慮對函式 distance 的例外處理, 代價就是一旦出現例外情況, 程式無法捕獲例外情況, 會被強行終止罷了 (對於 distance 這樣的函式, 這種情況幾乎不會出現, 因為絕大部分 difference_type 都是內建型別). 不過有一種情況, 我們需要考慮接管例外情況, 就是存在資源配置的情況下. 考慮以下程式碼 :

template <typename T>
void f(const T &value) {
    auto arr {reinterpret_cast<T *>(operator new(sizeof(T) * 10))};
    for(auto i {0}; i < 10; ++i) {
        new (arr + i) T(value);
    }
    //...
    for(auto i {0}; i < 10; ++i) {
        arr[i].~T();
    }
    operator delete(arr);
}

如果 arr[i] = value; 這個陳述式出現例外情況, 一種情況是函式 f 的使用者沒有捕獲這個例外情況, 那麼程式就會直接被終結; 如果函式 f 的使用者捕獲了這個例外情況, 為 arr 所配置的 sizeof(T) * 10 大小的記憶體就會流失

如果 arr[i].~T(); 這個陳述式出現例外情況, 那麼應該如何處理呢? 如果某個元素在解構時發生例外情況, 我的處理非常果斷, 直接終結程式 :

template <typename T>
void destroy(T *p) noexcept {
    p.~T();
}

template <typename T>
void f(const T &value) {
    auto arr {reinterpret_cast<T *>(operator new(sizeof(T) * 10))};
    for(auto i {0}; i < 10; ++i) {
        try {
            new (arr + i) T(value);
        }catch(...) {
            for(auto j {0}; j < i; ++j) {
                destroy(arr + j);
            }
            operator delete(arr);
            throw;
        }
    }
    //...
    for(auto i {0}; i < 10; ++i) {
        destroy(arr + i);
    }
    operator delete(arr);
}

原因很簡單, T 是什麼型別我不知道, 所以我並不清楚如何處理它產生的例外情況, 本來我就已經處於處理例外情況的困境當中了, 結果在解構的時候又發生例外情況, 我只能兩手一攤, 無能為力了...

不過幸運的事情又被我們遇到了, 這樣的例外情況處理一般只會發生在編寫程式庫的人的身上, 一般人不需要這樣的考慮. 因此, 又可以愉快地寫 C++ 了

我曾因 C++ 的例外處理專門寫過一篇文章 :《C++ 中的例外安全》, 更詳細的請觀看這篇文章

條款 09 : 絕不在建構和解構的過程中呼叫 virtual 函式

條款 10 : 令 operator= 回傳一個 reference to *this

條款 11 : 在 operator= 中處理 "自我指派"

條款 10 和條款 11 同樣適用於多載的移動指派運算子. 針對條款 11, 書中只提到一個複製指派運算子的實例, 我在這裡再寫出一個移動指派運算子的實例 :

class bitmap;
class game {
private:
    bitmap *b;
public:
    game &operator=(game &&rhs) noexcept {
        this->b = rhs.b;
        rhs.b = nullptr;
        return *this;
    }
};

一旦某些人不太注意, 出現了這樣的移動 :

game g;
game &rg {g};
game &save_g {g};
//...
//中間做了一些事情, 導致編寫者忘了 &rg == &save_g 這個事實
rg = move(save_g);

此時就會出現自我移動, 第一眼看好像沒什麼問題. 但是仔細思考 : 右值參考同樣是參考, 因此在多載的移動指派運算子中, *this 等同於 rhs. 於是 rhs.b = nullptr; 這條陳述式會導致 this->b == nullptr 這樣的後果, 結果自然就是記憶體流失 :

《Effective C++》讀後感 – 從自己的角度講一講《Effective C++》-Jonny'Blog

另外, 書上提到讓編寫自我指派自己承擔自我指派帶來的效率低下的後果, 我在這裡想說 : 建議大家還是為自己的類別加上自我指派的檢測. 因為一個 if 陳述式通常不會花費太長時間, 何況這種指標比較的檢測, 特別是大家在編寫程式庫的時候. 並且對於移動指派運算子, 當類別中存在資源配置的時候, 自我指派的檢測是不可省略的

條款 12 : 複製物件時勿忘其每一個成分

這個條款同樣適用於 C++ 11 引入的移動建構子和移動指派運算子

條款 13 : 以物件管理資源

條款中提到了 std::auto_ptr, 這個物件在 C++ 11 發布的時候就已經被遺棄了, 在 C++ 17 中正式被刪除, 取而代之的是新加入 C++ 11 中的 std::shared_ptr, std::unique_ptrstd::weak_ptr. 就本條款中的內容而言, tr1 這樣的名稱空間可以直接刪掉了

條款 14 : 在資源管理類別中小心 copying 行為

自從 C++ 11 引入移動語意之後, 許多原本不可被複製的物件就表現出了不可複製但是可以被移動的特點, 很少物件是既不可移動也不可複製的. 在處理物件複製的過程中, 也要小心物件移動中的一些問題

條款 15 : 在資源管理類別中提供對原始資源的訪問

C++ 11 引入的智慧指標 std::unique_ptr 明確了 "所有權" 這樣的概念, 就是必要的時候, 交出資源的所有權. 這是語言充分信任程式設計師的表現, 有把握的程式設計師會保證這些資源不流失

另外, 條款中提到增加隱含的多載轉型函式. 我想說的是, C++ 的隱含型別轉化可能會帶來多個你意想不到的表現, 除非必要, 不然不要隨意增加能夠隱含地被呼叫的多載轉型函式

條款 16 : 成對使用 newdelete 時要採取相同形式

有時候, 我們可能會自己接管記憶體控制, 也就是自行設計 newdelete. 此時, 我們同樣應該遵守這個條款

這個條款最後提到了 typedef, 並且給出了一個使用 typedef 給陣列型別取別名的實例. 在 C++ 11 中, 我們通常使用 using 而不使用 typedef :

#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {
    using int_array_10_by_using = int [10];
    typedef int int_array_10_by_typedef[10];
    cout << boolalpha << is_same<int_array_10_by_typedef, int_array_10_by_using>::value << endl;     //true
}

原因很簡單, 在 C++ 擁有 template 之後, 有些事情只有 using 可以做到, 而且使用 using 宣告別名更加清晰

條款 17 : 以獨立陳述式將配置的物件置入智慧指標

條款18 : 讓介面容易被正確使用, 不易被誤用

條款 19 : 設計 class 猶如設計 type

條款 20 : 寧以 pass-by-reference-to-const 替換 pass-by-value

條款 21 : 必須回傳物件時, 別妄想回傳其 reference

在這本書撰寫的時候, C++ 還沒有引入移動語意, 因此在回傳一個物件的時候, 只能是以複製的方式, 將一個局域物件複製一次. 但是當 C++ 11 引入移動語意之後, 由於局域物件總會在函式終結的時候被銷毀, 所以我們更傾向於移動它. 也就是說, 原來我們這樣寫 :

T f() {
    T value;
    //...
    return value;
}

現在, 我們這樣寫 :

#include <iostream>

T f() {
    T value;
    //...
    return std::move(value);
}

但是, 實際上我們不這樣寫, 編碼器也會幫我們移動, 這是編碼器優化程式碼的一種表現. 我想說的是, 不要過分手動優化. 那麼什麼情況下去手動優化呢?

  • 在一邊寫程式碼一邊思考的時候, 我們的腦中已經大致有了這個程式如何去寫的思路. 這個函式, 寫下一條陳述式, 順手去優化. 比如某一個變數在這一次使用過後就會被遺棄, 那麼順手 move 它
  • 程式碼編寫完成之後, 從優化的角度重新審視它, 該優化的地方就優化

一名優秀的 C++ 程式設計師寫完程式之後, 通常不需要優化, 僅需要偵錯. 因為它們在寫下程式碼的時候, 已經順手優化了, 能夠優化的部分不在於 C++ 的程式碼, 而是邏輯部分或著演算法部分. 邏輯部分是指某些陳述式的順序調換一下, 可能擁有更優的性能; 又或著我們在寫的時候, 無意中寫多了幾個沒用的陳述式. 書中也提到了, 編碼器會幫我們進行優化. 所以, 儘管寫下你認為正確的程式碼, 順手優化一下, 剩下的交給編碼器就可以了

條款 22 : 將成員變數宣告為 private

這個條款的標題有一些不正確, 條款過於排斥 protected 訪問權限. , 在某些時候, 確實是需要將成員變數宣告為 protected 的. 考慮以下程式碼 :

struct eyes;
struct ears;
struct mouth;
struct nose;

class human {
protected:
    eyes eye;
    ears ear;
    mouth m;
    nose n;
};

class Chinese : protected human {};
class Guangdong : private Chinese {};

大家都知道, 每個人的五官都是特有的. 所以, 如果要使用類別描述人類, 那麼五官必定是受保護的. 但是特殊的是, 五官又是每一個人都擁有的東西, 只是它在不同人的臉上表現為不同的形式, 因此它必定是受保護的成員變數, 而並不是私用的或著共有的成員變數. 類別 human 是對人類的一個總體描述, 類別 Chinese 繼承自 human, 顯然 Chinese 要繼承人類所擁有的五官. 而中國那麼大, 每個地域的人可能都有一些不同, 比如嘴巴說話時, 有一些地域的普通話帶有濃厚的方言發音. 因此類別 Guangdong (表示廣東人) 必定也要繼承自類別 Chinese 的基本特點, 然後在類別 Chinese 的基礎上特製化. 這樣一來, 五官的訪問權限就非常明顯了, 它們在廣東人身上必定是私用的. 不妨這樣考慮問題, 如果類別 Guangdong 處於繼承關係中的最高層, 那麼考慮把五官設為 public 的, 同時繼承中都使用 public 繼承, 也就是說, 所有人都可以把自己的五官借給它人? 這顯然不合理. 另外, 如果考慮把五官一開始在類別 human 中就設為 private 的, 那麼類別 Chinese 無法從 human 中繼承到五官, 結果導致所有中國人都沒有五官, 最終廣東人也沒有五官? 這也不合理. 因此, 最好的選項也是惟一正確的選項, 就是採用 protected 訪問權限

所以, 不要和書中一樣去排斥 protected 訪問權限, 存在即合理

條款 23 : 寧以 non-member、non-friend 替換 member 函式

我總結一下這個條款, 這個條款是說一味地去追求物件導向程式設計的守則有時候並不是一個好的選擇 :

  • 如果某個成員函式只是簡單地訪問其它成員函式, 並且訪問的函式對外界都可見, 那麼這個成員函式破壞了類別的封裝性. 原因是我們希望更少地暴露私用成員, 而增加一個成員函式無疑是增加了一個暴露私用成員的機會
  • 非成員函式能夠提供較低的編碼依賴

這條其實無可厚非. 在物件導向程式設計的思想之下, 這條有其道理性. 但是, class 並不總用於物件導向程式設計, 有時候我們僅僅是為了包裝一下不對外公布的成員變數而已. 這個時候, 這條就不再起作用了

考慮以下程式碼 :

test.hpp :

class test {
private:
    //...
public:
    void f1();
    void f2();
    void f3();
    void call_all();
};

void call_all(test &t);

test.cpp :

#include "test.hpp"

void test::f1() {
    //...
}
void test::f2() {
    //...
}
void test::f3() {
    //...
}
/*void test::call_all() {
    this->f1();
    this->f2();
    this->f3();
}*/

void call_all(test &t) {
    t.f1();
    t.f2();
    t.f3();
}

main.cpp :

#include "test.hpp"

int main(int argc, char *argv[]) {
    test t;
    t.f1();
    call_all(t);
}

上述程式碼會產生編碼錯誤嗎? 顯然不會, 因為我們沒有用到 test::call_all, 所以連接器並不會去連接它. 這顯然是降低了編碼依賴度, 而這個條款中所說的編碼依賴度這裡根本不存在

而且對於不同的人來說, 每一個人對於封裝都有著不同的標準, 有些人是 everything that should be private must be private, 而有的人是 everything that should be private can be private. 考慮到之後類別的繼承, 我們把成員變數設為 protected 的, 那麼就說它破壞了封裝性? 這顯然不太合理

所以這個條款帶有一定的哲學性, 至少我是持中立意見的, 我認為具體情況具體分析, 並不一定總要那樣做. 只要最終寫出來的程式沒有巨大的性能差異, 那麼任何一種可行的設計都應該被接受

條款 24 : 若所有參數皆需型別轉換, 請為此採用 non-member 函式

條款 25 : 考慮寫出一個不擲出例外情況的 swap 函式

在這裏作一個提醒 : C++ 11 之後, swap 函式都會從複製轉型為移動. 因此, 不擲出例外情況就更好做到了. 因為大部分類別設計者都會為類別添加一個保證不擲出例外情況的移動建構子和移動指派運算子

條款 26 : 盡可能延後變數定義的出現時間

針對這條, 特別指出, 大家應該儘量遵守用到才宣告, 宣告後立即初始化的原則. 目的書中已經說的非常明確了, 首先減少不必要的建構和解構, 另外也可以減少不必要的複製或移動 (雖然絕大多數移動都快於複製, 但是移動總要耗費一些時間, 不必要的移動能免除就免除)

條款 27 : 儘量少做轉型動作

這一條對於一般的 C++ 使用者而言, 確實應該遵守. 但是對於程式庫設計者而言, 我們無法做到不轉型

對於書上的程式碼 :

class Widget {
public:
    explicit Widget(int size);
    //...
};
void doSomeWork(const Widget &w);
doSomeWork(Widget(15));

我更推薦寫成這樣的形式 :

doSomeWork(Widget {15});

或著更乾脆 :

doSomeWork({15});
這是只有在 C++ 11 下才能做到的, 我們明確這樣的程式碼是初始化, 而非一般意義上的型別轉換

條款 28 : 避免回傳 handles 指向物件內部成分

條款 29 : 為 "例外安全" 而努力是值得的

我在之前的條款中討論過 C++ 的例外安全, 而且我也在那個條款中指出我曾寫過一篇關於例外安全的文章. 如果你已經看懂了那個條款的說明和我寫的關於例外安全的文章, 那麼現在我要告訴你, 什麼時候應該考慮例外安全

首先, 在設計類別的函式的時候, 你應該考慮例外安全. 就像書中指出的這種更改背景的情況, 至少你能夠保證在有例外被捕獲的時候, 你的類別還是可以使用的. 對於例外安全的基本承諾是每一個類別設計者都應該為自己類別考慮的

其次, 在設計函式的時候, 如果你的函式裡面需要配置記憶體或著配置資源, 那麼你也應該適當考慮例外安全. 這個時候, 例外安全的強烈保證也許是比較好的選擇

最後, 在使用已經設計好的類別或者已經設計好的函式的時候, 不要考慮例外安全. 因為這是類別設計者或者函式設計者才需要考慮的事情, 如果因此出現了意外, 這不是你的錯誤, 而是他們的錯誤. 你只管用, 複雜的事情交給他們, 誰讓他們是設計者

作為一個提醒, 任何內建型別或者 POD 型別都是保證不擲出例外情況的, 在使用它們的時候大膽一些

為了讓函式擁有 nothrow 屬性, 應該為其標識 noexcept, 而不是 throw(), 這是過時的東西, 它在 C++ 11 中被遺棄, 在 C++ 17 中被移除

如果你是某個程式庫的作者, 你不僅僅要為例外安全而努力, 而是要保證你的程式庫擁有基本的例外安全保證. 否則, 你就是一名不合格的程式庫作者, 你寫出來的程式庫也是不合格的. 除此之外, 不要執迷於例外安全的強烈保證或者不擲出例外的保證, 那對於程式庫的作者 (特別是泛型程式庫的作者) 而言毫無意義. 你完全不知道例外情況什麼時候發生, 也完全不知道會發生什麼樣的例外情況, 你更不知道例外情況所帶來的後果. 因此, 當例外安全的強烈保證或者不擲出例外保證不太合理的時候, 為你的程式庫添加基本保證是優秀程式庫設計者的選擇. 從我的經驗來說, 使用 C++ 17 引入的 if constexpr 和標頭檔 <type_traits> 有時候能夠幫助你解決一些煩惱, 而且有時候還能提高效率

條款 30 : 透徹了解 inlining 的裏裏外外

這個條款可能需要一些作業系統的知識, 不過好在並不多. 條款的標題所說的是 "透徹了解", 但是實際上條款並不夠透徹, 再加上 C++ 的發展, inline 已經有了新的含義

我們首先講講書中所提到的, 建構子和解構子並不適合 inline 這個觀點 : 這樣的觀點是帶有錯誤傾向的! 沒錯, 建構子和解構子同樣可以被 inline, 但是是否適合 inline, 則要看具體的時機. 有些時候, 為建構子和解構子標識 inline 是非常好的選擇. 這就與書上所說的建構子和解構子不適合 inline 的觀點有些矛盾. 考慮下面這個類別 :

class Foo {
    int a;
    char b;
    double c;
    long d;
public:
    constexpr Foo() noexcept : a {}, b {}, c {}, d {} {}
};

我們看到 Foo 內部全部都是內建型別, 而且不存在任何動態記憶體配置或者動態資源配置. 因此為 Foo 的預設建構子標識 inline 是可以的. 書上提到的情況過於極端 :

class Bar {
    string a;
    string b;
    string c;
    vector<string> d;
    forward_list<vector<string>> f;
    deque<bitset<sizeof(string)>> g;
public:
    Bar();
};

對於這種類別來說, 要想初始化它, 本身就要經歷不少的建構子. 如果都是使用它們的預設建構子也就算了 (有些預設建構子不進行動態記憶體配置), 但是如果要求有初始值呢? 所以將這種類別的建構子或著解構子 inline 本來也就不是什麼好的選擇, 更別提把從 Bar 類別繼承的類別的建構子 inline 掉了

通過上面的分析, 我們大致得到了這個結論 : 對於簡單類別的建構子和解構子是可以將其 inline 掉變為內嵌函式的 (特別是 POD 型別)

我們再來分析, 即使我們將 Bar 類別的建構子 inline 掉了, 就一定有效果嗎? 不一定, 而且大概沒效果. 因為是否 inline 最終取決於編碼器, 而不是我們. 所以, 錯誤的 inline 可能會有編碼器為我們擦屁股

反觀 C++ 標準樣板程式庫, 很多預設建構子如果沒有動態記憶體配置, 同時也沒有很複雜的操作, 那麼我們可以看到它們可能會被 inline 掉 :

《Effective C++》讀後感 – 從自己的角度講一講《Effective C++》-Jonny'Blog

這個是我從 libc++ 中擷取的 vector 程式碼, 我們可以看到 vector 的預設建構子之前被標識了 _LIBCPP_INLINE_VISIBILITY, 這就是 C++ 標準樣板程式庫將一個類別的建構子 inline 掉的一個標誌

inline 除了書上所說, 有一個說明符的意義之外, 還有一個標識作用. 就是限制同一個編碼單位下, 一個名稱僅能對應一個函式或著變數 :

test.cpp :

int a {0};

test2.cpp :

inline int a {999};

main.cpp :

#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {
    extern int a;
    cout << a << endl;
}

猜一下上述程式碼最終的輸出是什麼? 結果是 0

這個特性稱為內嵌變數, 是 C++ 17 引入的, 我還沒有對這個特性所對應的 Proposal 撰寫任何文章. inline 告訴編碼器, 同一個編碼單位下可能有多個同名的變數或著函式, 被 inline 掉的不用理它, 把它視作沒有被 inline 的那個就可以了. test2.cpp 中的變數 ainline 掉了, 那麼它就是指 test.cpp 中的變數 a; 如果它沒有被 inline 掉, 那麼 test.cpp 中的變數 atest2.cpp 中的變數 a 沒有任何關係, 它們屬於兩個不同的變數. 當 main.cpptest.cpptest2.cpp 同時處於同一個編碼單位之下的時候, 就會出現連接錯誤. 連接器會提示你, 連接器找到了兩個可以連接的變數 a, 但是它並不知道該連接哪一個. 如果其中一個變數 ainline 掉了, 不論這個變數 a 做了什麼樣的初始化, 編碼器知道, 這個變數 a 就該忽略掉, 只要去另外一個檔案中連接就行了. 也就是說, inline 保證了同一個編碼單位下, 所有同名的變數或著函式都只保留最初沒有被 inline 的那一個, 剩餘的無論它們有什麼樣的操作, 都會被編碼器無視掉. 另外一個實例是關於函式的 (關於函式的這個特性造就有了, 只有變數那一個特性才是 C++ 17 引入的) :

test.cpp :

void func() {
    std::cout << 1 << std::endl;
}

test2.cpp :

inline void func() {
    std::cout << 2 << std::endl;
}

main.cpp :

int main(int argc, char *argv[]) {
    void func();
    func();     //最終結果 : 1
}

條款 31 : 將檔案間的編碼依存關係降到最低

到這個條款為止, 這本書已經不止一兩次得提到 PIMPL 設計方法 (Pointer Implement). 確實, 這是一個程式設計的好方法. 但是除非必要, 我不推薦過度使用一些設計方法. 這只會增加程式碼的複雜性和編碼的時長. 什麼時候我們需要使用指標呢? 就是兩個類別相互依賴對方的時候, 具體實例可以參考這篇文章《【cplusplus-實戰】複製建構範例》

這個條款中提到了 export 關鍵字, 這裡要說明的是, C++ 11 已經移除了這個關鍵字本身的作用, 並且對這個關鍵字作了保留; C++ 20 在 Module 中又重新引入了它. 它的具體含義這篇文章不涉及, 之後我會撰寫專門針對 C++ 20 Module 的文章

另外, 還有一個點我們曾在《C++ 學習筆記》中提到過, 這裏不妨再說一次. 同一個類別樣板可能會被具現化到同一個型別多次, 例如在同一個編碼單位下, 有多個檔案需要包含標頭檔 <vector>, 而這些檔案中都用到了 vector<int>, 那麼 vector<int> 就會具現化多次. 為了只產生一份 vector<int>, C++ 11 引入了 extern template, 這也是將編碼依存關係降低的一種表現

條款 32 : 確定你的 public 繼承塑模出 is-a 關係

條款中提到的矩形和正方形的問題, 我這裡倒是有個方案, 既可以保持繼承關係, 又使得類別行為正確 :

#include <utility>

using point = std::pair<int, int>;
class rectangle {
protected:
    point up_left;
    point down_right;
public:
    virtual void set_height(int height) noexcept {
        this->down_right.second = height;
    }
    virtual void set_width(int width) noexcept {
        this->down_right.first = width;
    }
    int height() const noexcept {
        return this->down_right.second - this->up_left.second;
    }
    int width() const noexcept {
        return this->down_right.first - this->up_left.first;
    }
};
class square : public rectangle {
public:
    void set_height(int height) noexcept override {
        this->down_right = {height, height};
    }
    void set_width(int width) noexcept override {
        this->set_height(width);
    }
};

條款 33 : 避免遮掩繼承而來的名稱

條款 34 : 區分介面繼承和實作繼承

條款 35 : 考慮 virtual 函式以外的其它選擇

這一條款下提到了兩個設計模式, 我們應該辯證地看待設計模式, 不是一股腦地崇拜, 也不是一味地拋棄

假如目前你正在編寫一般的程式碼, 你所編寫的程式碼之後會被不同的人所查看或著升級, 那麼此時, 採用適當的設計模式是絕對正確的選擇. 因為設計模式本身就是以增加程式碼的複雜性和降低運作效率為代價, 從而給程式碼閱讀者良好的體驗. 針對現代計算機而言, 使用設計模式而導致的運作效率降低幾乎可以忽略不計

假如你正在編寫程式庫, 那麼一味地追求設計模式就是錯誤的 (當然, 有些設計模式是專門針對程式庫而言的, 追求它們是沒錯的), 程式庫的一個重要特徵就是運作效率. 通常, C++ 程式庫的作者甚至不惜以幾十行程式碼替換幾行程式碼來提升那麼一點點的運作效率. 絕大多數的設計模式都是會影響程式碼運作效率的, 特別是經過包裝後的程式碼 (儘管 C++ 編碼器有強大的優化能力, 但是編碼器對於垃圾程式碼也是束手無策). 因此, 當你撰寫程式庫的時候, 儘量少地採用會影響效率的設計模式才是正確的. 另外, 有的設計模式和效率無關, 它好像天生就是為程式庫而誕生的, 比如疊代器模式. 在 C++ 標準樣板程式庫中, 大量採用了疊代器模式, 將容器的細節和演算法分離開來, 從而使得我們在設計演算法的時候不需要考慮容器的實作細節. 在之前的條款中, 我們還提到了單例模式, 它非常有用, 不應該被排斥

條款 36 : 絕不重新定義繼承而來的 non-virtual 函式

說一下這個條款的題外話, C++ 11 引入的 override 可以讓編碼器幫助你識別是否錯誤地覆蓋了基礎類別的函式. 結合這個條款中所說的, 如果我們意外地以為基礎類別中某個函式是虛擬函式 (實際上它不是), 從而在衍生類別中進行了覆蓋, 在函式之後標識 override, 編碼器就可以幫助你檢測你所覆蓋的那個函式在基礎類別中是否存在, 是否為虛擬函式, 如果不是就會擲出編碼錯誤

條款 37 : 絕不重新定義繼承而來的預設引數

條款 38 : 通過組合塑模出 has-a 或 "根據某物實作出"

條款 39 : 明智而審慎地使用 private 繼承

書中看似非常排斥私用繼承 (除了在空基礎類別優化那一塊), 但是實際上在 C++ 標準樣板程式庫中, 除了使用空基礎類別優化之外, 私用繼承到處可見. 假如我們正在設計一個 vector, 由於 vector 中所要實作的東西太多了, 我們希望 vector 從一個基礎類別繼承, 而那個基礎類別中放置了一些基本的操作 :

template <typename T, typename Allocator>
class vector_base {
protected:
    using value_type = T;
    using reference = T &;
    using const_reference = const T &;
    using pointer = T *;
    using const_pointer = const T *;
    //...
protected:
    static pointer allocate() {
        return Allocator::allocate(sizeof(value_type));
    }
    static void destroy(pointer p) noexcept {
        Allocator::destroy(p);
    }
    static pointer realloc(std::size_t);
    static pointer construct(pointer);
    //...
};

當把一些基本的操作和名稱寫好之後, 直接讓 vector 繼承 vector_base :

template <typename T, typename Allocator = std::allocator<T>>
class vector : protected vector_base<T, Allocator> {
    //...
};

這樣, 類別 vector 看起來就乾淨了許多, 而且運作效率和原來一樣. 另外, 這裡使用了 protected 繼承而並非 private 繼承. 這是因為可能還會有類別從 vector 繼承, 然後使用到 vector_base 中的一些成員函式. 當然, 如果 vectorfinal 標識, 那麼就可以直接使用 priavte 繼承. 如果像書中那樣去設計 :

template <typename T, typename Allocator = std::allocator<T>>
class vector : protected vector_base<T, Allocator> {
private:
    class vector_impl : vector_base<T, Allocator> {
        //...
    };
private:
    vector_impl base;
    //...
};

就算是 vector_base 中有成員, 無法使用空基礎類別優化, 那看起來也不是特別美觀, 而且多此一舉. 因此, 書中所使用的設計在這裡並不合適. 這裡又要回到之前設計模式所講述的辯證看待某一個設計的問題 : 如果直覺告訴你某個設計可能不太合適 (不太美觀, 影響運作效率等等原因), 那麼就不要使用它

我不知道是我記錯了還是我再次看書的時候忽略了, 我記得書中提到過 protected 沒有什麼存在價值或著存在價值很小 (類似於這樣的話), 這句話是絕對錯誤的. 上面的例子就可以很好地說明, 不僅僅是 private 繼承, protected 繼承存在也是絕對必要的

另外, 條款中呈現了如果想要阻止衍生類別重新定義虛擬函式的做法. C++ 11 引入的 final 除了可以防止類別被繼承之外, 還可以用於標識函式. 為函式標識 final 說明在繼承體系中, 這個虛擬函式的覆蓋是最終版本, 接下來任何企圖覆蓋的行為都會引起編碼器擲出編碼錯誤

條款 40 : 明智和審慎地使用多繼承

書上的用法可能有誤, 我要區別以下多繼承和多重繼承 :

class A {};
class B : A {};
class C : B {};
class D : C {};

這種一層一層繼承的行為, 稱之為多重繼承

class A {};
class B {};
class C {};
class D {};
class Foo : A, B, C, D {};

這種一次性繼承多個類別的行為, 稱之為多繼承

我在《C++ 學習筆記》中, 就已經明確了這兩個概念. 但是實際使用中, 很多人還是把它們兩個混為一談, 這是錯誤的. 當然, 書上可能說多重繼承是我們所說的多繼承, 而多繼承才是我們所說的多重繼承, 也就是概念互換了一下. 但是我在這篇文章中, 還是想和《C++ 學習筆記》進行統一, 所以我不採用其它說法, 而是採用我上面所用的說法

綜合條款 39 和條款 40, 我覺得還應該再加一個條款 : 明智和審慎地使用虛擬繼承. 原因很簡單, 虛擬繼承會使得類別增加一個指標的大小. 如果你確定某個類別不會被用於多繼承, 如果這個類別又繼承自其它基礎類別, 那麼就不要使用虛擬繼承的方式進行繼承

條款 41 : 了解隱含介面和編碼期多型

C++ 的多型分為編碼器多型和運作期多型. 其中, 使用最多的指標或著配合 virtual 函式能讓類別有不同的表現, 這個屬於運作期多型. 樣板的特製化以及函式多載屬於編碼期多型

條款 42 : 了解 typename 的雙重意義

在 C++ 17 之前, 如果要在樣板參數中放置樣板, 你需要這樣寫 :

template <template <typename, typename> class T>
class Foo {};

template <typename, typename>
class Bar {};
Foo<Bar> f;

看到 Foo 的唯一一個樣板參數, 它是接受兩個引數的樣板, 而 T 在 C++ 17之前只能用 class, 不可以用 typename. 這是可能是因為 C++ 在這裡要強調 T 必定是一個類別樣板, 也就是用戶自訂型別, 而不是內建型別, 所以這裡只能用 class. 在 C++ 17 之後, 就可以用 typename 替換 class 從而做到統一的風格 :

template <template <typename, typename> typename T>
class Foo {};

若要問 C++ 17 以前, typenameclass 在樣板中的表現完全相同嗎? 這不一定, 所以條款一開始給出了錯誤的答案. 但是 C++ 17 之後, 就完全一樣了

條款中還提到了必須要使用 typename 進行標識的一些地方, C++ 20 已經減少了一些不必要 (不是全部) 的 typename 標識

條款 43 : 學習處理樣板化基礎類別中的名稱

這個條款裡面有這樣一段程式碼 :

template <typename>
class Base {
public:
    void Foo() {}
};
template <typename T>
class Derived : public Base<T> {
public:
    void Bar() {
        Foo();      //Compile Error
    }
};

具體原因條款中已經解釋清楚了, 就是編碼器不知道 T 是什麼型別, 所以不會去基礎類別中去尋找函式 Foo. 但是為什麼在函式呼叫之前加上 this-> 就可以通過編碼了呢?

template <typename>
class Base {
public:
    void Foo() {}
};
template <typename T>
class Derived : public Base<T> {
public:
    void Bar2() {
        this->Foo();      //OK
    }
};

這個是因為, C++ 標準規定 C++ 在處理這樣的函式樣板的時候, 名稱搜尋分成兩步 : 第一階段是檢查基本的語法錯誤; 第二階段是當樣板被具現化的時候, 才按照正常的類別去編碼. 當類別沒有被具現化的時候, 編碼器只能處理一些最基本的錯誤, 比如沒有加分號等等. 上述的編碼錯誤也就是能夠在第一階段被找到的基本錯誤. 沒有加上 this-> 的時候, 編碼器並不假設 DerivedBase<T> 中繼承了函式 Foo, 所以對函式 Foo 的呼叫產生了編碼錯誤; 加上了 this-> 之後, 編碼器就假設 DerivedBase<T> 中繼承了 Foo 函式, 即使沒有找到函式 Foo, 但編碼器也不會擲出編碼錯誤. 那麼此時你可能有個疑問, 我隨便寫一個沒有的函式也可以嗎? 當然可以 :

template <typename>
class Base {
public:
    void Foo() {}
};
template <typename T>
class Derived : public Base<T> {
public:
    void Bar2() {
        this->Foo();      //OK
        this->sdfadsfdsfsadfsd();       //OK
    }
};

上述程式碼同樣可以通過編碼, 前提是類別樣板 Derived 沒有被具現化; 否則, 編碼器就會無情地擲出編碼錯誤, 告訴你找不到那個亂七八糟的函式 sdfadsfdsfsadfsd. Clang 和 GCC 都遵循了名稱搜尋分成兩步進行這個標準, 但是 MSVC 沒有遵循. MSVC 把兩步合併成了一步, 只有到樣板被具現化的時候, 才會去檢查是否存在語法錯誤. 因此, 下面的類別樣板在沒有被具現化之前在 MSVC 下是可以通過編碼的 :

template <typename>
class Base {
public:
    void Foo() {}
};
template <typename T>
class Derived : public Base<T> {
public:
    void Bar2() {
        this->Foo();      //OK
        this->sdfadsfdsfsadfsd();       //OK
        dsvsadvsadgdgg      //OK
        你好      //OK
        ;;;;;;;;;;;;;;      //OK
        int a {string("!23')};      //OK
    }
};

但是上述程式碼在遵循標準的 Clang 和 GCC 兩個編碼器下是行不通的

對於 C++ 樣板的兩步名稱搜尋, 我之後還會寫專門的文章來闡述它

條款 44 : 將於參數無關的程式碼抽離 template

這裡的參數指的是樣板參數. 這個條款也涉及了一些作業系統的知識

條款 45 : 運用成員函式樣板接受所有相容型別

條款 46 : 需要型別轉換時請為樣板定義非成員函式

這個條款中提到的把 operator* 宣告為 Rational<T> 的友誼函式, 實際上是使用了 C++ 名稱搜尋中的例外規則, 我們曾在《C++ 學習筆記》中提到過它 : 當編碼器處理 Rational<int>() * 2 這樣的表達時的時候, 會去類別 Rational<T> 中尋找函式. 此時, T 被具現化為 int, 那麼 Rational<int> 和一般的非樣板類別沒什麼不同, 因此編碼器會在 Rational<int> 中找到它 :
friend const Rational operator*(const Rational &, const Rational &);
如若 Rational 不是樣板, 那麼自然可以發生隱含型別轉化, 於是 2 就可以被轉型為 Rational<int>. 但是外層的 operator* 可不是這樣 :

template <typename T>
const Rational<T> operator*(const Rational<T> &, const Rational<T> &) {}

由於外層的 T 並沒有被具現化為 int, 此時 operator* 還是一個函式樣板. 針對樣板, 只有極少數的轉型才可以成功, 隱含型別轉化並不在這些被允許的極少數轉型中. 自然地, 轉型沒有辦法發生, 但是編碼器又找不到其它可以匹配的函式, 於是擲出編碼錯誤

這個知識點被應用於《一份 C++ 試題 (2020 年)》中的第 13 題

條款 47 : 請使用 traits classes 表現型別信息

這個條款在呈現 traits 的作用的時候, 使用了函式多載, 這在文章《【C++ Template Meta-Programming】函式多載與 SFINAE 初步》中已經提到過. 另外, 我在文章《【C++ Template Meta-Programming】Traits 技巧》中還使用了函式樣板的特製化, 呈現了另外一種方法. 但是這兩種方法過於複雜, 一般來說, 我們通常使用 SFINAE, 參考文章《【C++ Template Meta-Programming】SFINAE》

條款中使用運作時型別識別 typeid 的方法我絕不推薦

條款 48 : 認識 template 超編程

如果你想更詳細地學習樣板超編程, 那麼請參考 C++ Template Meta-Programming 系列的文章

條款 49 : 了解 new-handler 的行為

條款中有這樣的函式宣告 :

static void *operator new(std::size_t size) throw(std::bad_alloc);

一般來說, 這樣的宣告沒有問題, 因為我實在想不出來除了 std::bad_alloc 之外, 還有什麼樣的例外情況會在這樣的函式中擲出. 當然, 函式的實作者肯定也會保證這個函式不會擲出除了 std::bad_alloc 以外的例外情況. 但是在 C++ 11 中, 這種宣告方式已經被遺棄, 並且在 C++ 17 中, 這種宣告方式已經從 C++ 中被移除. 這是因為對於絕大多數函式來說, 我們根本沒有辦法預測這個函式會擲出什麼樣的例外情況, 特別是函式樣板. 因此, 明確標識會擲出的例外情況根本沒有任何意義. 於是 C++ 11 之後, 對於一個函式來說, 只有不保證不擲出例外情況和保證不擲出例外情況這兩種. 換句話說, 這兩種情況就是函式不被 noexcept 標識和被 noexcept 所標識. 所以, 不要再像條款中一樣寫出這樣的宣告

條款中還呈現了一個 NewHandlerHolder 這樣的 RAII 類別, 大家應該儘量學習這種實作方式. 這種技巧可以解答《一份 C++ 試題 (2020 年)》中的第 30 題

條款 50 : 了解 newdelete 的合理替換時機

條款 51 : 編寫 newdelete 時需固守常規

當需要的記憶體為 0 的時候, 也就是我們可能這樣呼叫 :

#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {
    auto n {0};
    //do something for n
    //...
    operator new(n);
    malloc(n);
}

條款中已經提到, 這樣必定會造成記憶體流失. 因為當 n == 0 的時候, operator newmalloc 都會至少配置 1 個大小的記憶體區塊. 因此, 我們在配置記憶體之前, 必須要檢查配置的大小是否為 0. 如果配置的大小為 0, 那麼就不要呼叫內建的 operator new 以及 malloc

內建的 operator new 只會配置一部分額外的空間來儲存配置的大小, 但是 operator new[] 可能還需要一部分空間來儲存元素數量. 因此, 在一些記憶體非常吃緊的機器上, 我們通常使用 operator new 而不是使用 operator new[]. 而 C++ 本身允許我們使用 operator new 來配置陣列, 參考 C 的 malloc 就知道了

我這裡再對最後一段話作一個解釋. 假設我們正在撰寫一個繼承體系, 它採用了記憶池, 所以需要自己接管記憶體控制 :

class memory_pool {
public:
    void *operator new(size_t);
    void operator delete(void *);
    void operator delete(void *, size_t);
};
class Foo {
protected:
    void *operator new(size_t n) {
        if(n == 0) {
            throw "size cannot be zero";
        }
        return memory_pool::operator new(n);
    }
    void operator delete(void *p) {
        memory_pool::operator delete(p);
    }
    void operator delete(void *p, size_t n, const Foo &f) {
        memory_pool::operator delete(p, n + sizeof f);
    }
    //...
};
class Bar : public Foo {
private:
    int n;
public:
    Bar() = default;
    virtual ~Bar() = default;
    void f() {
        auto p {Foo::operator new(n + sizeof *this)};
        //...
        Foo::operator delete(p, n, *this);
    }
};

對於 Foo 類別內多載的 operator newoperator delete 來說, 它們只是簡單地將配置工作交給 memory_pool 中多載的 opertaor newopertaor delete. 在 Bar 的成員函式 f 中, 剛開始 p 保存了申請而來的 n + sizeof *this 大小的記憶體, 最後通過 Foo 類別中的 void operator delete(void *, size_t, const Foo &) 函式歸還記憶體. 但是問題就出在這, Foo 類別的設計者沒想到有人會從這個類別繼承, 於是只是歸還了 n + sizeof(Foo) 大小的記憶體. 而實際上, Bar 類別除了比 Foo 類別多了一個 int 型別的成員變數之外, 還需要一個指標大小用於虛擬表. 從這裡可以看出, sizeof(Bar) 至少比 sizeof(Foo) 大 12. 但是, 也就是說, 類別 Bar 中的成員函式 f 在配置記憶體的時候, 配置的大小至少比歸還的大小多了 12 (事實上, 由於記憶體對位, 所以很可能大 16). 由於記憶池中的 operator delet 是自訂的, 所以很可能產生錯誤的歸還. 也就是條款中所說的, operator delete 可能無法正確運作

希望沒有人會這樣撰寫程式碼

我曾針對記憶池專門寫過一篇文章 : 《記憶池》. 如果使用的是這篇文章中實作的記憶池, 那麼 operator delete 無法正確運作, 上述程式碼導致的後果就是記憶池縮水

條款 52 : 寫了 placement new 也要寫 placement delete

除了條款中提到的, 我順便再詳細闡述以下編碼器在特殊情況下為類別合成的解構子 :

class Foo {
protected:
    void *operator new(size_t n) noexcept {
        return nullptr;
    }
    void operator delete(void *, size_t, size_t) noexcept {}
};
class Bar : public Foo {
public:
    virtual ~Bar() = default;
};
int main(int argc, char *argv[]) {
    Bar b;      //Compile Error : attempt to use a deleted function, virtual destructor requires an unambiguous, accessible 'operator delete'
}

上述程式碼會出現編碼錯誤, 這是因為我們沒有為 Foo 類別提供一個和多載的 operator new 對應的 operator delete, 但是 operator delete 又有著一個另外的形式. 對於含有虛擬函式的類別來說, 如果出現上述情況, 這個類別的解構子就會編碼器宣告為被刪除的函式. 你可能會想, 由我自己來實作這個解構子可以嗎?

class Foo {
protected:
    void *operator new(size_t n) noexcept {
        return nullptr;
    }
    void operator delete(void *, size_t, size_t) noexcept {}
};
class Bar : public Foo {
public:
    virtual ~Bar() {}
};
int main(int argc, char *argv[]) {
    Bar b;      //Compile Error : no suitable member 'operator delete' in 'Bar'
}

還是不行. 在這種情況下, 編碼器根本不可能知道應該怎樣去回收帶有虛擬函式類別的記憶體. 有一種不推薦的解決方案, 就是不在 Foo 中宣告或著實作任何多載的 operator delete :

class Foo {
protected:
    void *operator new(size_t n) noexcept {
        return nullptr;
    }
    //void operator delete(void *, size_t, size_t) noexcept {}
};
class Bar : public Foo {
public:
    virtual ~Bar() = default;
};
int main(int argc, char *argv[]) {
    Bar b;      //OK
}

這個時候, 如果編碼器在類別中找不到多載的 operator delete, 就會去外層可視範圍中去尋找, 這可能會出現條款 51 中所說的隱含錯誤的情況. 因此, 最好的辦法還是為 Foo 實作一個多載的 operator delete :

class Foo {
protected:
    void *operator new(size_t n) noexcept {
        return nullptr;
    }
    void operator delete(void *) noexcept {}
    void operator delete(void *, size_t, size_t) noexcept {}
};
class Bar : public Foo {
public:
    virtual ~Bar() = default;
};
int main(int argc, char *argv[]) {
    Bar b;      //OK
}

條款 53 : 不要輕忽編碼器的警告

條款 54 : 讓自己熟悉包括 TR1 在內的標準程式庫

這個條款完全過時, C++ 早已不是當年的 C++. 在這本書發布之後的十幾年裡, C++ 依次經歷了 C++ 11、C++ 14、C++ 17 和 C++ 2a. 當年還沒有加入 C++ 標準樣板程式庫的東西早已納入. 因此, 我們現在要做的就是儘量熟悉這些新納入的東西

這個條款如果一定要更改的話, 我建議改為讓自己熟悉新的 C++ 標準

條款 55 : 讓自己熟悉 Boost

從某個角度來說, Boost 更像是一個實驗性質的 C++ 標準樣板程式庫. 自 C++ 11 起, 很多新納入 C++ 標準樣板程式庫的東西都來自於 Boost

從我個人的角度來看, Boost 中的程式比 C++ 標準樣板程式庫中的程式碼更加容易讀懂, 因為它們擁有更貼近一般程式設計師的名稱風格, 並且變數或著任何名稱堅持不使用下劃線打頭

從實作的角度來說, C++ 標準樣板程式庫中的有些東西依賴於編碼器, 而 Boost 會自己實作. 例如標頭檔 <type_traits> 中的 is_empty (我還找一個 is_base_of), C++ 標準樣板程式庫可能的實作方法是這樣的 :

template <typename T>
struct is_empty {
    using value_type = bool;
    constexpr static inline auto value {__is_empty(T)};
    //...
};

__is_empty 是編碼器提供的一個魔法, 在 C++ 標準樣板程式庫中, 你找不到它的實作, 它的實作由編碼器在編碼時才提供, 它的行為和巨集有一些像 (因為我沒有研究過, 所以我不確定這是不是巨集)

而 Boost 中可能是這樣去實作 is_empty :

template <typename T>
struct is_empty_auxiliary0 : public T {
    double var;
};
struct is_empty_auxiliary1 {
    double var;
};
template <typename T>
struct is_empty {
    using value_type = bool;
    constexpr static inline auto value {sizeof(is_empty_auxiliary0<T>) == sizeof(is_empty_auxiliary1)};
    //...
};

因此, 閱讀 Boost 的程式碼有助於幫助你更好地理解那些 C++ 標準樣板程式庫中的物件是如何實現的

 

最後, 我要說一句. 如果文章中明確提到過時的東西, 那麼它確實在 C++ 11 之後時過時的, 這是公認的, 剩下的只是針對條款的補充.《Effective C++》到目前為止, 仍然是一本 C++ 程式設計師不可不讀的好書, 即使你已經邁入 C++ 11