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

目錄 顯示

0. 前言

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

首先閣下需要了解, C++ 在過去的幾十年裡一直在發展. 截止發文 (文章更新和修正) 為止, C++ 20 即將 (已經) 發佈. 另外, 本篇文章僅僅對每個條款重要的地方進行重新剖析, 剩餘內容閣下需要自行閱讀. 最好是閣下先《Effective C++》的一個條款, 然後看看這篇文章中對應得條款. 又或者一邊看《Effective C++》一邊看這篇文章. 如果遇到了一些沒什麼好說的條款, 那麼我就只會寫下條款的標題.

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

本文的目錄過長, 可能影響前面章節的閱讀體驗, 故本篇文章的目錄預設為隱藏不展開狀態, 需要閣下手動展開.

更新紀錄 :

  • 2022 年 5 月 2 日進行第一次更新和修正.

1. 視 C++ 為一個語言聯邦

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

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

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

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

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

#include <iostream>

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

int main(int argc, char *argv[]) {
    std::cout << noexcept(f1()) << std::endl;     // 輸出結果 : false
    std::cout << noexcept(f2()) << std::endl;     // 輸出結果 : true
}

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

#include <iostream>

constexpr void f(int);

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

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

Code 2 中, 類別樣板 Foo 是否被具現化就可以被檢測出來.

準確地來說, 這應該是一個技巧, 不過這個技巧被 C++ 標準委員會認定為是病式的. 目前, GCC 9+ 對此已經作出了更正, Clang 完全不支援這樣的技巧. 未來的 C++ 標準很可能對這個漏洞進行封堵, 不過我倒是希望這樣的技巧可以被加入標準.

再來說說 Contract-Based Programming. 由於 Contract 還有一些問題沒有被解決, 它無緣 C++ 20. 不過它並沒有被拋棄, C++ 標準委員會那邊成立了一個專門的小組解決這些問題. 從目前來看, 雖然 C++ 20 中沒有 Contract 特性, 但是未來它很可能再次被加入 C++ 標準中, 看看 Concept 就知道了. Contract 是為了保證某些函式的引數合理, 因此基於 Contract, C++ 的基於合約的程式設計很可能得到發展. 試想一下現在的程式碼, 我們可能為了某些條件, 而大量編寫 if 陳述式. 而基於合約的程式設計允許我們將這些條件放入合約中, 從而減少函式中某些需要提前判斷的 if 陳述式.

2. 儘量以 const, enuminline 替換 #define

這個條款其實有些過時. 在 C++ 11 下, 我們擁有更好的選擇 : constexprenum class. 針對編碼期已經知道的常數, 我們並不是使用 const 或著 enum 來替換, 而是應該使用 constexpr. 這有助於將某些計算提前到編碼期. 再來說一說 enum, 不論 enum 是否具名, 列舉中的所有名稱都會污染當前的可視範圍, 而 enum class 並不會. 所以我們應該盡量使用 enum class 替換來 enum . C++ 20 還引入了 using enum 特性 (《【C++ 20】Using Enum》). 最後, #define 替換的功能在 C++ 中應該被遺棄. C 中實作一個泛型確實離不開 #define, 但是 C++ 有 template.

3. 盡可能使用 const

我從作業系統和編碼器的角度來講述帶有 const 限定的變數. 首先, C++ 標準規定了對帶有 const 的變數進行更改是未定行為 (帶有 mutable 宣告的情形除外). 對於明確使用 const 標識的變數, 編碼器首先會將其放入到一個唯讀的區域. 這裡面的所有變數都不可被更改, 從而保證了 const 變數不變的屬性. 編碼器在對待任何從始至終都確定不變的變數可以進行更大膽的優化. 因此, 我們常常會看到對某些變數加以 const 限定, 程式的運作效率更高. 但是如果有時候確實會因為外部原因, 需要對帶有 const 限定的變數進行更改呢? 考慮使用 volatile. volatile 告訴編碼器不要對這個變數進行任何優化, 因為它隨時可能因為外部原因產生變化, 儘管它帶有 const限定, 是一個常數. 此時, 變數不再被放入唯讀的記憶體區域, 而是存在於一般的記憶體區域. 除此之外, 編碼器針對普通變數進行的任何優化, 帶有 volatile 標識的變數都不再享有, 從而它們可以通過 const_cast 去除 const 屬性之後被更改. 不過不是特別的情況下, 不要去修改一個帶有 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 的邏輯定義. 如果某個物件 obj 的型別是 const Foo, 那麼 obj[0] = '0'; 這樣的程式碼是否應該通過編碼? 這個修改雖然不符合 const 的邏輯定義, 但是卻可以通過編碼. 正確的做法應該是為 Foo 再提供一個不帶有 const 標識的多載 operator[]. 請注意, 我們不僅僅要視 C++ 為語言聯邦, 它還能和哲學有一些聯繫. 上面這個實例在書中被稱為 bitwise constness.

書中還提到了如果 operator[] 非常複雜, 那麼實作不帶有 const 標識的 operator[] 和帶有 const 標識的 operator[] 就會導致程式碼比較長. 如果這兩個 operator[] 的功能是一樣的, 那麼我們可以實作一個帶有 const 標識的私用成員函式, 讓 operator[] 呼叫這個函式, 將本來應該由 operator[] 完成的事情委託給這個私用的函式. 另外, 我們還可以通過配合 static_castconst_cast, 讓不帶有 const 標識的那個函式呼叫帶有 const 標識的函式, 避免程式碼過長 :

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

雖然這種方式絕對安全, 但是這種方式並不推薦. 首先, 它進行了兩次型別轉型, 顯得非常非常複雜, 程式碼也非常難看; 另外, 我想大家肯定都會複製和貼上這種簡單的操作. 如果函式不是特別複雜的話, 複製貼上就可以了.

4. 確定物件被使用前已先被初始化

這個條款實際上是針對新手的, 有一定經驗的程式設計師一般都不會犯這種錯誤. C++ 11 引入了列表初始化, 我們在宣告變數的時候可以通過 T v {}; 這樣的方式進行預設初始化. 所以, 當寫下一個變數的時候, 我們應該順手就將其初始化. 書中提到, 如果某個類別存在多個建構子和數量不少的成員變數, 那麼這個時候程式設計師會有一些重複又無聊的工作. 在 C++ 11 中, 引入了委託建構子, 可以避免一些重複的工作.

這個條款中提到了一個非常重要的點, 即不同檔案中的靜態物件, 其初始化順序無法確定的問題. 書中提到的實例比較複雜, 我在這裡用一個簡單的實例介紹一下. 如果在 1.cpp 中存在一個變數 n, 在 2.cpp 中使用 extern 引入了這個變數 n, 然後再用 n 去初始化一個新的變數 m, 即 auto m {n + 42};. 假定 1.cpp2.cpp 在同一編碼單位下. 那麼對於 n 先被初始化還是 m 先被初始化, 這沒有明確的順序. 可能是 n 先被初始化, 也可能是 m 先被初始化. 如果是 n 先被初始化, 那麼 2.cpp 中的 m 可以正確地被初始化; 否則, 就會產生未定行為. 單例模式 (Singleton 模式) 可以很好地解決這個問題, 它保證了每個物件在被使用之前, 必定經過初始化, 配合 C++ 中的 static 物件僅會被初始化一次的特性, 我們可以這樣編寫程式碼來解決上面提到的問題 :

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

當然, 書上同樣提到, 在多執行緒的情況下, 上述程式碼不夠完善.

另外, 在 C++ 20 中引入了 constinit 關鍵字來避免了這種情況, 可以通過《【C++】constexpr, constevalconstinit來學習 constinit.

5. 了解 C++ 默默編寫並呼叫了哪些函式

這個條款中的內容也有一些過時. 編碼器除了會幫助類別合成預設建構子、複製建構子、解構子和複製指派運算子之外, 在 C++ 11 之後, 編碼器還會為其合成移動建構子和移動指派運算子. 當然, 具體的合成結果是取決於每個類別所具有的成員變數的. 我在這裡要特別提醒的是, 如果你覺得某個建構子或著指派運算子可有可無, 你寫出來的和編碼器為你合成的沒有什麼差別, 那麼你就不要去實作它. 考慮這個實例 :

#include <iostream>

class Foo {
public:
    Foo() {}
    Foo(const Foo &) {}
    ~Foo() {}
    Foo &operator=(const Foo &) {
        return *this;
    }
};

class Bar {};

int main(int argc, char *argv[]) {
    std::cout << noexcept(Foo {}) << std::endl;       // 輸出結果 : false
    std::cout << noexcept(Bar {}) << std::endl;       // 輸出結果 : true
}

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

6. 若不想使用編碼器合成的函式, 就應該明確拒絕

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

7. 為多型基礎類別宣告 virtual 解構子

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

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

8. 別讓例外情況逃離解構子

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

#include <cstdlib>

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

當然, 如果在解構子中成員函式 close 擲出例外情況, 就算沒有 std::abort, 程式也會通過 std::terminate 終結. 因為解構子預設情況下會被自動標識 noexcept 或者 throw() (C++ 98/03).

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

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;
}

函式 distance 用於計算疊代器之間的距離, 一般來說, 這個函式根本不會有例外情況, 特別是針對 T * 疊代器, 但是你一定能保證它沒有例外情況嗎? 不一定! typename iterator_traits<Iterator>::difference_type 不一定是內建的型別, 它可能是用戶自訂型別. 可能針對變數 n 的初始化, 已經可以有一個例外情況. 說不定還真有人會這麼設計.

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

template <typename Iterator>
typename iterator_traits<Iterator>::difference_type distance(Iterator begin, Iterator end) {
    typename iterator_traits<Iterator>::difference_type n {0};
    int arr = new int[512];
    while(begin++ not_eq end) {
        ++n;
    }
    delete[] arr;
    return n;
}

Code 7-2 中增加了記憶體配置和回收的操作. 一旦 begin++ not_eq end 或者 ++n 擲出例外情況的時候, arr 所管理的記憶體就會流失. 這個時候我們就要主動接管例外情況的管理了 :

template <typename Iterator>
typename iterator_traits<Iterator>::difference_type distance(Iterator begin, Iterator end) {
    typename iterator_traits<Iterator>::difference_type n {0};
    Iterator arr = new int[512];
    try {
        while(begin++ not_eq end) {
            ++n;
        }
    }catch(...) {
        delete[] arr;
        throw;
    }
    delete[] arr;
    return n;
}

那麼有人可能會問 delete[] arr 這個表達式不會擲出例外情況嗎? 特別是在 catch 中的時候, 如果擲出了又應該如何處理? 答案是無能為力, 不作處理. 原因很簡單, Iterator 具體是什麼型別我不知道, 所以我並不清楚如何處理它產生的例外情況. 本來我就已經處於處理例外情況的困境當中了, 結果在解構的時候又發生例外情況, 我只能兩手一攤, 無能為力了... 當然, 有一種極端的做法是在 catch 中增加一個新的 try-catch 區塊, 一旦有例外情況擲出, 就呼叫 std::terminate 強行終結程式. 不過幸運的事情又被我們遇到了, 這樣的例外情況處理一般只會發生在編寫程式庫的人的身上, 一般人不需要這樣的考慮. 因此, 又可以愉快地寫 C++ 了.

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

9. 絕不在建構和解構的過程中呼叫 virtual 函式

10. 令 operator= 回傳一個 reference to *this

C++ 11 中引入了移動指派運算子, 需要注意的是, 這個條款也適用於移動指派運算子.

11. 在 operator= 中處理 "自我指派"

書中只提到一個複製指派運算子的實例, 我在這裡再寫出一個移動指派運算子的實例 :

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 這樣的後果, 結果自然就是 b 對應的記憶體流失.

另外, 書上提到讓編寫自我指派自己承擔自我指派帶來的效率低下的後果, 我在這裡想說 : 建議大家為自己的類別加上自我指派的檢測, 特別是大家在編寫程式庫的時候. 因為一個 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. 特別是在樣板中, 有些事情只有 using 可以做到, 而且使用 using 宣告別名更加清晰.

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

18. 讓介面容易被正確使用, 不易被誤用

19. 設計 class 猶如設計 type

20. 寧以 pass-by-reference-to-const 替換 pass-by-value

21. 必須回傳物件時, 別妄想回傳其 reference

這本書撰寫的時候, C++ 還沒有引入移動語意, 因此在回傳一個物件的時候, 可能需要將一個局域物件複製一次. 當 C++ 11 引入移動語意之後, 由於局域物件總會在函式終結的時候被銷毀, 所以我們更傾向於移動它. 也就是說, 原來我們 return value; 現在我們可以 return std::move(value);. 但是, 實際上我們不這樣寫, 編碼器也會幫我們完成移動. 編碼器採取的方式是直接在原地進行建構. 這個特性在 C++ 98/03 就有了, 被稱為回傳值優化 (RVO). 本來在回傳一個局域物件的時候, 需要對這個局域複製一次. 但是有了回傳值優化的時候, 編碼器可以直接原地建構這個物件, 不再需要最後一次複製. 使用了移動反而會打破這個優化. 例如

#include <vector>

std::vector<int> func(int x) {
    std::vector<int> vec;
    vec.reserve(x);
    vec.assign(x);
    return vec;
}

std::vector<int> v {func(42)};

編碼器採取的做法可能是直接在 v 對應的記憶體中建構一個 std::vector<int>, 剩下的操作都在 v 中進行. 相當於這樣 :

#include <vector>

std::vector<int> v {};

void func(std::vector<int> &v) {
    v.reserve(42);
    v.assign(42);
}

如果我們將 Code 9-1 中函式 funcreturn 陳述式改為 return std::move(vec);, 那麼就不能享受這個優化了, 從而多了一次移動和解構操作.

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

22. 將成員變數宣告為 private

這個條款過於排斥 protected 訪問權限 (我發現其實整本書都有些排斥 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 Cantonese : private Chinese {};

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

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

23. 寧以 non-member 和 non-friend 替換 member 函式

我總結一下這個條款, 這個條款是說一味地去追求物件導向程式設計的守則有時候並不是一個好的選擇. 如果某個成員函式只是簡單地訪問其它成員函式, 並且訪問的函式對外界都可見, 那麼這個成員函式破壞了類別的封裝性. 原因是我們希望更少地暴露私用成員, 而增加一個成員函式無疑是增加了一個暴露私用成員的機會. 另外, 非成員函式能夠提供較低的編碼依賴. 在物件導向程式設計的思想之下, 這個條款有其道理性. 但是, class 並不總用於物件導向程式設計, 有時候我們僅僅是為了包裝一下不對外公布的成員變數而已. 這個時候, 這個條款就不再起作用了.

而且對於不同的人來說, 每一個人對於封裝都有著不同的標準, 有些人是 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(Widget {15}); 甚至 doSomeWork({15});. 這是只有在 C++ 11 下才能做到的, 我們明確這樣的程式碼是初始化, 而非一般意義上的型別轉換.

28. 避免回傳 handles 指向物件內部成分

29. 為例外安全而努力是值得的

在設計類別的函式的時候, 你應該考慮例外安全. 就像書中指出的這種更改背景的情況, 至少你能夠保證在有例外被捕獲的時候, 你的類別還是可以使用的. 對於例外安全的基本承諾是每一個類別設計者都應該為自己類別考慮的. 在設計函式的時候, 如果你的函式裡面需要配置記憶體或著配置資源, 那麼你也應該適當考慮例外安全. 這個時候, 例外安全的強烈保證也許是比較好的選擇. 在使用已經設計好的類別或者已經設計好的函式的時候, 不要考慮例外安全. 因為這是類別設計者或者函式設計者才需要考慮的事情, 如果因此出現了意外, 這不是你的錯誤, 而是他們的錯誤. 你只管用, 複雜的事情交給他們.

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

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

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

如果想更多了解 C++ 中的例外安全, 可以閱讀《C++ 中的例外安全》.

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

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

inline 的另外一個作用和新的用法可以參考這篇《【C++】另一種 inline》.

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

到這個條款為止, 這本書已經不止一兩次得提到 PIMPL 設計方法 (Pointer Implement). 確實, 這是一個程式設計的好方法. 但是除非必要, 我不推薦過度使用一些設計方法. 這只會增加程式碼的複雜性和編碼的時長. 什麼時候我們需要使用指標呢? 就是兩個類別相互依賴對方的時候. 例如 A 類別在實作的時候用到了 B 類別, 而 B 類別在實作的時候又用到了 A 類別.

這個條款中提到了 export 關鍵字, 這裡要說明的是, C++ 11 已經移除了這個關鍵字本身的作用, 但是對這個關鍵字作了保留. C++ 20 在 Module 中又重新引入了 export, 可以參考《【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++ 標準樣板程式庫中, 大量採用了疊代器模式, 將容器的細節和演算法分離開來, 從而使得我們在設計演算法的時候不需要考慮容器的實作細節. 在之前的條款中, 我們還提到了單例模式, 它非常有用, 不應該被排斥 (寫下這句話的時候, C++ 20 還沒有引入 constinit. 現在既然 C++ 20引入了 constinit, 那麼 C++ 20 中就應該儘量使用 constinit 來初始化而不是單例模式).

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

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

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

38. 通過組合塑模出 has-a 或 “根據某物實作出”

39. 明智而審慎地使用 private 繼承

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

另外, 如果直覺告訴你某個繼承設計可能不太合適 (不太美觀, 影響運作效率等等原因), 那麼就不要使用它.

我不知道是我記錯了還是我再次看書的時候忽略了, 我記得書中提到過 protected 沒有什麼存在價值或著存在價值很小 (類似於這樣的話). 這句話是絕對錯誤的, Code 10 就是最好的例子. 不僅僅是 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 {};

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

41. 了解隱含介面和編碼期多型

C++ 的多型分為編碼器多型和運作期多型. 其中, 使用最多的指標或著配合 virtual 函式能讓類別有不同的表現, 這個屬於運作期多型. 樣板的特製化, SFINAE (《【C++ Template Meta-Programming】函式多載與 SFINAE 初步》) 以及函式多載屬於編碼期多型.

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 從而做到統一的風格. 這個新特性作為該條款的補充.

條款中還提到了必須要使用 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
        this->Foo();        // OK
    }
};

具體原因條款中已經解釋清楚了, 就是編碼器不知道 T 是什麼型別, 所以不會去基礎類別中去尋找函式 Foo. 但是為什麼在函式呼叫之前加上 this-> 就可以通過編碼了呢? 這個是因為, C++ 標準規定 C++ 在處理這樣的函式樣板的時候, 名稱搜尋分成兩步 : 第一階段是檢查基本的語法錯誤; 第二階段是當樣板被具現化的時候, 才按照正常的類別去編碼. 當類別沒有被具現化的時候, 編碼器只能處理一些最基本的錯誤, 比如沒有加分號等等. 上述的編碼錯誤也就是能夠在第一階段被找到的基本錯誤. 沒有加上 this-> 的時候, 編碼器並不假設 DerivedBase<T> 中繼承了函式 Foo, 所以對函式 Foo 的呼叫產生了編碼錯誤; 加上了 this-> 之後, 編碼器就假設 DerivedBase<T> 中繼承了 Foo 函式, 即使沒有找到函式 Foo, 但編碼器也不會擲出編碼錯誤. 那麼此時你可能有個疑問, 我隨便寫一個沒有的函式也可以嗎? 例如在 Code 17this->Foo(); 下一行增加一個 this->safsadgfager3g3g3();. 當然可以, 前提是類別樣板 Derived 沒有被具現化, 否則編碼器就會無情地擲出編碼錯誤. Clang 和 GCC 都遵循了名稱搜尋分成兩步進行這個標準, 但是 MSVC 沒有遵循. MSVC 把兩步合併成了一步, 只有到樣板被具現化的時候, 才會去檢查是否存在語法錯誤. 因此, 在 MSVC 中即使在 Code 17this->Foo(); 下一行增加一個 sdfasgdsgasdg, 不加分號, 都可以通過編碼. 這個在遵循標準的 Clang 和 GCC 兩個編碼器下是行不通的.

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* 還是一個函式樣板. 針對樣板, 只有極少數的轉型才可以成功, 隱含型別轉化並不在這些被允許的極少數轉型中. 自然地, 轉型沒有辦法發生, 但是編碼器又找不到其它可以匹配的函式, 於是擲出編碼錯誤.

47. 請使用 traits classes 表現型別信息

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

條款中使用運作時型別識別 typeid 的方法, 除非是在類別的多型上, 否則我不推薦使用 typeid 運算子.

48. 認識樣板超編程

如果你想更詳細地學習樣板超編程, 那麼在 Jonny'Blog 中搜尋 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 類別, 大家應該儘量學習這種實作方式.

50. 了解 newdelete 的合理替換時機

51. 編寫 newdelete 時需固守常規

當需要的記憶體為 0 的時候, 我們可能有這樣的呼叫 : operator new(0) 或者 malloc(0). 條款中已經提到, 當 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 delete 是自訂的, 所以很可能產生錯誤的歸還. 也就是條款中所說的, 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;      // 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.

53. 不要輕忽編碼器的警告

54. 讓自己熟悉包括 TR1 在內的標準樣板程式庫

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

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

55. 讓自己熟悉 Boost

從某個角度來說, Boost 更像是一個實驗性質的 C++ 標準樣板程式庫. 自 C++ 11 起, 很多新納入 C++ 標準樣板程式庫的東西都來自於 Boost. Boost 中的程式比 C++ 標準樣板程式庫中的程式碼更加容易讀懂, 因為它們擁有更貼近一般程式設計師的名稱風格, 並且變數或著任何名稱堅持不使用下劃線打頭.

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

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

在 C++ 標準樣板程式庫中, 你找不到它的實作, 它的實作由編碼器在編碼時才提供, 它的行為和巨集有一些像. 不過, 它更類似於一個關鍵字, 就像 sizeofalignof 那樣. 而 Boost 中可能是這樣去實作 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++ 標準樣板程式庫中的物件是如何實現的.