摘要訊息 : Jonny 的 C++ 學習筆記.

0. 前言

C++ 學習筆記是本人學習《C++ Primer》第五版的筆記, 其中記錄的都是本人當時不知道的知識點. 所以一些我已經知道了的知識點本文是沒有列出來的. 因為我在大學課程中已經學習過 C++ 這門程式設計語言了, 因此有一定的基礎.

這篇筆記創建於 2018 年 2 月 9 日, 在 2019 年 1 月 21 日完成. 當然, 其實我只用了三個月不到就把《C++ Primer》逐字讀完 (習題除外). 用了將近一年的時間, 才把我書寫的筆記一個字一個字地放到 Jonny'Blog 上, 工作量也是非常大的.

由於一些我已經知道的知識點沒有在本文列出, 所以本文並不適合新人來入門 C++. 另外, 本文包含了《C++ Primer》所有章節, 所以本文的長度是出乎意料的. 閣下可以通過 command (control) + f 搜尋想要的信息. 本文大部分程式碼都在 Apple LLVM version 10.0.0 (clang-1000.11.45.5) 中測試過了.

最後講一下我當時想要學習 C++ 的動機吧. 早在 2017 年的時候, 我就購入了《C++ Primer》. 但是我一直沒想要開始閱讀, 因為我認為我的 C++ 水平雖然不高, 但是學會了指標和類別已經足以. 所以我一直想著怎麼去學好 PHP, 怎麼去學好 Swift. 後來有一門課程是和 2D 遊戲設計有關, 當時我理直氣壯地對著老師說了一句 "樣板沒學過". 到現在我仍然認為這句話極其可笑. 這件事可能只是一個導火索, 真正的原因應該是當時心裡也開始想著與其學習一些新的語言, 不如把已經學過的撿起來, 然後再把它學好. 於是, 我便開始了閱讀 《C++ Primer》.

今天是 2022 年 4 月 8 日, 對這篇文章進行的第一次更新和修正. 我無比慶幸當時我選擇了精讀《C++ Primer》整本書的這個決定. 閣下可以隨意在 Jonny'Blog 中進行搜尋有關 C++ 的文章, 也可以去我的 GitHub (https://github.com/Jonny0201) 上查看兩個公開的項目, 便可以大致了解我目前的 C++ 水平. 相比起當初完成大學 C++ 課程考試之後的我, 現在的我已經提升了太多.

更新紀錄 :

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

1. 概論

std::cerr 主要用於警告與錯誤的輸出; std::clog 主要用於日誌的輸出, 即程式運作時的一般性信息; std::endl 的作用是在輸出信息的時候進行換行並且刷新當前程式關聯的緩衝區.

/* */ 註解不可以是巢狀的; // 註解中的所有內容都會被忽略, 包括巢狀的註解.

如若想要變數 i 遞增, 那麼 i++, ++i, i = i + 1i += 1 的最終效果都是一樣的. 但是如果當成表達式, 那麼就有所區別了. x = i++ 相當於先 x = i 然後 i = i + 1; x = ++ix = i += 1 相當於先 i = i + 1 然後 x = i. 對於遞減也是類似的.

2022 年 4 月 8 日 : 看看當時的我, 連前置遞增運算子和後置遞增運算子都還分不清楚, 還需要記下來.

對於 while(std::cin >> input_data) 這樣的迴圈 (其中, input_data 的型別為 int), 迴圈結束的條件是當 input_data 遇到文件結束標誌 EOF (End Of File) 或者輸入錯誤. 輸入錯誤的情況又分為兩種, 當然這可能視編碼器的情況而定. 當輸入非 int 非浮點數的時候, 會直接結束迴圈; 當輸入浮點數的時候, 會將浮點數轉換為 int 然後執行一次迴圈內的語句, 然後才結束迴圈.

來自標準樣板程式庫的標頭檔應該使用 <> 引入, 非標準樣板程式庫的標頭檔應當使用 "" 引入.

2. 變數與型別

wchar_t 是寬字元型別, char16_tchar32_t 是 Unicode 字元型別.

unsigned int 可以縮寫為 unsigned. 算術表達式中避免使用 charbool.

浮點數型別應該儘量採用 double 而不選用 float, long double 只有在 double 不夠用的時候才採用.

當給一個無號數型別的值超出本身範圍的正值, 那麼這個值會被重新計算為該值和這個型別可以容納的最大值之差減去一. 例如 unsigned int 可以容納的最大值為 4294967295, 如果把 4294967300 指派給一個型別為 unsigned int 的變數, 那麼這個變數最終的值是 4294967300 - 4294967295 - 1 = 4. 如果給一個無號數型別一個負值, 那麼最終的結果會被計算為該型別最大值加上這個負數再加一. 例如將 -5 指派給一個 unsigned int 型別的變數, 那麼這個變數最終的值為 4294967295 + (-5) + 1 = 4294967291. 但是如果給一個有號數型別超出本身範圍的值, 會導致未定行為.

設有一個型別為 unsigned int 的變數 u, 值為 10, 有一個型別為 int 的變數 i, 值為 -42. 我們宣告變數 s : auto s = u + i;, 那麼變數 s 的值應該是什麼? 答案是 4294967264. 這就相當於把 -32 指派給一個 unsigned int 型別的變數. 因此, 不論是有號數型別還是無號數型別, 特別是有號數型別, 我們應該避免其值超過本身可容納的範圍.

在 C++ 中, 0 開頭的數字表示八進制數; 0x0X 開頭的數字表示十六進制數.

若字面值字串較長, 可進行分隔 (空格, 縮進或者換行) :

#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {
	cout << "A really really really long string literal "
			"that spans two lines." << endl;
}

Code 1 最終的輸出是 A really really really long string literal that spans two lines.

對於字面值前綴, 我們有 :

  • u 對應型別 char16_t;
  • U 對應型別 char32_t;
  • L 對應型別 wchar_t;
  • u8 對應型別 char (在 C++ 20 之後, 它不再對應 char, 而是對應 char8_t).

這些字面值前綴不僅可以用於單個字元之前, 也可以用於一個字串之前. 對於字面值後綴, 我們有 :

  • u / U 對應型別 unsigned;
  • f / F 對應型別 float;
  • ll / LL 對應型別 long long;
  • l / L 在整型型別上對應型別 long, 在浮點數型別上對應 long double.

初始化不是指派一個值給變數, 要區分兩個概念. 初始化是宣告變數的時候給定一個初始數, 指派是將當前變數中的值用新的值替代. 要向初始化一個變數, 現在我們有 4 種方法 :

  • int a = 0; : 最常用, 但是最容易與賦值相混;
  • int a = {0}; : 容易與數組的初始化相混;
  • int a {0}; : C++ 11 引入的列表初始化;
  • int a(0); : 容易與函式的調用相混.

在 C++ 11 之後, 列表初始化得到全面的運用, 而且也是最推薦使用的初始化方式. 但是, 列表初始化中使用不同型別的值進行初始化將會產生編碼錯誤, 此時我們需要使用強制型別轉換. 例如 int a {true}; 會產生編碼錯誤, 而 int a {(int)true}; 就是正確的.

函式內使用 extern 關鍵詞將會報錯. 如若一個變數之前沒有被宣告過, 那麼使用 extern 宣告將會無視 extern 的作用. 如果變數已經在其它檔案中被宣告過, 不管是否已經初始化, 使用 extern 的時候如果為其指派新的值, 將會導致編碼錯誤.

我們自己編寫程式碼的時候, 所有名稱不建議使用兩個下劃線開頭或者下劃線緊連著大寫英文字母開頭, 特別是函式之外. 因為這些名稱都是保留給編碼器或者標準樣板程式庫使用的. 但是如果一定要這樣做, 只要不真正產生衝突, 編碼器可能只會給出編碼警告甚至連編碼警告都不會給出.

下面這些型別在函式之內宣告變數的時候不會被初始化, 而在函式之外的地方宣告的話, 如果沒有被初始化, 會預設被指派為 0 :

  • char;
  • signed char;
  • unsigned char;
  • wchar_t;
  • char16_t;
  • char32_t;
  • bool;
  • short;
  • unsigned short;
  • int;
  • unsigned int;
  • long;
  • unsigned long;
  • long long;
  • unsigned long long;
  • float;
  • double;
  • long double;
  • 指標型別 T *.

C++ 為了向下相容很舊的裝置, 提供了一些關鍵字或者符號用來替換常用的符號或者運算子 :

運算子或者符號 替換關鍵字或者符號
&& and
& bitand
~ compl
!= not_eq
|= or_eq
^= xor_eq
&= and_eq
| bitor
! not
|| or
^ xor
{ <%
} %>
[ <:
] :>
# %:
## %:%:

當使用參考這個術語的時候, 一般指的是左值參考 :

int a;
int &b {a};     // 相當於給 a 起了一個別名

在 C++ 11 中, 空指標應該儘量使用 nullptr, 同時避免使用 NULL 或者 0 來指派. void * 指標只能用於儲存記憶體位址, 不能通過 * 運算元存取指標內的具體物件.

帶有 const 標識的變數僅在檔案內有效, 若其它檔案需要使用, 可以用 extern 進行宣告. 我們不能令不帶 const 標識的左值參考指向一個常數表達式 (例如 int &a = 1;).

設有型別為 int 的變數 a, 現在讓帶有 const 標識的左值參考 b 指向 a, 即 const int &b {a};. 那麼 a 是可以進行更改的, 而 b 的值是不可以進行更改, 但是 b 的值會隨著 a 的更改而發生相同變化.

對於一個帶有 const 標識的變數, 可以讓帶有 const 標識的指標指向它, 但是不能用不帶有 const 標識的指標指向它. 例如 aconst int 型別, 那麼我們可以宣告 const int *b {a}; 但是不能宣告 int *c {a};.

頂層 const 標識指標本身, 表明指標的指向不可變. 例如 int *const p 中, p 是不可變的, 而 *p 中保存的值可以改變. 底層 const 標識的是指標指向的值不可變. 例如 const int *p 中, p 是可以指向其它記憶體位址的, 而 *p 中的值是不可變的. 那麼 const int *const p 中, p*p 的值都不可變.

關於頂層 const 和底層 const 更深的理解可以參考這篇文章《【C++ 學習筆記】頂層 const 還是底層 const

常量表達式指的是表達式的值不會改變並且在編碼期可以得到結果. 帶有 constexpr 的變數在編碼期中就要初始化完成. 如果某個變數在編碼期中就可以得到確定的值, 那麼就儘量為其標識 constexpr.

型別別名的定義除了使用 typedef 之外還可以使用 using : using int_type = int.

若用 auto 在一個陳述式中宣告多個變數, 那麼這些變數的型別必須相同. 使用 auto 宣告會忽略頂層 const, 保留底層 const.

decltype 也可以推導變數的型別, 具體用法是 decltype(x). 其中, x 是一個任意型別的變數. decltype(x) 的結果是 x 的具體型別. 如果要使用 decltype 來宣告變數, 必須注意最後推導的型別帶不帶有參考, 如果帶的話, 它必須在宣告的時候就初始化. 設有變數 int &a {0};, decltype(a) 的型別為 int &, 如果想要得到一個不帶有參考的型別, 可以寫成 decltype(a + 0).

C++ 11 規定可以為類別中的成員變數提供一個預設值, 但是不可以使用括號初始化. 也就是說, 對於類別中的成員變數, int x {0};, int x = 0;, 或者 int x = {0}; 這樣的宣告是可以的, 然而使用 int x(0); 這樣的宣告會產生編碼錯誤.

#ifdef 可以檢測某個巨集名稱是否到目前程式碼處已經被定義過了, 而 #ifndef 則檢測某個巨集名稱是否到目前程式碼處都沒有被定義過. 一旦檢測結果為 true, 那麼對接下來的程式碼進行編碼, 直到遇到 #endif 為止. 在 C++ 20 之前, 巨集不會受到 C++ 名稱可視範圍的限制, 一旦引入或者宣告某個巨集, 只要這個巨集沒有被 #undef 掉, 那麼接下來的檔案任意處都可以使用這個巨集. 巨集名稱必須唯一, 而且一般採用全部大寫的形式 :

#define COMPILE

#ifdef COMPILE
// 如果巨集名稱 COMPILE 被定義過, 那麼編碼此處的程式碼
#endif

#ifndef COMPILE
// 如果巨集名稱 COMPILE 沒有被定義過, 那麼編碼此處的程式碼
#endif

3. 字串, 向量與陣列

使用函式 std::getline 函式來讀取整行的話, 空格也會被讀入, 遇到換行符結束這一行的讀入.

std::string 中多載的 + 運算子兩側的運算物件至少要有一個是 std::string 型別, std::string str = "a" + "b"; 就是錯誤的, 要修改為 std::string str = std::string("a") + "b"; 或者 std::string str = "a" + std::string("b");. 對於 std::string str = "a" + "b" + std::string("c");, 可以嘗試修改為 std::string str = "a" + ("b" + std::string("c")).

C++ 標準樣板程式庫的標頭檔 <cctype> 中提供了一些和字元有關的函式 :

  • isalnum(c) : 判斷 c 是否為數字或者字母;
  • isalpha(c) : 判斷 c 是否為字母;
  • iscntrl(c) : 判斷 c 是否為控制字元;
  • isdigit(c) : 判斷 c 是否為數字;
  • isgraph(c) : 判斷 c 是否不為空格且可列印;
  • islower(c) : 判斷 c 是否為小寫字母;
  • isprint(c) : 判斷 c 是否為可列印字元;
  • ispunct(c) : 判斷 c 是否為標點符號;
  • isspace(c) : 判斷 c 是否為空格;
  • issupper(c) : 判斷 c 是否為大寫字母;
  • isxdigit(c) : 判斷 c 是否為十六進制數字;
  • tolower(c) :若 c 為大寫字母, 則回傳對應的小寫字母; 否則, 回傳 c;
  • toupper(c) : 若 c 為小寫字母, 則回傳對應的大寫字母; 否則, 回傳 c.

C++ 11 引入了 Range-For 迴圈, 其基本形式為

for(TYPE VARIABLE : CONTAINER) {
    // ...
}

其中, TYPE 是變數 VARIABLE 的型別, CONTAINER 包含了提供 beginend 兩個成員函式的容器 (例如 std::vector, std::list, std::forward_list, std::string, std::mapstd::set 等) 和陣列. 一般來說, 變數 VARIABLE 的型別 TYPE 我們一般讓 auto 進行推導. 對於來自標準樣板程式庫的容器, 如果迴圈中需要向容器增加元素的話, 應該避免使用 Range-For, 否則可能因為疊代器失效導致未定行為.

要使用類似於 std::vector 這樣的容器中的成員型別別名 size_type, 要明確宣告是由哪種具體型別定義的 : vector<T>::size_type, 而不是 vector::size_type.

只有當元素對應的型別支援比較的時候, 兩個 std::vector<T> 才可以進行比較. 如果兩個 std::vector 類別容量不同, 但是每個對應位置的元素都相同, 那麼元素較少的向量小於元素較多的那個; 若元素有區別, 那麼兩個 std::vector 的大小由對應不同元素的大小決定. 例如 std::vector<int> {1, 2, 3} 小於 std::vector<int> {1, 2, 3, 4}, 而 std::vector<int> {7, 8, 9} 大於 std::vector<int> {1, 2, 3, 4, 5}.

std::vectorstd::string 都支援使用陣列註標運算子存取其中的元素, 但是不能用於添加新的元素. 如果用陣列註標運算子去存取一個不存在的元素, 那麼會導致未定行為, 並且這個錯誤在編碼期很難被發現. 所以, 確保陣列註標一直合法的有效手段, 就是使用 Range-For. 另外, std::vectorstd::string 都定義了自己的疊代器, 其中的成員型別別名 iterator 是可存取可寫入的疊代器, 而 const_iterator 是只能存取不能寫入的疊代器. 也就是說, iterator 相當於 T *, 而 const_iterator 相當於 const T *. 疊代器之間的距離對應的型別是容器內部另外一個型別別名成員 difference_type, 它是一個帶號數整型型別. 不帶有 const 標識的容器物件, 例如 std::vector<int> a;, 我們可以使用 a.begin()a.end() 來回傳頭部疊代器和尾後疊代器, 對應的型別是成員型別別名 iterator, 而 const std::vector<int> b; 回傳的疊代器對應的型別是成員型別別名 const_iterator. 對於 a, 如果我們想得到型別為 const_iterator 的疊代器, 可以使用另外兩個成員函式 cbegincend.

對陣列進行列表初始化時, 允許陣列的第一個維度為空, 編碼器會根據陣列中的元素數量自動計算陣列維度. 但是使用 char [] 的時候, 如果要把陣列當作字串使用, 進行初始化的時候必須以空字元為結束, 否則可能導致未定行為. 陣列的實際運用中, 要儘量避免陣列的直接指派, 因為這屬於編碼器拓展, 目前並不是 C++ 標準特性. 這種程式碼可能在其它編碼器中可能無法通過編碼.

當陣列型別和參考指標結合的時候, 總體型別會變得比較複雜 :

  • int arr[N] : 元素型別為 int 的陣列;
  • int *ptrs[N] : 元素為 int * 型別的陣列;
  • int (*ptr)[N] : 指向一個陣列的指標, 這個陣列中的元素型別為 int;
  • int (&r_arr)[10] : 參考至一個陣列, 這個陣列中的元素型別為 int;
  • int *(&rp_arr)[10] : 參考至一個陣列, 這個陣列中的元素型別為 int *;
int main(int argc, char *argv[]) {
    int arr[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    int *ptrs[10] {arr, arr + 1, arr + 2, arr + 3, arr + 4, arr + 5, arr + 6, arr + 7, arr + 8, arr + 9};
    int (*p_arr)[10] {&arr};
    int (&r_arr)[10] {arr};
    int *(&rp_arr)[10] {ptrs};
}

使用 autodecltype 推導陣列型別的時候, 結果會有不同. 設 a 的型別為 int [N], 若宣告 auto p = a;, 那麼 p 的型別為 int *; 若將宣告改為 decltype(a) p;, 那麼 p 的型別為 int [N].

在標準樣板程式庫的標頭檔 <iterator> 中, 定義了 beginend 函式, 用於獲得指向一個陣列頭部的疊代器和指向陣列尾後的疊代器. 例如宣告 int arr[10], 則 begin(arr) 就是 arr, 而 end(arr)arr + 10.

兩個相同型別的指標可以進行減法運算, 用於計算兩個指標之間的距離, 結果的型別為 std::ptrdiff_t, 它被定義在標頭檔 <cstddef> 中, 在 Apple Clang 14.0.0 編碼器和 macOS 12 系統下, 它是 long 的型別別名. 如果一個指標是空的, 即指向 nullptr, 那麼允許給它加上或者減去 0. 兩個空指標進行減法運算, 最終的結果為 0.

在 C++ 程式設計中應該儘量使用 std::vectorstd::string, 並且使用疊代器來替代指標, 避免使用 C 式 char * 陣列, 內建陣列和指標.

4. 表達式

布林值不應該被寫入運算中, 例如設變數 vbool 型別, 值為 true, 如果令 v = -v, 其結果是什麼呢? 仍然是 true. 因為取負值的操作只有整型型別有, 此時 v 會被提升為 int 型別, true 的預設 int 型別值為 1, 取負號之後得到 -1, 然後指派給 v 要轉型為 bool 型別. 然而對於 int 型別來說, 除了 0 的布林值為 false, 其餘都是 true, 即 -1 的布林值也是 true.

在條件陳述式中, 一般是先完成指派, 再對最終值進行判斷. 那麼, 我們可以把 while(x) {x = ...} 改寫為 while(x = ...) {}. 這也便是第 1 節while(cin >> input_data) 能夠成立的原因.

使用算術指派運算子在效能上會比直接使用算術運算子要高, 並且算數指派運算元滿足右結合律. 即 i += 1 的效能會比 i = i + 1 要高. 從效能方面考慮, 如非必須, 儘量使用前置遞增或者前置遞減運算元. 因為後置遞增或者後置遞減運算元會先保存原來的值並且回傳後才進行遞增或遞減運算.

對於 *p = func(*p++) 這樣的表達式應該避免, 因為會造成未定行為, 不同編碼器可能會有不同的結果.

條件運算子 ?: 支持巢狀型態, 並且滿足右結合律. 位元左移 << 和位元右移 >> 運算子滿足左結合律.

sizeof 運算子可以針對型別或者表達式進行計算, 對表達式計算的時候可以不需要括號, 即 sizeof expr. 在 sizeof 運算子中解參考一個無效指標是一個安全的行為 (因為不會真的去解參考, 編碼器只需要解參考之後的表達式型別), 並且 sizeof 運算子不會將陣列轉換為指標處理. 對 std::stringstd::vector 這樣的容器進行 sizeof 運算時, 只會回傳這些類別固定部分的大小, 不會計入類別中的元素的總體占用空間.

在大多數表達式的計算中, 比 int 型別小的整數型別通常會被晉升為 int 型別 (例如 bool, char, short 等). 如果某個表達式運算的各個運算元的型別不一致, 這些運算元將隱含地轉化為同一種型別 (例如 5l + 3 + '1' 這樣的表達式, 三個運算元都會被提升為較大的 long 型別). 當然, 提升的具體型別可能取決於編碼器. 常數 0 或者字面值 nullptr 可以被轉化為任意型別的指標. 任意型別的指標都可以隱含地轉換為 void * 或者 const void *.

static_cast 也可用於找回 void * 指標中的資料, 轉化時應該保證型別相同並且指標指向的記憶體位址不變. 例如由 int * 轉換到 void * 的指標, 使用 static_cast 時應該轉換為 int *, 而不是 long * 或者其它, 這樣可能導致未定行為. const_cast 只能改變運算物件的底層 const. 例如可以將 const int & 或者 const int * 轉換為 int & 或者 int *, 對於 int *const 這樣的頂層 const 無效. 一旦將底層 const 通過 const_cast 去除之後, 再更改其中的物件會導致未定行為 :

int main(int argc, char *argv[]) {
    int a {};
    const int r {a};
    const_cast<int &>(r) = 1;      // undefined behavior
}

reinterpret_cast 本質上是依賴於裝置的, 若要安全使用 reinterpret_cast, 則要對型別, 編碼器轉換的過程甚至作業系統非常了解. 在實際的程式設計中, 我們應該儘量避免使用 reinterpret_cast (如果程式中存在 reinterpret_cast 形式的強制轉換型別, 那麼說明程式可能存在設計缺陷). 如果一定要使用, 那麼也應該控制變數可視範圍.

下列表格為 C++ 所有運算子優先順序. 優先級 0 為最高, 同優先級一般按照從左到右的順序進行運算. 不過複合指派和巢狀條件運算元是從右到左進行運算 :

運算子 表達式示例 名稱 優先順序
:: ::name 全域名稱空間可視範圍 0
class::name 類別可視範圍
namespace::name 名稱空間可視範圍
. object.member 類別成員存取 (透過物件) 1
-> pointer->member 類別成員存取 (透過物件指標)
[] container[subscript] 陣列註標
() function(argument-list) 函式呼叫
type(argument-list) 函式型類別建構
++ variable++ 後置遞增 2
-- variable-- 後置遞減
typeid typeid(type) 針對型別的 typeid 運算子
typeid(expression) 針對表達式的 typeid 運算子
*_cast<> static_cast<type>(expression)
dynamic_cast<type>(expression)
const_cast<type>(expression)
reinterpret_cast<type>(expression)
型別轉換
++ ++variable 前置遞增 3
-- --variable 前置遞減
~ ~expression 位元取反
! !expression 布林取反
- -expression 取負
+ +expression 取正
* *experssion 解參考
& &expression 取記憶體位址
() (type)expression C-Style 型別轉換
sizeof sizeof expression 物件大小
sizeof(type) 型態大小
sizeof...(pack) 引數包大小
new new type 配置記憶體
new type[size] 配置若干個記憶體空間
delete delete expression 回收記憶體空間
delete[] expression 回收若干個記憶體空間
noexcept noexcept(expression) 檢查例外情況
->* pointer->*pointer-to-member 成員指標存取 (透過指標) 4
.* object.*pointer_to_member 成員指標存取 (透過物件)
* expression1 * expression2 5
/ expression1 / expression2
% expression1 % expression2 取餘
+ expression1 + expression2 6
- expression1 - expression2
<< expression1 << expression2 位元左移 7
>> expression1 >> expression2 位元右移
< expression1 < expression2 小於 8
> expression1 > expression2 大於
<= expression1 <= expression2 小於等於
>= expression1 >= expression2 大於等於
== expression1 == expression2 等於 9
!= expression1 != expression2 不等於
& expression1 & expression2 位元與 10
^ expression1 ^ expression2 位元異或 11
| expression1 | expression2 位元或 12
&& expression1 && expression2 布林與 13
|| expression1 || expression2 布林或 14
?: expression ? if : else 條件運算 15
= variable = expression 指派 16
*= variable *= expression 複合指派 17
/= variable /= expression
%= variable %= expression
+= variable += expression
-= variable -= expression
<<= variable <<= expression
>>= variable >>= expression
&= variable &= expression
|= variable |= expression
^= variable ^= expression
throw throw exception 擲出例外情況 18
, expression1, expression2 逗號 19

5. 陳述式

switch 中, case 後必須跟隨一個整型表達式. 如果要在 case 分支中宣告變數, 那麼需要讓宣告和初始化分離, 或者使用 {} 增加一個可視範圍即可 :

int main(int argc, char *argv[]) {
    switch(argc) {
        case 1:
            int a;
            a = 10;     // OK
            int b {10};     // Error
            {
                int c {10};     // OK
            }
            break;
        default:
            break;
    }
}

goto 不可以將程式的控制從變數的可視範圍之外轉移到可視範圍之內, 即 goto 也不可以跳過帶有初始化的變數宣告.

例外處理通常使用 throw 來擲出例外情況, 表示遇到了程式無法處理的例外問題. try-catch 區塊由 try 區塊和一個或者一個以上的 catch 分支共同構成. try 區塊中通常可能存在例外情況的擲出, 然後擲出的例外情況可能被某個 catch 分支捕捉 (因為可能遇到沒有 catch 分支可以匹配這個例外情況). 當 catch 區塊捕捉到例外之後, 由區塊內的程式碼進行例外處理 :

#include <stdexcept>
#include <iostream>

int main(int argc, char *argv[]) {
    std::runtime_error e1("error 1");
    std::runtime_error e2("error 2");
    int choice;
    std::cin >> choice;
    try {
        switch(choice) {
            case 1:
                throw e1;
            case 2:
                throw e2;
            default:
                throw "hello";
        }
    }catch(std::runtime_error &e) {
        std::cerr << e.what() << std::endl;
    }catch(const char *str) {
        std::cerr << str << std::endl;
    }
}

除去 std::exception 之外, 一些常用的例外處理類別被定義於標準樣板程式庫的標頭檔 <stdexcept> :

  • std::exception : 基本的例外情況, 幾乎所有來源於標準樣板程式庫的其它例外類別都集成自這個類別. 這個類別只能被默認初始化, 而且它是被定義在標頭檔 <exception> 中的, 而不是 <stdexcept>;
  • std::runtime_error : 程式運作時監測到的例外;
  • std::range_error : 運作時, 報告獲得結果超過變數範圍;
  • std::overflow_error : 運作時, 報告算術溢位;
  • std::underflow_error : 運作時, 報告算術反向溢位;
  • std::logic_error : 邏輯錯誤, 程式運作前假定可偵測的錯誤;
  • std::invalid_argument : 邏輯錯誤, 報告無效的引數;
  • std::domain_error : 邏輯錯誤, 報告網域錯誤 (引數結果不存在);
  • std::length_error : 邏輯錯誤, 報告長度問題;
  • std::out_of_range : 邏輯錯誤, 報告引數超出其有效範圍;
  • std::bad_cast : dynamic_cast 轉型失敗的時候擲出;
  • std::bad_alloc : new 表達式失敗.

std::exception, std::bad_caststd::bad_alloc 類別外, 其它類別都應該使用 std::string 物件或者 const char * 進行初始化, 以便例外處理的時候可以查看到例外情況的具體原因. std::runtime_errorstd::logic_error 沒有預設的建構子. 所有的例外類別都只有一個成員函式 what, 回傳一個字面值字串作為提示, 型別為 const char *.

6. 函式

如若想讓一個變數的可視範圍在函式區塊結束之後還能夠存在, 可以在函式內將變數宣告為靜態變數, 即使用 static 進行標識. 靜態變數如果沒有被初始化, 則會被值初始化. 內建型別的靜態空間變數會被初始化為 0. 函式不應該回傳函式內局域變數的參考或者其對應的指標.

函式的宣告可以省略參數名稱, 但不可以省略型別. 在程式碼中, 並不建議省略參數名, 因為這可以讓函式的作用 (包括變數的作用更加明顯), 特別是在函式多載時.

函式要是用一些大型物件 (複製建構子很複雜的類別對應的物件可以稱為大型物件, 例如 std::vectorstd::string 這樣的類別), 此時可以把參數設定為參考型別 (盡量避免 C-Style 的指標型別). 當無需要更改物件的時候, 還可以為參考增加 const 標識, 方便編碼器進行優化. 對於陣列, 我們也可以使用參考的方式傳遞給函式 :

int f(int (&arr)[N]) {
    // ...
    return 0;
}

這樣, 我們便可以知曉陣列的大小, 相比較於 int f(int *arr, int n) 這樣的宣告更加方便且安全. 不過這個函式限定了呼叫範圍, 只可以傳送 N 個元素的 int 型別陣列這樣的引數.

當在主函式 main 中定義了參數 argv, 則使用者的輸入一般要從 argv[1] 開始. 因為 argv[0] 通常用於保存程式的名稱, 而並不是輸入. 主函式 main 的回傳值可以看作程式是否成功執行的標誌. 在 C++ 標準中, 應該只回傳 0, EXIT_FAILUREEXIT_SUCCESS 三個之一. EXIT_FAILUREEXIT_SUCCESS 是巨集名稱, 被定義在標頭檔 <cstdlib> 中. 其實, 主函式的回傳值取決於裝置, 在不同裝置上不同的回傳值可能有不同的效果.

實作函式的過程中, 可能會出現無法確定的參數個數. 如果參數型別相同的情況之下, 可以使用 C++ 11 新標準中的 std::initializer_list<T>, 它被定義於標頭檔 <initializer_list>, 當然引入 <utility> 也可以找到它. std::initializer_list 相當於一個臨時的 std::vector, 具有成員函式 size, begin, endempty, 使用方法和 std::vector 對應的成員函式一樣. 不過, std::initializer_list 中的元素永遠都是常數, 無法改變. 它可以像陣列一樣直接使用列表初始化 : std::initializer_list<T> list {e1, e2, ...}.

C++ 繼承了 C 的可變參數, 即使用省略號 ..., 但是大多數 C++ 型別的物件在傳遞給 ... 時, 都無法正確複製. ... 只能出現在函式參數定義的最後一個位置.

在 C++ 11 中, 可以在函式回傳的時候, 直接回傳一個初始化列表 :

#include <vector>

std::vector<int> f(int x) {
    // ...
    return {1, 2, 3, 4};        // 相當於回傳 vector<int> {1, 2, 3, 4}
}

主函式 main 不可以呼叫自己, 即主函式無法遞迴. 除此之外, 主函式不能多載.

如果要回傳一個指向陣列的指標或者參考, 在 C++ 11 中, 可以使用尾置回傳型別 : auto function(parameter_list) -> returning_type :

int (&)[10] f();        // Error
auto f() -> int (&)[10];        // OK
int (*)[10] f2();       // Error
auto f2() -> int (*)[10];       // OK

或者也可以使用 decltype 進行推導 :

int arr[10];
decltype((arr)) f();
decltype(&arr) f2();

只有在函式功能類似, 但是具體行為因為參數不同而有一定不同的時候才使用函式多載. 函式在多載時, 參數列表要滿足 :

  • 參數列表數量不同;
  • 參數列表數量相同, 但是型別順序不同;
  • 參數列表數量相同, 但是型別存在不同.

不允許回傳型別不同但是參數列表完全相同的情況 (對於 SFINAE 不屬於函式多載, 對 C++ 樣板有很深了解的讀者具體可以閱覽《【C++ Template Meta-Programming】SFINAE》). 另外, 參數列表中無頂層 const 的參數和帶有頂層 const 的參數屬於同一種型別, 但是帶有底層 const 的參數和不帶有低層 const 的參數是屬於不同的型別 :

void f(const int) {}
void f(int) {}        // Error, same as void f(const int), redefinition
void f2(const int &) {}     // OK
void f2(int &) {}       // OK

對於 std::string 這樣的函式參數, 如果函式內不對其進行更改, 那麼儘量使用 const 標識. 然而這種標識也可能發生問題 :

std::string &shorter(const std::string &str1, const std::string &str2) {
    return str1.size() < str2.size() ? str1 : str2;
}

這個時候, 可以把 return 陳述式中的 str1str2 使用 const_cast 移除 const 標識 : return str1.size() < str2.size() : const_cast<std::string &>(str1) : const_cast<std::string &>(str2). 這樣的轉型是安全的, 因為之後我們不再會在函式內部用到它們.

在函式多載中, 當有多餘一個函式可以配對, 但是每一個都不是最佳的選擇的時候, 將會發生編碼錯誤 :

void f(int) {}
void f(long) {}
int main(int argc, char *argv[]) {
    long long c {};
    f(c);       // Error
}

為了確認最佳配對, 編碼器將配對劃分為幾個等級 :

  • 精準匹配 :引數與參數型別完全相同 (包含了陣列型別或者函式型別衰變為陣列指標型別或者函式指標型別, 添加或者忽略掉頂層 const 後引數的型別和參數型別匹配);
  • 通過 const 轉換實現的配對 : 例如原引數的型別為 int &, 但是函式參數型別為 const int &, 那麼此處會發生隱含型別轉化, 為引數添加一個 const 標識;
  • 通過型別提升實現的配對 : 例如函式的參數型別為 int, 但是放入 char, short 這樣的型別可以通過型別提升來匹配, 這裡也會發生一次隱含型別轉化;
  • 通過算術型別轉換或者指標轉換實現的配對 (所有算數型別轉換的級別都是相同的) : 例如函式 f 接受一個型別為 float 的引數, 但是實際傳入的引數為 int 型別, 那麼此處會發生一次隱含型別轉化; 另外, 如果函式 f 接受一個 void * 型別的指標, 但是傳入的指標的型別為 T *, 那麼這裡會將 T * 通過一次隱含型別轉化轉換為 void *;
  • 通過類別型別轉換實現的配對 : 例如呼叫類別中多載的轉型運算子, 相當於發生了一次隱含型別轉化.

函式可以通過宣告, 將其限定在一個可視範圍內 :

void f(int) {
    // f cannot see void f();
    f();        // Error
}

int main(int argc, char *argv[]) {
    void f();       // main is able to see void f();
    f();
}
void f() {}

一旦一個函式內的參數被指派了預設值, 那麼接下來的參數必須都要被指派預設值. 因此我們應當合理設定被指派了預設值的參數順序, 讓使用最多的參數儘量靠前, 使用較少的參數儘量靠後. 在一個既定的可視範圍內, 一個函式的參數只可以被指派一個預設值, 若在下一次宣告函式或者定義函式的時候被指派了不同的預設值, 將會產生編碼錯誤. 在多次宣告中, 允許為沒有指派預設值的參數進行指派預設值. 但是一旦在某次宣告中為中間的參數指派預設值, 必須保證之後的參數在前面的宣告中已經被指派預設值. 預設引數不能來自於一個函式內的局域變數. 當使用變數作為函式的默認值的時候, 當變數改變時, 函式的默認參數值將會也隨之改變 :

#include <iostream>

constexpr char c = 'c';
char c2[] {'d', 'o', 'u', 'b', 'l', 'e', '\0'};

struct S {
    int i {0};
};

void f(int, char, double);
void f(int, char, double = 0.0);        // OK
void f(int, char = c, double);        // OK
void f(int, char, double) {}

void f2(int, int, int);
void f2(int, int = 0, int);     // Error

void f3(char = c2[0]);     // OK

void f4(int = S {}.i);      // OK

void f5(int);

int main(int argc, char *argv[]) {
    f(0);       // same as f(0, 'c', 0.0);
    int a;
    std::cin >> a;
    void f5(int = a);       // Error
    f5();
}

void f5(int) {}

可以使用 inline 標識一個函式為內嵌函式, 內嵌函式可以避免在函式呼叫的開銷. 例如在 Code 17 中, 如果將函式 f 標識為內嵌函式 inline void f(int, char, double) {}, 那麼在主函式中, 編碼器可能直接把 f(0) 這個呼叫展開為空陳述式, 從而不呼叫 f(0), 避免了函式呼叫時額外的消耗. inline 只是向編碼器發出一個請求, 編碼器可以不接受這個請求. 一般地說, 內嵌機制用於優化規模比較小, 流程直接和頻繁被呼叫的函式, 編碼器基本不會接受內嵌函式的遞迴. 完全被定義在 classstruct 或者 union 內的函式, 無論是成員函式還是友誼函式, 都是一個隱含的 inline 函式.

對於 inline 的詳細描述, 閣下可以參考文章《【C++】另一種 inline.

一個帶有 constexpr 的函式表示這個函式有機會在編碼器得到最終值, 那麼就可以避免在運作時呼叫這個函式產生的消耗. 當然, 這個函式是否可以在編碼期完成計算, 這個判斷是由編碼器來完成的. 如果一個 constexpr 函式無法在編碼期完成計算, 那麼這個函式會轉化為一個普通函式在運作時進行計算. 如果要實作 constexpr 函式, 要確保回傳型別以及所有的參數的型別都是字面值, 且函式內部只能存在 return 陳述式, 空陳述式, 型別別名宣告和 static_assert 判斷陳述式. 帶有 constexpr 標識的函式也是一個隱含的 inline 函式. constexpr 標識和 inline 標識沒有順序的限制.

我們一般將 inline 函式和 constexpr 函式的宣告直接放在標頭檔中. 一個函式在多次宣告中, 只要有一次被 inline 標識, 那麼無論其它宣告和實作中是否標識 inline, 它都將向編碼器發送內嵌請求. 而對於 constexpr, 必須嚴格進行標識, 不能存在某個宣告標識, 而其它宣告或者定義省略 constexpr 的情況.

當程式中含有一些只在除錯時使用的程式碼, 而實際發布的時候需要屏蔽的程式碼, 可以借助 NDEBUGassert 來完成. 這兩個名稱都是巨集, 被定義在標頭檔 <cassert> 中. assert 類似於函式, 它接受一個表達式 : assert(expression). 其中, 表達式的值必須是布林型別或者可以通過隱含型別轉化轉換為布林型別. 當 expression 回傳 true, 那麼 assert 什麼都不做; 否則, 就會直接終結程式並且輸出錯誤信息. 另外, assert 依賴於一個叫做 NDEBUG 的前處理巨集. 假設定義了 NDEBUG 這個巨集, 那麼 assert 什麼都不做, 也就相當於 assert 這一行陳述式不存在; 否則, assert 將會正常執行檢測. 當然, 我們也可以不通過 NDEBUG 進行偵錯, 自己定義偵錯巨集 :

#include <cassert>

//#define DO_NOT_DEBUG

int main(int argc, char *argv[]) {
    int a = 10;
#ifndef DO_NOT_DEBUG
    assert(a < 5);
#endif
}

編碼器內建了一些巨集 :

  • __func__ : 當前函式名稱;
  • __FILE__ : 檔案名稱;
  • __LINE__ : 當前行;
  • __TIME__ : 編碼時間;
  • __DATE__ : 編碼日期.

函式指標是一種指向函式的指標. 在函式指標的指派中, 取位址運算子 & 可以被省略. 對應地, 在使用函式指標的時候, 解參考運算子 * 也可以省略. 但是一旦在函式指標中使用了解參考運算子, 由於運算子優先級別不同, 括號必不可少. 由於函式型別比較複雜, 為了簡化宣告, 我們一般採用 typedef, using, auto 或者 decltype 來簡化程式碼. 如果使用 decltype 進行推導, 我們要注意它最終得到的是函式型別, 而不是函式指標. 要得到函式指標, 必須寫成 decltype(f) * 的形式 :

void f(int, char) {}
void (*function_pointer)(int, char) = f;        // same as void (*function_pointer)(int, char) = &f; 

typedef void (*)(int, char) f_ptr_type;
using f_ptr_type2 = void (*)(int, char);
f_ptr_type fp2 {f};
f_ptr_type2 fp3 {f};

auto fp4 {f};
decltype(f) *fp5 {f};

int main(int argc, char *argv[]) {
    function_pointer(0, 1);     // same as f(0, 1);
    (*function_pointer)(0, 1);      // same as f(0, 1);
}

對於函式指標來說, 我們將指標的名稱寫在解參考運算子之後並且使用括號包裹, 那麼我們可以仿照這個寫法, 把名稱變成函式名稱, 之後跟著參數列表 :

T (*function_name(parameter-list1))(parameter-list2),

於是我們便得到了一個回傳型別為函式指標 T (*)(parameter-list2), 函式名稱為 function_name, 參數列表為 parameter-list1 的函式. 當然, 如果使用尾置回傳型別, 就可以寫成 auto function_name(parameter-list1) -> T (*)(parameter-list2), 這樣更加清晰. 更進一步地, 我們可以通過 decltype, usingtypedef 來簡化.

7. 類別

this 是一個常數指標, 所以保存的地址不能變更. 若類別的型別為 T, 則 this 的型別就是 T const *, 而不是 const T *. 如果在成員函式參數列表之後使用 const 標識函式, 就代表在這個成員函式之內, 類別內的所有成員都無法更改, 只能存取. 此時, 在成員函式之內, this 的型別為 const T *const. 對於那些沒有改變成員變數的函式, 我們應該盡量為其標識 const. 如果再使用 volatile 標識成員函式, 那麼 this 的型別就變為 const volatile T *const. 對於類別之內的某些成員變數, 如果想要其可以在被 const 標識的成員函式之內仍然可以進行更改, 可以在成員變數前面標識 mutable. 不過, mutableconst 是不可以共存的, 這兩個共存也是無意義的. 帶有 const 標識和不帶有 const 標識的同名成員函式屬於函式多載.

對於一個帶有 const 標識的類別物件, 只有在完成建構之後才能獲得常數屬性.

在 C++ 11 中, 如果建構子, 指派運算子或者解構子需要編碼器生成的預設行為, 就可以在參數列表後增加 = default, 它既可以被宣告於類別內, 也可以被宣告於類別外. 在類別內被宣告時, 它是內嵌的; 當它被宣告與類別外時, 它預設情況之下不是內嵌的.

在 C++ 中, class TT 是等價的, struct TT 也相同. struct 類別的成員權限預設是 public 的, class 類別的成員權限預設是 private 的.

要在類別之外存取私用成員, 就要使用 friend 標識友誼關係. 友誼關係的宣告只能在類別內部進行, 並且 friend 宣告的任何成員都不是真正的成員, 不受類別的存取權限的限制. 我們一般在類別開始或者尾部集中地宣告 friend 成員.

C++ 允許將類別的宣告和實作分離, 同樣也支援類別僅宣告而不實作. 此時這樣的類別被稱為不完全型別, 使用的場景比較有限, 一般較多地應用於分離實作當中 :

  • 宣告指向這種型別的指標或者參考;
  • 宣告 (但不可以進行實作) 以該型別為參數或者回傳型別的函式.

當一個類別 A 的成員函式 f 想要存取另外一個類別 B 的私用成員的時候, 我們可以在 B 使用 friend 宣告友誼函式. 但是如果類別 A 此時還不知道類別 B 中有什麼成員, 就必須把這個函式 f 實作在類別 B 被實作之後 :

class B;
class A {
public:
    void f(B &);     // the member of class B is still unknown here
};
class B {
    int a;
    friend void A::f(B &);       // error when implement A::func here
};

void A::f(B &) {
    // A::f must be implemented outside after class B implemented
    // ...
}

因為編碼器在處理完所有類別成員的宣告之後才開始處理函式的實作, 所以函式中可以使用任何類別內已經宣告的名稱, 不論這個名稱出現在該函式宣告之前還是之後. 類別內的成員如果使用類別可視範圍之外的型別名稱, 可以直接使用.

當成員變數中存在參考或者 const 的時候, 必須在建構子的初始化列表之後進行明確初始化. 最好讓建構子的初始化順序與類別內的成員變數的宣告順序保持一致, 而且儘可能避免使用一個成員變數去初始化另外一個成員變數. 因為編碼器對成員變數的初始化順序並不會因為建構子初始化列表中的順序和宣告順序不同就採用初始化列表中的順序, 編碼器永遠會按照類別成員變數的宣告順序進行初始化. 如果要用一個成員變數去初始化另外一個成員變數, 必須保證該成員變數已經被初始化, 否則會導致未定行為. 假如一個建構子提供了與類別成員變數對應的所有的初始化參數, 並且每一個參數也提供了預設引數, 那麼它實際上也是定義了預設建構子.

C++ 11 支援將一個建構子的功能委託給其它的建構子, 基本的委託建構子形式是 T(paramater-list) : T(argument-list). 其中, 冒號之後本來應該是初始化列表 :

class A {
public:
    A(int, char);
    A(int a, char b, float c) : A(a, b) {
        // ...
    }
};

若某個類別 T 存在預設建構子, 在函式內宣告 T 對應的物件的時候, 不應該使用 T var(); 這樣的形式, 這會被編碼器誤認為是在宣告一個回傳型別為 T 的無參數函式. 在宣告類別物件的時候, 可以採用 T var; 或者 T var {}; 這樣的形式, 因為它們都會直接呼叫預設建構子. 在使用 new 的時候, 也可以採用類似的方法 : T ptr = new T; 或者 T ptr = new {};. 但是由於宣告指標的時候, 由於 new 表達式的特殊性, 因此我們也可以寫成 T ptr = new T();.

C++ 標準規定了在函式匹配的時候, 只能發生一次隱含型別轉換 :

#include <iostream>

class A {
public:
    A(std::string);
};
void f(A);
int main(int argc, char *argv[]) {
    f("abc");       // Error
}

Code 22 中, 我們希望型別為 const char *"abc" 可以匹配 std::string 的建構子, 然後轉換為 std::string("abc"), 再去匹配 A 的建構子. 然而這樣發生了兩次隱含型別轉換, 所以程式碼無法通過編碼. 當然, 我們我們將 f("abc") 改為 f(std::string("abc")), 那麼就可以通過編碼. 但是我們不相讓這種情況發生, 也就是不允許 std::string 隱含的轉換為 A, 那麼可以在建構子 A(std::string) 之前增加 explicit 標識. 當帶有多個參數的建構子被 explicit 標識的時候, 也就防止了使用等號初始化的時候, 初始化列表向類別轉型. 如果一定要使用等號進行初始化, 那麼需要借助 static_cast :

struct A {
    explicit A(int);
    explicit A(int, int);
}
int main(int argc, char *argv[]) {
    A a1 {1};       // OK
    A a2 = {1};     // Error
    A a3 = 1;       // Error
    A a4 {1, 2};        // OK
    A a5 = {1, 2};      // Error
    A a6 = static_cast<A>(1);       // OK
}

當建構子被實作在類別之外的時候, 無需再次宣告 explicit, 否則會產生編碼錯誤.

若一個類別所有成員都是 public 的, 沒有建構子和虛擬函式, 沒有基礎類別, 所有成員變數也不存在預設的初始值, 那麼我們稱這個類別為聚合類別 (aggregate class). 我們可以直接使用列表初始化, 按成員變數宣告順序給出初始化的引數即可對聚合類別完成初始化. 如果給定的初始化引數少於成員變數的數量, 那麼剩下來的成員變數將會被預設初始化.

建構子雖然不可以被 const 限定, 但是可以被 constexpr 標識. 帶有 constexpr 標識的建構子也可以宣告為 = default 或者 = delete. 對於 constexpr 建構子來說, 函式內部一般是空的, 它必須初始化所有成員變數. 通過呼叫帶有 constexpr 標識的建構子可以宣告類別的 constexpr 物件, 也可以把類別作為 constexpr 函式的回傳型別.

靜態屬性變數被所有相同類別物件所共享. 靜態成員函式不可以使用 this 指標, 也不可以使用類別專用的標識, 它就相當於普通函式, 只是把它寫在類別內. 靜態屬性成員可以是不完全型別. 非靜態成員變數不可以作為類別內函式的預設引數, 但是靜態成員變數卻可以. 習慣上, 我們通常使用可視範圍運算子 :: 來存取或者呼叫類別中的靜態成員, 但是同樣也可以通過成員存取運算子 ., ->, .*->* 進行存取. 通常, 我們不建議在類別內部直接對靜態成員變數進行初始化. 當在類別內部初始化靜態成員變數時, 要求靜態成員變數必須是字面值的常數型別, 而且初始化的值必須是一個常數表達式 :

class C {
    static constexpr int a {1};
}

8. I/O

不可以對來自標準樣板程式庫的 IO 物件進行複製或者指派操作, 所有傳入函式的 IO 物件參數都應該是參考的形式. IO 物件會因為讀取或者寫入操作而被更改狀態, 所以有讀寫與寫入操作時, 不能傳入或者回傳被 const 標識的 IO 物件的參考.

當一個資料流發生錯誤, 那麼對於後續的操作全部都會失敗. 如果當一個資料流發生錯誤, 還想繼續使用資料流, 那麼可以使用其成員函式 clear 復位資料流中的所有錯誤標識, 例如呼叫 std::cin.clear(). 在每個輸出操作之後, 可以使用 std::unitbuf (例如 std::cout << std::unitbuf) 來設定資料流的內部狀態, 清空緩衝區. 默認情況下, 對 std::cerr 是設定了 std::unitbuf 的, 因此寫入到 std::cerr 的內容都是立即刷新的. 如果不再需要立即刷新, 可以使用 std::nounitbuf 取消. std::endl 會換行且立即刷新緩衝區, std::flush 只會立即刷新緩衝區, std::ends 會輸出一個空的字元之後刷新緩衝區. 但是當程式異常終結的時候, 輸出緩衝區不會被刷新.

一個輸出流可能會被繫結到另一個資料流. 在這種情況下, 當讀取或者寫入被繫結的資料流時, 繫結到的資料流的緩衝區會被立即刷新. 預設情況下, cincerr 都被繫結到了 cout. 因此, cincerr 都會導致 cout 的緩衝區被立即刷新.交互式系統通常應該關聯輸入和輸出資料流, 這意味著所有輸出包括提示都會在操作之前都被影印出來. 我們可以使用資料流中的成員函式 tie 使資料流之間相互關聯 :

#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {
    ostream *os = cin.tie();        //使 cin 不再和其它流繫結, 並且回傳一個繫結的流的指標。 cin 之前繫結的是 cout, 則 os 指向 cout
    cin.tie(&cerr);     //使 cin 與 cerr 繫結
}

一個來自標頭檔 <fstream> 檔案資料流被銷毀的時候, 其成員函式 close 會被自動呼叫. 打開一個檔案的模式有 :

  • in : 讀;
  • out : 將之前檔案內容移除並且寫;
  • app : 每次寫之前都定位到末尾;
  • ate : 打開之後立即定位到末尾;
  • trunc : 將之前的檔案內容移除;
  • binary : 二進制.

來自標頭檔 <sstream> 的字串資料流的輸入緩衝區和輸出緩衝區都是 std::string 掌控的 :

#include <iostream>
#include <sstream>

int main(int argc, char *argv[]) {
    std::string total_string {"first second last"};
    std::string array[3];
    std::istringstream in(total_string);
    in >> array[0] >> array[1] >> array[2];
    using std::cout, std::endl;
    cout << array[0] << endl << array[1] << endl << array[2] << endl;
    std::ostringstream os;
    os << array[0] << " " << array[1] << " " << array[2] << "!!!";
    cout << os.str() << endl;
}
/* 程式輸出結果 :
    first
    second
    last
    first second last!!!
*/

和 IO 有關的疊代器有兩個 :

  • std::istream_iterator 使用 >> 來讀取資料流;
  • std::ostream_iterator 使用 << 來寫入資料流.

我們可以直接將這兩個疊代器繫結到 std::cin, std::coutstd::cerr 等的資料流, 然後讀取或者寫入資料即可. 當一個繫結到資料流的疊代器, 一旦遇到資料流錯誤或者檔案的末尾, 疊代器的的值就會與對應資料流的尾後疊代器相等. 資料流的尾後疊代器可以通過預設建構子來建構 :

#include <iostream>
#include <iterator>
#include <vector>

int main(int argc, char *argv[]) {
    std::istream_iterator<int> cin_it {std::cin}, cin_end {};
    std::vector<int> v;
    while(cin_it not_eq cin_end) {
        v.push_back(*cin_it++);
    }
}

std::ostream_iterator 接受兩個引數, 第二個引數是一個可選的 const char * 的字面值字串. 如果傳入一個字面值字串, 那麼每一次輸出之後都會再次輸出這個字串. std::ostream_iterator 中盡管存在 *++ 運算子, 但是並不會對其有實際的效果, 只是回傳疊代器本身而已.

任何多載了位元左移或者位元右移運算子的類別都可以由它們來建立資料流疊代器.

9. 順序容器

本節提到的容器都屬於資料結構的實作, 可以參考資料結構 (https://jonny.vip/category/computer-science/data-structure/) 分類下面的文章.

C++ 標準樣板程式庫提供了以下線性容器 :

  • std::vector : 向量, 支持快速隨機存取;
  • std::deque : 雙向佇列, 支持隨機存取;
  • std::list : 連接串列, 插入和刪除元素較快;
  • std::forward_list : 前向連結串列, 插入和刪除元素較快. 它不支援反向的操作, 例如它不存在反向疊代器;
  • std::array : 陣列, 支持快速隨機存取, 不可以添加或者刪除元素. 初始化之後, 不再支援使用 {} 進行指派;
  • std::string : 字串, 與 std::vector 類似, 只是裡面儲存的是 char.

當程式在寫入操作之後才需要進行讀取操作的, 可以使用 std::list. 完成之後, 再將所有元素複製到 std::vector 中. 對於兩個不同容器, 若容器內部元素型別相同, 那麼可以使用它們的疊代器進行相互轉換相互複製元素, 但是不可以直接進行拷貝 :

#include <vector>
#include <forward_list>

int main(int argc, char *argv[]) {
    std::vector<int> vec {1, 2, 3, 4, 5, 6, 7, 8};
    std::forward_list<int> list(vec.cbegin(), vec.cend());
}

在比較老的編碼器上, std::vector<std::vector<int>> 這樣的型別會產生編碼錯誤, 因為編碼器會將最後的 >> 誤認為是位元左移運算子, 而不是尖括號. 此時, 需要在兩個尖括號之間加入空格 : std::vector<std::vector<int> >.

標準樣板程式庫的 std::swap 同樣為這些容器提供了交換操作, 而容器本身也提供了 swap 成員函式, 這兩個函式最終的效果都是一樣的.

若容器的成員函式 beginend 回傳的物件相等, 那麼就說明容器是空的.

std::array 進行複製的時候, 兩個 std::array 的大小必須相同. 對於 std::array 進行列表初始化的時候, 列表中的元素應該小於或者等於 std::array 的大小. std::array 不支援使用數量和值的方式進行初始化 (例如 std::vector 支援 std::vector<int> vec(10, 10); 這樣的宣告, 表示建構時在向量中插入 1010, 但是 std::array 不支援這樣的建構操作).

容器提供的 assign 成員函式支援用某個數量的值覆蓋整個容器, insert 成員函式是向容器某個位置插入元素的, erase 成員函式是從容器中移除元素的. 除了 std::array 之外, 所有容器都支援 emplace 操作, 它可以直接在原地進行建構, 省去了移動或者複製操作. 我們應該儘量使用容器的 emplace 系列成員函式, 而不是 insert 系列成員函式. 容器提供的成員函式 at 和陣列注標運算子提供類似的功能, 但是越界的時候會擲出 std::out_of_range 例外情況. 不論是使用成員函式 at 還是使用陣列注標運算子來存取容器中的元素, 它們回傳的都是元素的參考. 另外, std::forward_liststd::list 不可以對元素進行隨機存取, 所以就不存在成員函式 at 和多載的陣列注標運算子.

對於 std::vector, std::dequestd::string 來說, 向其中插入元素可能導致已經保存的疊代器失效. 所以, 所有插入和移除元素的操作都會回傳一個最新的疊代器. 除 array 之外, 容器可以使用成員函式 resize 對容器的大小進行改變, 同樣可能導致疊代器失效. 使用成員函式 resize 的時候, 當大小小於當前元素的數量的時候, 尾部的元素將會被拋棄.

std::forward_list 和其它容器有些不同, 其中的元素在存取的時候, 只能向前進無法向後退, 因此它提供的成員函式都帶有 after, 例如 insert_after, empalce_aftererase_after.

std::vectorstd::string 都有成員函式 capacity 用於回傳在不重新分配空間的情況之下還可以容納多少個元素, 成員函式 reserve 告訴容器應該配置多少大小的空間. 但是成員函式 reserve 接收到的配置大小如果小於目前容器中的元素數量, 那麼什麼都不會做. std::vectorstd::string 有自己的空間擴容方法, 它們在必須擴容的時候才會擴容.

對於 std::vector, std::dequestd::string 來說, 它們都有一個成員函式 shrink_to_fit, 用於回收沒有使用的空間. 但是它只是發送一個請求, 並不保證空間一定可以被回收.

std::string 單獨提供了搜尋操作, 相關的成員函式為 :

  • find : 搜尋第一次出現的位置;
  • rfind : 搜尋最後一次出現的位置;
  • find_first_of : 搜尋傳入的引數中任何一個字符第一個出現的位置;
  • find_last_of : 搜尋傳入的引數中任何一個字元最後一次出現的位置;
  • find_first_not_of : 搜尋第一個不在傳入的引數中的字元;
  • find_last_not_of : 搜尋最後一個不在傳入引數中的字元.

如果找不到, 那麼會回傳 std::string::npos, 這是 std::string 向外公開的一個靜態成員變數. std::string 還提供了成員函式 compare, 和 std::strcmp 的比較類似.

std::string 還可以和內建型別進行相互轉換 :

  • std::to_string : 將任意的內建算術型別轉換為 std::string;
  • std::stoi : 將 std::string 轉換為 int;
  • std::stol : 將 std::string 轉換為 long;
  • std::stoll : 將 std::string 轉換為 long long;
  • std::stoul : 將 std::string 轉換為 unsigned long;
  • std::stoull : 將 std::string 轉換為 unsigned long long;
  • std::stof : 將 std::string 轉換為 float;
  • std::stod : 將 std::string 轉換為 double;
  • std::stold : 將 std::string 轉換為 long double.

傳入的引數模型為 +-.0123456789 (十進制). 傳入的引數第一個非空的字元必須是符號 (+ 或者 -) 或者數字. 它可以是 0x 或者 0X 打頭表示的十六進制數字. 對於那些要轉換為浮點數據型別的可以是小數點 . 帶頭, 並且可以使用 e 或者 E 表示指數部分. 根據基數的不同, 也可以傳入大於 9 的數字 :

#include <string>

int main(int argc, char *argv[]) {
    std::string s("The price is $1.23");
    std::string num("+-.0123456789");
    float price {std::stof(s.substr(s.find_first_of(num)));        // price 為 1.23
}

如果 std::string 不可以被轉換為一個數, 那麼會擲出 std::invalid_argument 例外情況; 如果 std::string 轉換得到的數值無法用任何型別標識, 那麼擲出 std::out_of_range 例外情況.

C++ 標準樣板程式庫還提供了三個容器配接器 : std::stack, std::queuestd::priority_queue. 其中, std::stack 被定義在標頭檔 <stack> 中, std::queuestd::priority_queue 被定義在標頭檔 <queue> 中. 它們分別用於實作堆疊, 佇列和優先佇列 :

  • std::stack 擁有成員函式 push, popback, 底層容器可以是除了 std::arraystd::forward_list 之外的任何順序容器, 只要擁有成員函式 back, push_backpop_back 即可;
  • std::queue 擁有成員函式 push, pop, backfront. 低層容器只能是 std::liststd::deque 這樣有 front, back, push_backpop_front 成員函式的容器;
  • std::priority_queue 擁有成員函式 front, pushpop, 低層容器需要擁有隨機存取的能力和成員函式 push_backfront 這樣的容器, 那麼可以從 std::vectorstd::deque 中選擇.

10. 泛型演算法

定義在標頭檔 <algorithm> 中的泛型演算法是不依賴於元素型別的演算法. 演算法不改變底層容器的大小, 但可能會改變容器中保存元素的值, 也可能會移動容器中的元素, 但是不會直接添加或者刪除容器中的元素. 那些只接收一個單一疊代器來表示第二個序列的演算法, 都假定第二個序列至少要和第一個序列一樣長. 因為演算法並不會執行容器操作, 即演算法不可能主動去改變容器大小, 那麼向容器使用演算法進行寫入操作的時候, 都應該確保序列本身空閒的大小不小於要寫入的元素數目.

std::accumulate 函式的第三個參數是求和的初始值, 傳入的第一個和第二個疊代器中的值必須支援通過明確型別轉換或者隱含型別轉換和第三個引數做加法運算. 例如將第三個參數設定為 const char * 就是錯誤的, 因為 std::string 支持 + 運算子, 但是 const char * 並不支持, 所以應該設定為 std::string.

標準程式庫中有一個 copy 函式, 接受一個疊代器範圍和輸出疊代器, 把範圍內的元素複製到輸出疊代器所代表的空間上.

std::back_inserter 是插入疊代器, 定義在標頭檔 <iterator> 中. 將一個容器作為其建構引數, 每次解參考操作都相當於呼叫容器的成員函式 push_back. 對應地, std::front_inserter 將會呼叫容器的 push_front 成員函式, 而 std::inserter 將會呼叫容器的 insert 成員函式 :

#include <iterator>
#include <vector>

int main(int argc, char *argv[]) {
    std::vector<int> vec;
    auto it = std::back_inserter(vec);
    *it = 1;        // 相當於向 vec 的尾部插入一個 1, vec.push_back(1);
}

Lambda 表達式的基本形式為 [capture-list](parameter-list) -> type {};. 其中, capture-list 可以放置一些 Lambda 表達式目前所在的可視範圍內可以看到的局域變數或者局域常數 (一些靜態變數或者全域變數 Lambda 表達式是可以直接看到並且使用的, 無需捕獲); 參數列表和尾置回傳型別是可以省略的, 省略參數列表相當於 Lambda 表達式不接受引數, 省略尾置回傳型別編碼器會自動推導 Lambda 表達式的回傳值. 當省略 Lambda 表達式尾置回傳型別的時候, 如果 Lambda 表達式中只有一個 return 陳述式, 那麼最終的回傳型別取決於 return 陳述式的回傳值型別; 否則, 最終的回傳型別為 void (這裡存在疑問, 在 Apple Clang 14.0.0 下, 不論 Lambda 表達式有多少行, 只要存在 return 陳述式, 編碼器都不會假設 Lambda 的回傳型別為 void, 實際型別仍然取決於 return 陳述式). Lambda 表達式中, 參數不可以有預設引數 (這點存在疑問, 因為在 Apple Clang 14.0.0 下, C++ 11 的 Lambda 表達式的參數可以有預設值). Lambda 表達式實際上是一個可呼叫物件, 它可以替代函式指標傳遞到泛型演算法中. Lambda 表達式在傳遞給演算法的時候, 有一個函式指標無法替代的好處, 就是它可以捕獲局域變數, 這樣可以減少參數列表中所需要的參數數量. 當定義一個 Lambda 表達式的時候, 編碼器會為 Lambda 表達式聲稱對應的類別, 一個 Lambda 表達式對應一個類別. 被 Lambda 表達式捕獲的變數在 Lambda 表達式創建的時候就已經將值複製好了, 因此在之後修改值的時候, 不會影響 Lambda 表達式內部的對應變數的值. 當然, 如果使用參考捕獲的話, 外部值的改變也會影響內部捕獲變數的值. 可以直接在參數列表中使用 & 或者 = 讓編碼器自行判斷要捕獲哪些變數, 這樣捕獲的變數是隱含捕獲的. 當混合使用明確捕獲和隱含捕獲的時候, & 或者 = 必須放在捕獲列表首位. 當隱含捕獲是採用參考的方式, 那麼明確捕獲中不能有參考捕獲; 當隱含捕獲是採用值捕獲的方式, 那麼明確捕獲中不能有值捕獲的方式. 當一個值被 Lambda 表達式捕獲之後, 其預設被 const 標識, 在 Lambda 表達式內部是不可改變的. 如果希望 Lambda 表達式捕獲的變數的值可以在 Lambda 表達式內部改變, 可以在參數列表之後增加 mutable 標識 :

int main(int argc, char *argv[]) {
    int a {};
    auto lambda1 {[a]() -> void {
        ++a;        // Error
    }};
    auto lambda2 {[a]() mutable -> void {
        ++a;        // OK
    }};
}

因為沒有採用參考捕獲, 所以 Lambda 中的更改不會影響外部 a 變數的值. 當在 Lambda 表達式參數列表之後標識 mutable, noexcept 或者 throw(...) 的時候, 參數列表的括號不能省略.

函式 std::bind 被定義在標頭檔 <functional> 中, 它接收一個可以被呼叫的物件和一些預定引數, 將引數繫結至可呼叫物件, 然後回傳一個可呼叫物件. 在名稱空間 std::placeholders 中, 有若干個佔位的變數, 以下劃線開頭, 以數字結尾, 通常的形式是 _N. 在 libc++ 標準樣板程式庫中, 總共有十個佔位變數; 在 libstdc++ 標準樣板程式庫中, 總共有二十九個佔位變數. 佔位變數是搭配 std::bind 使用的. 例如有某個函式 void f(int, char, void *, const char *, std::string, std::vector &, const float &, long double, long long);, 這個函式的參數很多, 但是實際上只有第三個和第四個和字串有關的變數經常不同, 剩餘參數基本上都是一個固定的值. 我們每一次呼叫函式 f 的時候, 都要把固定的值傳遞給 f, 這樣很繁瑣, 為了簡便起見, 我們希望有一個可呼叫物件, 我們只需要傳遞兩個字串, 便可以完成函式的呼叫 : f_agent("123", std::string("abc"));. 這樣, 對於那些固定的值, 我們根本不需要去傳遞, 也就簡化了程式碼. std::bind 就可以完成這個需求. 對於那些可能改變的參數, 我們只需要用佔位變數去佔位即可 :

#include <functional>
#include <string>
#include <vector>
#include <iostream>

int first_argument;
char second_argument;
void *third_argument;
std::vector<int> sixth_argument;
float seventh_argument;
long double eighth_argument;
long long ninth_argument;

void f(int, char, void *, const char *pre, std::string post, std::vector<int> &, const float &, long double, long long) {
    // other codes...
    std::cout << pre << " " << post << std::endl;
    // other codes...
}
int main(int argc, char *argv[]) {
    auto f_agent {std::bind(f, first_argument, second_argument, third_argument, std::placeholders::_1, std::placeholders::_2, sixth_argument, seventh_argument, eighth_argument, ninth_argument)};
    f_agent("hello", "world");      // 輸出結果 : hello world
}

這裡, 我們使用了 std::placeholders::_1 來佔位第一個 const char * 字串, std::placeholders::_2 來佔位第二個 std::string 型別的字串. 那麼 f_agent 只接受兩個引數, 第一個型別為 const char *, 第二個型別為 std::string, 剩餘的引數傳遞 f_agent 已經幫我們完成了. 另外, 在 Code 32 中, 有兩個錯誤的傳遞. 函式 f 的存在兩個參數的型別是參考, 因此 sixth_argumentseventh_argument 的傳遞需要改為 std::ref(sixth_argument)std::cref(seventh_argument).

std::bind 的原型是 std::bind1ststd::bind2nd, 它們是在 Lambda 表達式沒有引入的時候, 替代 std::bind 使用的. 然而 std::bind 可以做到的事情, 我們宣告另外一個函式或者使用 Lambda 表達式同樣可以做到.

可向標準程式庫演算法中傳遞反向疊代器來進行逆向操作, 反向疊代器中具有成員函式 base 可以將反向疊代器轉換為普通的疊代器. std::vector, std::string, std::array, std::dequestd::list 都提供了自己的反向疊代器, 可以通過成員函式 rbegin, rend, crbegincrend 來獲得.

根據演算法所需要的疊代器應該具有的操作, 可以將疊代器分為五類 :

  • 輸入疊代器 : 只可以讀取, 單邊掃描, 支援的運算子有 ==, !=, ++, *->. std::istream_iterator 就是一個輸入疊代器;
  • 輸出疊代器 : 只可以寫入, 單邊掃描, 支援的運算子有 ++, *. std::ostream_iterator 就是一個輸出疊代器;
  • 前向疊代器 : 支持讀取和寫入, 可以多次掃描, 只支持遞增操作. std::forward_list 的疊代器就是一個前向疊代器;
  • 雙向疊代器 : 支持讀取與寫入, 可以多次掃描, 支持遞增與遞減. std::list 的疊代器就是一個雙向疊代器;
  • 隨機訪問疊代器 : 支持讀取與寫入, 可以多次掃描, 不僅僅支援遞增和遞減, 還支援陣列注標, 向後或者向前移動多步 (+, -, +=-=). 指標, std::vector, std::string, std::dequestd::array 的疊代器就是隨機訪問疊代器.

雙向疊代器具有前向疊代器的特性, 隨機訪問疊代器具有雙向疊代器的特性. 在使用標準樣板程式庫中的函式的時候, 需要注意演算法支援的疊代器類別. 如果在疊代器代表的空間中插入元素, 標準樣板程式庫中的函式會假定插入多少都是安全行為. 有幾個標準樣板程式庫函式特別重要 :

  • std::sort : 對疊代器範圍內的元素進行排序, 可以自訂排序規則, 預設使用 < 運算子進行比較;
  • std::merge : 對兩個疊代器範圍內的有序元素進行合併, 預設使用 < 運算子進行比較;
  • std::remove : 移除疊代器範圍內符合條件的元素, 預設使用 == 運算子進行比較;
  • std::unique : 移除疊代器內重複的元素, 預設使用 == 運算子進行比較.

這些演算法不適用於 std::forward_liststd::list, 因為它們無法提供隨機訪問疊代器. 但是這兩個容器都提供了同名的成員函式來達到類似的目的. 除了上面提到的函式之外, std::forward_list 提供了成員函式 splice_after, std::list 提供了成員函式 splice 用於合併兩個連結串列. 有的演算法會導致連結串列底層結構的更改.

std::sort 為例, 如果我們希望使用我們自己的規則來進行排序, 比如使用 >= 運算子進行排序, 那麼我們可以借助 Lambda 表達式或者函式指標來設定比較規則 :

#include <algorithm>

using namespace std;
int main(int argc, char *argv[]) {
    int arr[10] {5, 7, 3, 5, 7, 8, 0, 1, 3, 4};
    std::sort(begin(arr), end(arr), [](int a, int b) -> bool {
        return a >= b;
    });
}

對於一個輸入疊代器, *iterator++ 是有效的, 但是對它進行遞減操作可能會導致其它指向資料流的疊代器失效. 不能保證輸入疊代器的狀態可以保存下來並用來存取存取元素. 一次只能向一個輸出疊代器指派一次.

11.關聯容器

本節提到的容器都屬於資料結構的實作, 可以參考資料結構 (https://jonny.vip/category/computer-science/data-structure/) 分類下面的文章.

標準樣板程式庫提供了八種關聯容器 :

  • std::map : 影射, 一個鍵對應一個值, 被定義在 <map> 標頭檔中, 底層資料結構為紅黑樹;
  • std::set : 集合, 被定義在 <set> 標頭檔中, 底層資料結構為紅黑樹;
  • std::multimap : 一個鍵可以對應多個值的影射, 被定義在 <map> 標頭檔中, 底層資料結構為紅黑樹;
  • std::multiset : 元素可以重複的廣義集合, 被定義在 <set> 標頭檔中, 底層資料結構為紅黑樹;
  • std::unordered_map : 以雜湊表的形式組織的影射, 被定義在 <unordered_map> 標頭檔中;
  • std::unordered_set : 以雜湊表的形式組織的集合, 被定義在 <unordered_set> 標頭檔中;
  • std::unordered_multimap : 以雜湊表形式組織且鍵可重複的影射, 被定義在 <unordered_map> 標頭檔中;
  • std::unordered_multiset : 以雜湊表形式組織且鍵可重複的廣義集合, 被定義在 <unordered_set> 標頭檔中.

如果向不允許重複值的關聯容器中, 通過成員函式 emplace 或者 insert 重複添加同一個元素, 並不會真正被添加. 所有關聯容器的疊代器都是一個雙向疊代器.

#include <map>
#include <string>
#include <iostream>

int main(int argc, char *argv[]) {
    std::map<int, std::string> number_to_string {{1, "1"}, {2, "2"}, {3, "3"}, {4, "4"}, {5, "5"}};
    std::string tmp;
    int n;
    std::cin >> n;
    if(1 <= n and n <= 5) {
        tmp += number_to_string[n];
    }
}

對於 std::map<T, U>, std::multimap<T, U>, std::set<T>std:::multiset<T>, 型別 T 必須支援比較操作, 至少支援 < 運算子比較; 否則, 我們無法創建對應的物件並且會產生編碼錯誤.

一個 std::pair 保存兩個成員變數, 它是 std::map 中的元素型態, 它被定義在 <utility> 中. 當創建一個 std::pair 時, 必須提供兩個型別. pair 的屬性成員並非私用的, 而是 public 權限. 其中, 兩個成員根據位置, 分別被命名為 firstsecond. 因此, 對於一個 std::map 中的元素, 其鍵對應了 std::pair 中的 first, 鍵所對應的值對應了 std::pair 中的 second. 除了基本的運算與建構之外, 標準樣板程式庫中還定義了 make_pair 函式, 將傳入的兩個引數建構成一個 std::pair 物件然後回傳, 型別由傳入的引數進行推導.

關聯容器中, 具有另外三種類別內的型別 :

  • key_type : 鍵型別;
  • mapped_type : 值型別, 只適用於與 std::map 有關的容器;
  • value_type : 對於 std::set<T> 來說就是 T; 對於 std::map<T, U> 來說是 std::pair<const T, U> 型別;

通過觀察 value_type, 我們可以知道影射中, 鍵值無法改變, 只能改變影射的值. 另外, 集合的疊代器是一個常數疊代器, 所以無法對具體的值進行更改. 如果要對影射中插入元素, 必須插入一個 std::pair 物件, 其回傳值的型別為 std::pair<iterator, bool>. 其中, first 成員是疊代器, 即插入的結果, 類似於 std::vector; second 代表插入是否成功, 如果容器內已經存在對應元素, 插入不成功. 如果要移除關聯容器的元素, 通過成員函式 erase 傳入 key_type 對應的值即可.

影射可以通過陣列注標運算子或者成員函式 at 來存取鍵影射的值, 但是如果影射物件帶有 const 標識, 那麼這兩個操作都不可用.

標準樣板程式庫的標頭檔 <algorithm> 中還定義了一些適用於容器的函式 :

  • std::lower_bound 用於回傳第一個鍵不小於傳入的引數的疊代器, 這個函式不適用於無序容器;
  • std::upper_bound 方法用於回傳第一個鍵大於傳入的引數的疊代器, 這個函式不適用於無序容器;
  • std::euqal_range 方法回傳一個 std::pair 型別的疊代器, 表示鍵等於傳入的引數的範圍. 其中, first 成員指向第一個與鍵匹配的元素, second 成員指向最後一個與鍵匹配的元素.

如果一個允許重複的關聯容器有多個鍵相同的元素, 那麼這些元素都會按照序列進行儲存. 由此可以知道, 使用函式 lower_bound 的回傳值是否等於函式 upper_bound 的回傳值來判斷容器中是否存在給定的鍵.

在鍵沒有明顯序列關係的情況之下, 無需容器非常有用. 即如果鍵型別固有就是無序的, 或者性能測試時發現問題可以使用雜湊技術解決, 那麼就可以使用無需容器. 存儲在有序關聯容器中的元素在輸出時都會按照字典順序進行排序, 無需容器不一定是這樣. 無需容器在資料結構上時一組桶 (Bucket : 雜湊表中存儲元素的位置), 每個桶都會保存元素 (零個, 一個或者多個), 如果容器允許重複鍵, 那麼所有鍵相同的元素都會儲存在一個桶中. 當一個桶保存多個元素的時候, 需要按照順序進行搜尋對應元素. 無需容器提供了幾個用來管理桶的成員函式 :

  • bucket_count : 正在使用的桶的數量;
  • max_bucket_count : 容器最多能容納的桶的數量;
  • bucket_size : 傳入一個數, 回傳這個數對應的桶中的元素個數;
  • bucket : 傳入鍵, 回傳鍵處於第幾個桶;
  • 型別別名成員 local_iteratorconst_local_iterator 都是桶的疊代器;
  • begin, end, cbegincend 都需要傳入一個數, 回傳對應的桶的對應疊代器;
  • local_factor : 每個桶的裝載因素, 回傳型別為 float;
  • max_local_factor : 對應容器試圖守護的平均桶大小, 回傳型別為 float. 容器會在需要的時候添加桶並且保證 c.local_factor() <= c.max_local_factor();
  • rehash : 重新組織存儲, 使 c.bucket_count() 的值傳入的引數, 並且保證 c.bucket_count() > (c.size() / c.max_load_factor());
  • reserve : 重新組織存儲, 使容器可以保存傳入數的個數的元素, 而無需進行 rehash.

預設情況下, 無序容器使用鍵型別的 == 運算子進行比較, 並且還使用 std::hash<T> 來得到每個元素的雜湊值. 對於內建型別和來自標準樣板程式庫的絕大多數類別, 都可以直接放入無序容器中. 如果我們想要為自己定義的型別來使用無需容器, 那麼必須提供我們自己的雜湊版本 :

#include <unordered_map>
#include <string>

struct A {
    std::string str;
};
bool is_equal(const A &left, const A &right) {
    return left.str == right.str;
}
std::size_t hasher(const A &object) {
    return std::hash<std::string> {}(object.str);
}
int main(int argc, char *argv[]) {
    constexpr std::size_t bucket_count {10};
    std::unordered_map<A, const char *, decltype(hasher) *, decltype(is_equal) *> mapping(bucket_count, hasher, is_equal);
}

12. 動態記憶體

智慧指標可以幫助我們自動釋放指標所指的物件, 在 C++ 11 中智慧指標有三種, 它們都被定義在標頭檔 <memory> 中 :

  • std::shared_ptr : 允許多個指標指向同一個物件;
  • std::unique_ptr : 一個物件只允許一個指標指向;
  • std::weak_ptr : 弱指標, 指向 std::shared_ptr 所管理的物件, 幫助 std::shared_ptr 管理指標.

std::shared_ptrstd::unique_ptr 都有成員函式 get 來獲得儲存的記憶體位址. 有些操作時專門為 td::shared_ptr 而準備的 :

  • 函式 make_shared : 回傳一個 std::shared_ptr 物件, 指向一個動態配置型別為 T 的物件, 並且使用傳入的指標引數初始化, 它是建構 std::shared_ptr 最安全的方法;
  • 當使用 std::shared_ptr 的複製操作的時候, 會遞增其中的計數器. 例如使用 p = q 或者複製建構子;
  • 對於 p = q 這樣的指派, 除了增加 p 中的計數器之外, q 中的計數器會減少;
  • 若一個 std::shared_ptr 物件中的參考計數器變為 0, 那麼其管理的記憶體就會被自動回收;
  • std::shared_ptr 有兩個成員函式, use_count 用於回傳參考計數器中的數量, unique 判斷指標是否獨佔.

我們可以認為, 每一個 std::shared_ptr 都有一個繫結的計數器. 當向一個新的物件被複製時, 被複製的指標計數器就會遞增. 將其作為引數傳入函式或者作為函式的回傳值的時候, 參考計數器也會遞增. 當一個 std::shared_ptr 被銷毀或者被指派一個新的值的時候, 其參考計數器會遞減 :

#include <memory>

int main(int argc, char *argv[]) {
    std::shared_ptr<int> ptr {nullptr};
    {
        auto p = std::make_shared<int>(new int(20));
        ptr = p;
    }       // p will be destroyed here
    *ptr = 10;      // OK
}

在 C++ 11 下, 可以使用列表初始化的方式來初始化一個動態配置的物件 : auto p = new int {};. 甚至我們還可以把 int 改成 auto, 讓編碼器來推導型別, 但是此時只能使用圓括號 : auto q = new auto(0);, 而且引數列表中必須有引數. 其中, 上面 q 會被編碼器推導為 int * 型別. 如果我們想得到帶有底層 const 標識的指標, 那麼我們可以寫成 auto q = new const auto(0);; 如果想要得到帶有頂層 const 標識的指標, 我們可以寫成 const auto q {new auto(0)};.

在記憶體耗盡的情況之後, new 表達式會擲出 std::bad_alloc 例外情況. 但是可以通過增添 std::nothrow 來阻止 new 表達式擲出例外情況 : new (std::nothrow) int(42). std::bad_allocstd::nothrow 被定義在 <new> 標頭檔中.

使用 delete 嘗試回收一塊不是由 new 表達式配置的記憶體或者多次嘗試回收同一個指標都會產生未定行為. 對於帶有頂層 const 標識的指標 T *const, 雖然其指向的地址不可以被更改, 但是其記憶體是可以被回收的. 當然, 對於我們自己配置的記憶體, 如果忘記使用 delete 來回收, 就會產生記憶體流失. 因此, 為了避免錯誤, 我們必須避免使用普通指標或者混用智慧指標和普通指標, 盡量使用智慧指標 :

#include <memory>

int main(int argc, char *argv[]) {
    auto p = new auto(0);
    {
        auto q = std::shared_ptr<int>(p);
    }       // the memory will be deallocate
    delete p;       // the memory will be deallocate twice, undefined behavior
}

接受指標引數的智慧指標的建構子被 explicit 標識, 所以不可以使用等號直接指派.

智慧指標中具有成員函式 get, 用於回傳一個內建指標型別, 指向這個智慧指標所管理的物件. 對於這個指標, 我們不可以使用 delete 來回收, 它的管理仍然要交給智慧指標. 否則, 就會產生一個指標被回收兩次以上的未定行為.

智慧指標的回收行為可以自訂, 比如我們希望在智慧指標被銷毀的時候輸出一些日誌或者回收行為和預設的有區別 :

#include <memory>
#include <iostream>

struct S {
    int *p;
};
int main(int argc, char *argv[]) {
    S s {new int};
    auto function_name {__FUNCTION__};
    std::shared_ptr<S> p(&s, [&function_name](S *p) -> void {
        delete p->p;
        std::clog << function_name << " Line " << __LINE__ << " : The pointer s.p has been deallocated." << std::endl;
    });
}

在 C++ 11 中並沒有類似於 std::make_shared 這樣的標準程式庫函式回傳 std::unique_ptr 物件, 所以在宣告 std::unique_ptr 物件的時候, 我們需要使用 new 進行記憶體配置 (C++ 14 引入了 std::make_unique 來回傳 std::unique_ptr 物件). std::unique_ptr 的複製建構子和複製指派運算子是被刪除的函式, 所以它不支援複製操作. 但是 std::unique_ptr 支援被 nullptr 指派. 如果一個 std::unique_ptr 物件本來儲存著一個指標, 但是我們要把 nullptr 指派給它或者呼叫其成員函式 release 的話, 那麼原本儲存在 std::unique_ptr 的指標就會被回收. 雖然 std::unique_ptr 並不支援複製或者指派, 但是我們可以通過 std::unique_ptr 中的成員函式 reset 傳入非 const 標識的 std::unique_ptr 來轉移控制權. 不能複製 std::unique_ptr 的規則有一個例外 : 可以複製或者指派一個將要被銷毀的 std::unique_ptr. 這就說明我們可以通過函式回傳一個 std::unique_ptr. 此時, 編碼器知道將要回傳的是一個即將被銷毀的 std::unique_ptr, 會執行一段特殊的複製. 在 C++ 98/03 中, 還有一個智慧指標 std::auto_ptr, 它和 std::unique_ptr 非常類似, 但是它無法做到特殊的複製 (std::auto_ptr 已經在 C++ 11 中被遺棄, 在 C++ 17 中被移除).

如果我們要自訂 std::unique_ptr 的回收操作, 那麼除了給出一個可呼叫物件作為引數之外, 還需要給出可呼叫物件的型別 :

#include <memory>
#include <iostream>

struct S {
    int *p;
};
int main(int argc, char *argv[]) {
    S s {new int};
    auto function_name {__FUNCTION__};
    auto deleter {[&function_name](S *p) -> void {
        delete p->p;
        std::clog << function_name << " Line " << __LINE__ << " : The pointer s.p has been deallocated." << std::endl;
    }};
    std::unique_ptr<S, decltype(deleter)> p(&s, deleter);
}

如果傳入一個 Lambda 表達式, 就要使用 decltype 進行推導. 如果傳入一個函式指標, 那麼直接寫出這個函式指標的型別就可以了, 當然也可以用 decltype 進行推導.

std::weak_ptr 是一種不控制所指向物件生存週期的智慧指標, 它指向一個由 std::shared_ptr 管理的物件, 將一個 std::weak_ptr 繫結到 std::shared_ptr 上並不會遞增 std::shared_ptr 中的計數器. 即使一個物件被 std::weark_ptr 所指向, 但是計數器為 0 時, 也會回收其中的記憶體. 可以將 std::shared_ptr 或者 std::weak_ptr 指派一個 std::weak_ptr, 指派之後, 雙方共享物件. 當想要存取一個 std::weak_ptr 指向的物件的時候, 物件可能已經被銷毀, 所以使用之前, 我們有必要進行檢測 :

#include <memory>

int main(int argc, char *argv[]) {
    std::weak_ptr<int> weak_p {std::make_shared<int>(1)};       // the temporary pointer made by std::make_shared will be released after finishing constructing weak_p
    if(weak_p.lock()) {
        // do something on weak_p
    }else {
        // weak_p is invalid
    }
}

如果將一個已經被回收的 std::weak_ptr 物件指派給 std::shared_ptr, 那麼會擲出 std::bad_weak_ptr 例外情況.

使用 new 配置一個動態陣列的時候, 可以在之後跟隨一個初始化列表來初始化這個陣列 : auto arr = new int[5] {1, 2, 3, 4, 5}. 如果之後什麼都不跟隨, 那麼就是用預設的值或者預設建構子來初始化陣列中所有的元素. 如果初始化列表中的元素數量大於實際的陣列能夠容納的元素, 那麼 new 表達式將會失敗同時擲出 std::bad_array_new_length 的例外情況, 這些型別被定義在 <new> 標頭檔中. 如果一個 new 表達式擲出例外情況, 那麼代表記憶體配置失敗, 這個時候已經配置的記憶體會被自動回收.

std::unique_ptr 中由單獨管理陣列的一個版本 : std::unique_ptr<int []> up(new int [N]);. 當一個 std::unique_ptr 指向陣列時, 不可以使用 .-> 運算子, 因為這些運算子都是無意義的. 但是可以使用 [] 存取陣列中的元素, 但是這種存取方式只限於動態的陣列. std::shared_ptr 並不支援直接管理陣列, 如果要使用 std::shared_ptr, 我們就需要自訂記憶體回收操作, 否則將會導致未定行為.

類似於 new int[0] 這樣的表達式是合法的, 而且我們必須使用 delete[] 來回收對應的記憶體. 然而, 對於這樣配置出來的指標, 一旦對其解參考, 將會產生未定行為.

在回收記憶體的時候, 內部元素的解構子是從後往前呼叫的.

new 在靈活性上有一些限制. 當使用 new 進行記憶體配置的時候, 會同時進行物件的建構. 當我們配置記憶體數量很多的時候, 如果後面, 那麼會造成浪費. 標準程式庫中的 allocator 可以將記憶體的配置和物件的建構分開來, 它被定義在 <memory> 標頭檔中 :

  • std::allocator<T> a; : 定義 std::allocator 物件;
  • auto pa = a.allocate(N); : 配置 N 個大小的記憶體空間, 但是不進行建構. 該成員函式回傳一個指標, 指向配置的第一個記憶體位址;
  • a.deallocate(pa, N); : 回收記憶體, N 的大小必須和分配時的大小相同. pa 必須是成員函式 allocate 回傳的指標. 在釋放之前, 如果存在建構的, 必須使用成員函式 destroy 進行解構;
  • a.destroy(pa); : 對 pa 指向的物件進行解構, 但是不回收記憶體;
  • a.construct(pa, args...); : 對 pa 指向的記憶體進行建構, args... 是建構的引數, 這個引數與 pa 對應的物件型別的建構子相對. 在未建構的情況之下, 直接使用那段未建構的記憶體會造成未定行為.

標準樣板程式庫的標頭檔 <memory>std::allocator 提供了一組伴隨演算法, 可以在未初始化的記憶體中建構物件 :

  • std::uninitialized_copy(A, B, M) : 它接受一個疊代器範圍, M 為未建構的記憶體指標;
  • std::uninitialized_copy_n(A, N, M) : 它接受一個疊代器和建構數量;
  • std::uninitialized_fill(A, B, T) : 它接受一段未建構的記憶體範圍, 對其中的物件使用 T 進行複製建構;
  • std::uninitialized_fill_n(A, N, T) : 它接受一個指標和建構數量, 對其中的物件使用 T 進行複製建構.

13. 類別的複製建構與解構

如果一個建構子的第一個參數是類別本身型別的參考 (最好是常數參考, 雖然非常數參考也是可以的), 而且任何額外的參數都帶有預設引數, 那麼可以稱這個建構子為複製建構子. 因為複製建構子通常會被隱含地使用, 所以複製建構子通常不應該是 explicit 的, 複製建構子一般會在以下情景被呼叫 :

  • 使用 = 從另一個類別物件指派, 例如 T a = b;;
  • 將一個類別物件作為引數傳遞給另一個非參考的類別物件, 例如 T a(b);, T &a(b) 不會呼叫複製建構子;
  • 從一個回傳型別為非參考的函式回傳一個類別物件;
  • 使用 {} 列表初始化一個陣列中的元素或者一個聚合類別中的成員;
  • 某些類別會對它們所分配的物件進行複製初始化, 例如 std::vector 中的成員函數 inserterase.

編碼器可以繞過複製建構子或者移動建構子, 直接創建一個物件, 即編碼器允許將 std::string a = "abc"; 改寫為 std::string a("abc");. 即使編碼器略過了複製建構子或者移動建構子, 但是對應的複製建構子或者移動建構子必須是存在的, 並且可以在類別外被使用 :

struct A {
    A() = default;
    A(const A &);
    A &operator=(const A &) = delete;
};
int main(int argc, char *argv[]) {
    A a;
    A b = a;        // OK, same as A b(a), call A(const A &);
}

在類別中進行運算子多載, 並且多載的運算子為成員函式, 那麼左側運算物件會隱含地被繫結到 this 上. 例如多載的複製指派運算子, 它有兩個引數, 一個是類別物件本身, 另外一個是用於指派給當前類別物件的另一個物件. 但是, 它只需要宣告另外一個物件的型別, 即 const T & 即可, 而不是將參數列表寫成 T &operator=(const T &, const T &).對於一個二元運算子來說, 右側的運算物件作為明確引數傳遞. 一般來說, 為了與內建的指派運算子保持一致, 指派運算子通常回傳一個指向左側物件的參考.

在一個解構子中, 它的順序是首先運作函式內部的陳述式, 最後再按照成員初始化的逆順序進行銷毀所有成員變數. 如果一個類別因為記憶體配置的原因自訂自己的解構子, 那麼一般情況下幾乎可以肯定這個類別也需要自訂複製建構子和複製指派運算子 :

class A {
private:
    int *p;
public:
    A() = default;
    A(const A &rhs) : p {new int {*rhs.p}} {}
    A &operator=(const A &rhs) {
        if(&rhs not_eq this) {
            delete this->p;
            this->p = new int {*rhs.p};
        }
        return *this;
    }
    ~A() {
        delete this->p;
    }
};

如果 Code 42 中的類別 A 的所有複製操作都讓編碼器生成, 那麼一旦出現複製操作, 那麼顯然會出現一個指標被回收兩次的未定行為. 編碼器在生成複製建構子的時候, 直接會把指標 p 進行簡單複製, 而不是先配置一塊新的記憶體, 然後把原來指標 p 指向的記憶體內部的值複製過來. 當然, 最好的解決方案是使用 std::shared_ptr.

我們可以通過在建構子後添加 = default 來明確地讓編碼器來為我們自動生成合成版本地建構子. 即當我們為類別實作了建構子之後, 編碼器不會再為我們自動提供預設建構子了, 如果此時, 我們還需要預設的建構子, 我們可以通過這種方法來讓編碼器幫我們合成.

對於有些類別來說, 複製行為可能沒有任何意義, 甚至有時候還有害, 那麼此時我們需要阻止複製行為的發生. 例如和 IO 有關的類別, 複製會引起多個物件讀取或者寫入相同的 IO 緩衝. 在 C++ 11 中, 可以宣告被刪除的函式. 與 = default 相似, 被刪除的函式可以宣告為 = delete. 但是與 = default 相反的是, 被刪除的函式的宣告會告知編碼器不要再幫我們合成預設的對應函式, 我們不希望定義這些操作. 被刪除的函式 = delete 宣告必須出現在函式第一次被宣告的時候, 並且 = delete 可以指派給任何一個函式, 而 = default 只可以指派給建構子, 解構子和指派運算子. 也就是說, = delete 宣告可以有無數個, 甚至可以給普通函式標識, 不只是成員函式, 而 = default 宣告至多出現. 在引導函式匹配的過程中, = delete 有時候也可以發揮作用 :

void f(int);
void f(double) = delete;
int main(int argc, char *argv[]) {
    f(1.2);     // compile error : call to deleted function
    f(1);       // OK
}

這樣, 我們可以避免函式引數傳入時可能發生的隱含型別轉化.

類別的解構子一般情況下不應該被宣告為被刪除的函式, 否則類別產生的物件將無法被銷毀. 這些類別也無法直接具現化出物件, 因為會產生編碼錯誤. 對於一個被刪除解構子的類別來說, 可以通過動態配置記憶體的方式來宣告, 但是無法通過 delete 進行釋放記憶體. 對於某些類別來說, 即使編碼器會幫助我們嘗試產生對應的特殊函式, 但是將會將其自動宣告為被刪除的函式 :

  • 類別中存在某些物件的解構子是被刪除的或者是私用的, 那麼類別合成的解構子將會被宣告為被刪除的函式;
  • 類別中存在某些物件的複製建構子是刪除的或者是私用的, 那麼類別合成的複製建構子會被宣告為被刪除的函式的;
  • 類別中存在某些物件的複製指派運算子是刪除的或者是私用的, 或者類別內有被 const 所標識或者參考形式的成員變數, 那麼合成的複製指派運算子將會被宣告為被刪除的函式;
  • 類別中存在某些物件的預設建構子是被刪除的或者私用的, 或者類別內有被 const 所標識或者參考形式的成員變數, 並且這些類別設計者沒有在類別內初始化這些成員變數, 那麼合成的預設建構子會被宣告為被刪除的函式.

總體來說, 如果類別內有成員變數不能被預設建構, 複製, 指派或者解構, 那麼對應的特殊成員函式將會被編碼器自動宣告為被刪除的.

在 C++ 11 之前, 如果想要將函式設定為被刪除的, 一般通過將其設定為私用的成員. 對於私用的函式來說, 成員函式或者具有友誼關係的類別或者函式是仍然可以存取的. 如果連成員函式或者友誼權限的物件都不想讓其存取, 那麼可以不進行實作. 這樣, 可以通過編碼, 但是卻會在連結時產生連結錯誤 :

class C {
    friend class CC;
private:
    C(const C &);
    C &operator=(const C &);
public:
    C() = default;
    //...
};
class CC {
public:
    void f(C &c) {
        C c2 = c;       // compile pass but got link error
    }
};
int main(int argc, char *argv[]) {
    C c;
    CC cc;
    cc.f(c);        // link error
}
/* Clang 下的連結錯誤 :
    Undefined symbols for architecture x86_64:
    "C::C(C const&)", referenced from:
    CC::f(C&) in Untitled-bad506.o
    ld: symbol(s) not found for architecture x86_64
    clang: error: linker command failed with exit code 1 (use -v to see invocation)
*/

如果一個類別定義了自己的交換成員函式 swap, 那麼標準樣板程式庫中的演算法 std::swap 將優先呼叫類別內的 swap; 否則, 將使用普通的方法進行交換. 儘管定義 swap 函式對於某些類別來說並不是必要, 但是對於分配了資源的類別來說, 定義自己的 swap 函式可能是一種重要的優化手段 :

#include <cstddef>
#include <type_traits>

class simple_vector {
private:
    int *begin;
    int *first_empty;
    int *end;
public:
    simple_vector();
    simple_vector(const simple_vector &rhs) : begin {new int[rhs.size()]}, first_empty {this->begin}, end {this->begin + rhs.size()} {
        const auto size = rhs.size();
        for(auto i = 0; i < size; ++i) {
            this->begin[i] = rhs.begin[i];
        }
        this->first_empty += static_cast<std::ptrdiff_t>(size);
    }
    ~simple_vector();
    // ...
public:
    void swap(simple_vector &rhs) {
        using std::swap;
        swap(this->begin, rhs.begin);
        swap(this->first_empty, rhs.first_empty);
        swap(this->end, rhs.end);
    }
    std::size_t size() const;
    // ...
};

Code 45 中, 如果沒有實作成員函式 swap, 那麼在交換兩個 simple_vector ab 的時候 (std::swap 對於沒有實作成員函式 swap 的類別就會這樣做), 需要一個臨時的變數 c, 首先把 b 指派給 c (c = b), 然後再將 a 指派給 b (b = a), 最後再將臨時儲存的 c 指派給 a (a = c). 這會產生好幾次無意義的複製.

當我們了解交換的實質之後, 我們可以實作出一個特殊的複製指派運算子 :

class C {
    friend void swap(C &, C &);
public:
    C &operator=(C rhs) {
        swap(*this, rhs);
        return *this;
    }
    // ...
};

Code 45 中, 指派運算子的參數並不是和複製指派運算子一樣是參考的形式, 而是以複製的方式傳入, 此時會形成一個和原物件相同的臨時物件. 在函式中, 將臨時物件與 *this 進行交換, 並且回傳 *this. 因為傳入的並非願物件的參考, 而使願物件的複製形式, 所以即使交換之後被銷毀, 也不會對願物件產生仍然影響. 這種函式在 C++ 中自動處理了自指派的情況, 而且是天然例外安全的, 並且最終得到的結果和普通的複製指派運算子是一樣的. 如果類別中存在指標, 這段程式碼唯一可能出現例外情況的地方可能會在物件在複製時所採用的 new 表達式, 但是這種異常發生在開始交換之前, 所以也不會對願來的類別產生影響.

Code 46 中, 類別 simple_vector 複製的代價非常昂貴, 那麼臨時物件的複製就是一個可以進行性能提升的地方. 為此, C++ 11 引入了移動操作. 來自標頭檔 <utility> 中有一個函式 std::move 就是進行強行移動. 為了支援移動建構的需求, C++ 11 新標準引入了一種新的參考型別 : 右值參考, 即繫結到右值上的參考. 我們可以使用 T && 來宣告一個右值參考. 右值參考可以繫結到一個將要銷毀的物件上, 用於延長它的生命週期. 例如使用 T() 方式宣告的臨時物件, 它在使用完畢離開之後就會被銷毀, 但是如果使用 T &&t = T(); 這樣的宣告, 就可以延長 T() 的生命週期. 從原本使用完畢即銷毀變成離開 t 的可視範圍才銷毀. 當然, 一個左值的常數參考也可以延長臨時物件的生命週期 : const T &t = T();. 這個在 C++ 中十分特殊. 一個右值不可以被隱含地被繫結到左值上, 例如 T &t = T(); 這樣的宣告會產生編碼錯誤. 左值和右值在 C++ 中的區別最明顯的是左值具有持久的狀態, 但是右值的狀態是短暫的. 區分左值和右值最簡單的方法就是是否可以使用取位址運算子來取得物件的記憶體位址. 例如, &int {} 是會產生編碼錯誤的, 然而先將其繫結到右值參考上 int &&i {int {}};, 然後再取 i 的記憶體位置是可以的, 所以 i 就是左值, int {} 產生的物件就是右值.

使用 std::move 就是在告訴編碼器, 這個左值要像右值一樣進行處理, 因為這一次用完這個左值之後, 之後我可能再也不會用到它.

與複製建構子相似, 移動建構子的第一個參數也是該類別的一個參考型別, 而且其它參數必須有預設的值. 不過不同於複製建構子的是, 移動建構子第一個參數的參考是一個右值參考, 而且一般不被 const 所標識 (因為 const T && 是無意義的型別), 因為移動建構操作可能會對願物件產生天翻地覆的影響. 拿 std::string 來作為範例. std::string 物件在移動之後可能導致物件為空. 但是不同的物件對於移動建構的處理有自己不同的方法, 這個主要取決於類別的設計者. 正是因為我們一般不會去仔細查看類別的實作程式碼, 所以一般不對移動後的物件再次使用, 甚至也不會對其值作出任何假定. 所有移動建構子必須遵守一個原則, 原物件被移動之後, 必須保證原物件可以正常被解構或者被指派一個新的值, 即不依賴於目前值的情況之下還可以被安全使用. 儘管我們並不建議程式員使用被移動之後的物件. 另外, 移動建構子不應該擲出例外情況, 所以我們一般在移動建構子參數列表之後明確標識 noexcept, 明確告訴編碼器這個過程不擲出任何例外情況. 否則, 如果移動過程中擲出例外情況, 這將導致原物件被改變, 新物件並未完全完成移動, 從而使得兩個物件都不可用. 所以我們在使用移動操作的時候, 一般都會假定這個操作不會擲出任何例外情況. 當然, 如果移動建構子可能擲出例外情況, 那麼我們應該優先使用類別的複製建構子.

移動建構子也可以由編碼器合成. 編碼器會特別處理以下情況 :

  • 一個類別如果定義了自己的複製建構子, 複製指派運算子或者解構子 (或者其中的任意一個), 那麼將不會自動幫這個類別合成一個預設的移動建構子或者移動指派運算子;
  • 當一個類別中有成員變數不可以被移動建構或者移動指派的時候, 不會為這個類別自動合成預設的移動建構子或者移動指派運算子;
  • 即使通過 = default 宣告明確要求編碼器聲稱預設的移動建構子時, 若編碼器確定有些成員不可以被移動, 此時編碼器會將移動操作宣告為被刪除的函式;
  • 如果一個類別自訂了移動建構子或者移動指派運算子, 那麼這個類別合成的複製建構子或者複製指派運算子會被宣告為刪除的函式.

下面, 我們完善 Code 45simple_vector 的移動建構子 :

#include <cstddef>
#include <type_traits>

class simple_vector {
private:
    int *begin;
    int *first_empty;
    int *end;
public:
    simple_vector();
    simple_vector(const simple_vector &rhs) : begin {new int[rhs.size()]}, first_empty {this->begin}, end {this->begin + rhs.size()} {
        const auto size = rhs.size();
        for(auto i = 0; i < size; ++i) {
            this->begin[i] = rhs.begin[i];
        }
        this->first_empty += static_cast<std::ptrdiff_t>(size);
    }
    simple_vector(simple_vector &&rhs) noexcept : begin {rhs.begin}, first_empty {rhs.first_empty}, end {rhs.end} {
        rhs.begin = rhs.first_empty = rhs.end = nullptr;
    }
    ~simple_vector();
    // ...
public:
    void swap(simple_vector &rhs) noexcept {
        using std::swap;
        swap(this->begin, rhs.begin);
        swap(this->first_empty, rhs.first_empty);
        swap(this->end, rhs.end);
    }
    std::size_t size() const noexcept;
    // ...
};

這樣, 對於 simple_vector 兩個物件 ab 的移動, 我們可以通過移動來進行, 先把 b 通過移動的方式指派給臨時物件 c (c = std::move(b)), 然後以移動的方式將 a 指派給 b (b = std::move(a)), 最後再將臨時物件 c 通過移動指派給 a (a = std::move(c)). 這中間, 總共有九次指標的複製操作, 這比呼叫複製建構子快多了.

除了插入疊代器之外, 還有一個移動疊代器 std::move_iterator, 它也被定義在標頭檔 <iterator> 中. 移動疊代器解參考之後回傳的是內部儲存元素的右值. 我們可以通過標準程式庫函式 std::make_move_iterator 將一個普通的疊代器轉換為一個移動疊代器. 將移動疊代器傳遞給一個具有建構操作的演算法意味著演算法中將會使用移動建構的方式來建構新的元素.

對於一個移動後不確定的物件 (不了解對應型別如何設計), 再次對其進行移動將是危險的. 因此, 並不建議隨意使用移動操作. 小心使用移動操作, 將會給性能帶來提升, 否則將會產生難以查找的錯誤.

C++ 中, 允許一個右值物件使用其成員函式, 這造成了一個有趣的現象, 拿 std::string 來做範例 : string() + string() = "123". 上述陳述式是正確的, 並且在 C++ 98/03 中是無法阻止的. 而這樣的操作並非類別設計者所希望產生的. 所以在 C++ 11 下, 如果希望在阻止這樣的用法, 即希望強制等號左側的運算物件最終是一個左值, 那麼可以在參數列表之後放置一個參考標識符 & 或者 &&. 參考標識符限定了一個成員函式只可以被一個左值呼叫還是只可以被一個右值呼叫. 並且參考限定符只可以用於非靜態成員函式, 而且必須同時出現在宣告與實作中. 一個成員函式可以同時使用 const 標識符和參考標識符進行限定, 但是參考標識符只能跟隨在 const 之後 :

class simple_string {
public:
    simple_string operator=(const simple_string &) &;
    // ...
};
simple_string operator+(const simple_string &, const simple_string &);
int main(int argc, char *argv[]) {
    simple_string a, b;
    auto c = a + b;     // OK
    simple_string {} = a + b;      // Error, operator= is specified by &
}

14. 運算子與型別轉換多載

多載運算子函式的參數數量與運算元對應的運算物件一樣多. 例如 : 一元運算子只能有一個參數, 二元運算子只能由兩個參數. 左邊的運算物件會傳遞給第一個參數, 右邊的運算物件會傳遞給第二個參數. 除了函式呼叫運算子 "()" 之外, 其它運算子對應的多載函式參數列表中不可以有預設引數. 如果一個運算子是成員函式, 那麼第一個運算物件隱含地被繫結到 this 指標上. 因此, 運算子多載成員函式的參數比實際的運算子運算物件少一個. 我們不可以實作關於內建型別的多載運算子, 例如 double operator+(int, float);. 多載運算子的參數列表中至少出現一個用戶自訂類別. 我們只能對已經存在的運算子進行多載, 而無法發明新的運算子. 對於一個被多載的運算子來說, 其優先級和結合律和對應的內建運算子是保持一致的.

可以多載的運算子有 :

+ - * / % ^
& | ~ ! , =
< > <= >= ++ --
<< >> == != && ||
+= -= /= %= ^= &=
|= *= <<= >>= [] ()
-> ->* new new[] delete delete[]

以下運算子不能被多載 : ., ::, .*, ?:, sizeof, sizeof..., alignof, noexcept, typeid, T(argument), static_cast, dynamic_cast, const_castreinterpret_cast. 其中, T 是任意型別.

我們可以通過明確呼叫的方式來呼叫多載的運算子 :

struct S {
    S operator+(S);
};
int main(int argc, char *argv[]) {
    std::string a, b;
    auto c = a + b;      // OK, implicitly call to operator+(a, b)
    auto d = operator+(a, b);       // OK
    auto e = a.operator+(b);        // Error, because there is no member function operator+ in std::string
    
    S s1, s2;
    auto s3 = s1 + s2;      // OK, implicitly call to s1.operator+(s2)
    auto s4 = s1.operator+(s2);     // OK
    auto s5 = opertaor+(s1, s2);        // Error, because there is no function operator+(S, S) in scope
}

即使有些運算子支援被多載, 但是多載可能會捨棄其某些屬性, 所以以下運算子不建議進行多載 : &, ,, &&||. 部分運算子在多載的時候有特殊規則 :

  • =, [], ()-> 運算子在多載時必須實作為成員函式;
  • 複合指派運算子一般是成員函式;
  • 改變物件狀態的運算子或者與給定型別密切相關的運算子一般是成員函式 (--, ++* 等);
  • 具有對稱屬性的運算子可能可以轉換任意一端的運算物件, 通常是類別的友誼函式, 並且實作在類別之外 (+, - 和比較運算子等);
  • 如果多載運算子的第一個參數不是自訂的類別物件, 而是內建型別對應的物件, 那麼運算子必須定義為非成員函式 (如果放在類別內, 第一個參數被隱含地繫結到 this 上, 造成參數列表中有三個參數, 所以不能將混合型別的表達式放在類別內).

C++ 為 <<>> 提供了 IO 意義, 因此如果多載的時候仍然想保持來自標準樣板程式庫的 IO 意義, 那麼第一個參數和回傳型別必須是 std::ostream & 或者 std::istream & (回傳的物件一般都是第一個引數), 第二個參數型別才是類別本身. 所以這個運算子必須實作在類別之外.

如果類別定義了多載算數運算子, 那麼一般來說應該同時定義多載的複合指派運算子. 例如某個類別多載了 + 運算子, 那麼一般也要多載 += 運算子. 如果類別定義了 == 運算子, 那麼同時也應該儘量定義 != 運算子. 如果運算子的比較意義沒有改變的話, 其中一個運算子可以委託另外一個運算子. 對於比較運算子 <, <=, >>= 亦是如此. 對於這一類運算子, 我們儘量保持多載的運算子和運算子本身代表的意義相同.

陣列註標運算子通常以存取元素的參考作為回傳值, 這樣能使其出現在指派運算子的任何一端. 並且定義陣列註標運算子的時候, 通常定義兩個版本 (一個帶有 const 標識, 另一個不帶有 const 標識), 一個回傳普通的參考形式, 另外一個回傳常數的參考.

多載遞增或者遞減運算子的時候, 應該同時定義前置版本與後置版本. 在區分前置版本和後置版本時, 通常會在後置版本的參數中增加一個 int 型別的參數, 這個參數通常不會被使用, 所以可以不需要為這個參數命名. 編碼器會自動為其提供值為 0 的引數. 參數在此只是起到區分前置和後置版本的作用, 並不會參與實際的運算. 雖然語法上, 後置的運算子是使用這個參數的, 但是一般不會這麼做. 前置的遞增或者遞減運算子應該回傳參考, 後置的遞增或者遞減運算子應該回傳原值的拷貝而不是參考的形式. 實作後置的遞增或者遞減運算子的時候可以宣告一個額外的臨時物件作為 *this 的備份, 然後把實際的工作委託給前置的遞增或者遞減版本, 最終回傳這個臨時的物件. 在明確呼叫的時候, 應該對後置的版本傳入一個 int 型別的引數或者可以轉型為 int 型別的引數 : value.operator++(0).

一般來說, 多載了 * 解參考運算子的類別同時也應該多載 -> 運算子, 而 -> 運算子的工作一般委託給 * 來完成. -> 運算子一般不丟棄其成員存取的含義, 否則它將只能通過 object.operator->() 這樣的形式來呼叫.

如果一個類別多載了 () 運算子, 那麼可以稱這個類別為函式類別, 其產生的物件稱為函式物件或者可呼叫物件. 函式物件通常被用於泛型演算法中的作為引數傳入, 它類似於函式指標或者 Lambda 表達式. 對於 Lambda 表達式來說, 編碼器會在編碼的時候將其轉換為一個型別未知的物件, 並且這個型別中多載了 () 運算子. 預設情況下, Lambda 表達式不會改變捕獲列表中的變數, 那麼多載的函式呼叫運算子就是一個帶有 const 標識的成員函式. 當 Lambda 表達式通過參考捕獲變數的時候, 編碼器就直接會在 Lambda 表達式對應的類別內產生一個參考, 直接參考至外界變;. 而通過捕獲的方式, 編碼器需要為其建立一個對應的成員變數, 並且同時通過建構將外界的值指派給內部的成員變數.

標準樣板程式庫中定義了一組表示算數, 關係和邏輯運算的類別, 這些類別被定義在 <functional> 標頭檔中 :

std::plus<T> +
std::minus<T> -
std::multiplies<T> *
std::divides<T> /
std::modules %
std::negate<T> -
std::equal_to<T> ==
std::not_equal_to<T> !=
std::greater<T> >
std::greater_equal<T> >=
std::less<T> <
std::less_equal<T> <=
std::logical_and<T> &&
std::logical_or<T> ||
std::logical_not<T> !

這些類別都是可呼叫物件, 避免我們最近編寫函式或者 Lambda 表達式 :

#include <algorithm>
#include <iterator>
#include <functional>

int main(int argc, char *argv[]) {
    int arr[5] {5, 3, 1, 7, 8};
    std::sort(begin(arr), end(arr));        // 1, 3, 5, 7, 8
    std::sort(begin(arr), end(arr), std::greater<int> {});     // 8, 7, 5, 3, 1
}

除了判斷指標是否相等之外, 直接比較兩個指標的大小是未定行為, 但是通過 std::less, std::less_equal, std::greaterstd::greater_equal 來比較兩個指標大小就不再是未定行為.

總的來說, C++ 中具有的可呼叫物件有函式, 函式指標, Lambda 表達式, std::bind 創建的物件以及多載了函式呼叫運算子的類別. 雖然這些可呼叫物件有一樣的呼叫方式, 例如都可以通過名稱加引數列表的方式呼叫, 但是它們可能屬於完全不同的型別 :

#include <functional>

void f() {}
void (*p)() {f};
const auto lambda = [] {};
auto bind_to_f {std::bind(f)};
struct S {
    void operator()() {}
} functor;

void (*function_ptr[])() {f, p, lambda, bind_to_f, functor};        // Error

對於 Lambda 表達式來說, 編碼器為其實作了向對應型別的函式指標轉型的運算子. 因此在 Code 51 中, 前三個放入 function_ptr 中都不會產生編碼錯誤. 對於 bind_to_ffunctor 來說, 這兩個都無法向 void (*)() 型別轉型. 為了解決這個問題, 標頭檔 <functional> 中還提供了 std::function 這個類別, 專門用於存放可呼叫物件 :

#include <functional>

void f() {}
void (*p)() {f};
const auto lambda = [] {};
auto bind_to_f {std::bind(f)};
struct S {
    void operator()() {}
} functor;

std::function<void ()> callable_objects[] {f, p, lambda, bind_to_f, functor};       // OK

其中, std::function 的使用必須表明函式型別, 要注意的是 std::function<> 的樣板參數中不是放函式指標型別, 而是函式型別. std::function 為我們提供了第六種可呼叫物件, 它可以直接放入條件判斷陳述式中, 如果其內部儲存了可呼叫物件, 那麼就會回傳 true, 否則會回傳 false. 另外, std::function 中擁有一些型別成員 :

  • result_type : std::function 中可呼叫物件的回傳型別;
  • argument_type : std::function<T> 中, T 的對應型別別名.

我們不能直接將多載函式的名稱放入 std::function 的引數列表中, 因為編碼器並不知道應該具體呼叫哪一個函式. 我們需要用一個函式指標指向其中一個多載的函式, 之後將這個函式指標放入 std::function 中即可解決這個問題.

在 C++ 11 中, std::function 類別與 C++98/03 中的 std::unary_functionstd::binary_function 沒有任何關聯. 後面兩個類別在 C++ 11 中已經被更加通用的 std::bind 函式所替代, 並且已經被 C++ 11 所遺棄 (在 C++ 17 中被移除).

我們可以通過定義轉型建構子或者轉型運算子來進行類別的型別轉換, 轉型建構子就是只含有一個參數的類別建構子, 而轉型運算子是屬於多載在類別之內的運算子, 它的基本形式為 operator T();. 其中, T 是型別, 並且這個函式不需要也不能有回傳型別 (因為 T 就是回傳型別, 如果回傳值不是 T 型別或者不能通過隱含型別轉化轉換為 T 型別, 將會產生編碼錯誤). 陣列型別和函式型別不可以被多載, 多載 void 型別 (或者 const void, volatile voidconst volatile void) 是允許但是無意義的. 一般來說, 多載的轉型運算子不會修改成員變數, 所以它通常被 const 標識.

在 C++ 98/03 中, 很多標準樣板程式庫的實作中的 IO 物件都可能定義了向 bool 轉型的運算子 (也可能是向 void * 型別轉型), 以便於我們可以直接通過類似於 if(std::cin) 這樣的方式判斷資料流是否產生錯誤. 但是這樣也帶來了意外行為, 例如 std::cin << 1 這樣的表達式是合法的, 並且最終值可能為 2. 因為此時 std::cin 將被轉型為 int 型別, 如果資料流中沒有錯誤, 其值為 1, 於是有表達式 1 << 1, 最終值為 2. 為了避免這種情況, C++ 11 允許為轉型運算子標識 explicit. 標識了 explicit 的轉型運算子必須使用強制型別轉化 static_cast 或者 C-Style 轉型才可以呼叫這個轉型運算子. 但是也有一個例外, 就是針對向 bool 轉型的時候, 如果表達式被放在

  • ifwhileelse ifdo...whileswitch 的條件陳述式部分;
  • for 中的條件表達式中;
  • !, ||&& 運算子的運算物件中;
  • "?:" 條件表達式的判斷部分中,

編碼器可以幫我們自動完成強制型別轉換 :

struct S {
    bool b {true};
    explicit operator bool() const {
        return this->b;
    }
};
int main(int argc, char *argv[]) {
    S s;
    if(s) {     // OK
        // ...
    }
    bool boolean = s;       // Error
    bool boolean2 {static_cast<bool>(s)};       // OK
}

如果建構子可以轉型, 那麼儘量不要實作轉型運算子, 否則可能產生編碼錯誤 :

class B;
class A {
public:
    A() = default;
    A(const B &);
};
class B {
public:
    operator A() const;
};
void f(const A &) {}
int main(int argc, char *argv[]) {
    B b;
    f(b);       // calling to A(const B &) or operator B::A() const is ambiguous
    f(A(b));        // OK
    f(b.operator A());       // OK
}

如果在呼叫多載函式的時候, 需要使用建構子或者明確型別轉換來改變引數, 則通常程式存在設計不足 :

class A {
public:
    A(int);
};
class B {
public:
    B(int);
};
void f(A);
void f(B);
int main(int argc, char *argv[]) {
    f(10);      // calling to A(int) or B(int) is ambiguous
}

如果一個類別提供了向算數型別的轉換運算子, 也多載了對應的算數運算的運算子, 那麼將會遇到多載運算子與內建運算子衝突的問題 :

class C {
    friend C operator+(const C &, const C &);
public:
    C() = default;
    C(int);
    operator int() const;
};
int main(int argc, char *argv[]) {
    C a, b;
    C c = a + b;        // OK
    int p = a + 3;      // Error between operator+(const C &, const C &) and built-in operator+(int, int)
}

15. 物件導向程式設計

在繼承中, publicprotectedprivate 之間有這樣的關係 :

private 成員 protected 成員 public 成員
private 繼承 不會被繼承 會被繼承且屬性變成 private 會被繼承且屬性變成 private
protected 繼承 不會被繼承 會被繼承且屬性保持 protected 會被繼承且屬性變成 protected
public 繼承 不會被繼承 會被繼承且屬性保持 protected 會被繼承且屬性保持 public

當使用基礎類別的參考或者指標呼叫一個虛擬函式將發生動態繫結. 當一個類別存在虛擬函式的時候, 即使類別中的解構子什麼都不做, 但仍然應該將其宣告為虛擬函式. 如果基礎類別將一個函式宣告為 virtual 的, 那麼它在衍生類別中也是隱含的 virtual 函式. 如果成員函式未被宣告為 virtual 的, 那麼函式匹配將發生在編碼期而非運作期間. C++ 11 允許衍生類別的成員函式在參數列表之後, const 標識之後和參考標識符之後添加 override 標識說明這個成員函式是從基礎類別繼承過來的並且被覆寫, 將覆寫的繼承虛擬函式的檢查交給編碼器. 如果編碼器無法發現有對應的可以繼承的虛擬函式, 那麼將會產生編碼錯誤.

因為在衍生類別繼承了基礎類別中的成員變數, 所以我們能夠將衍生類別的物件當作基礎類別的物件來使用. 我們能將基礎類別的指標或者參考繫結到衍生類別物件的基礎類別部分上 (也就是捨棄衍生類別多出來的那個部分). 這種轉型被稱為衍生類別到基礎類別的型別轉換, 由編碼器進行隱含地轉換. 儘管衍生類別中有繼承而來的成員變數, 但是衍生類別並不能直接對這些成員變數直接進行初始化, 必須委託基礎類別的建構子來完成初始化. 也就是說, 每一個類別都由自己來控制屬於自己本身的成員變數的初始化過程. 衍生類別的建構子會首先進行基礎類別部分的初始化, 之後按照本身的成員變數的宣告順序進行衍生類別新增成員的初始化. 儘管從語法上來說, 我們可以在衍生類別中對繼承而來的可存取的成員變數進行指派, 但是最好不要這麼做, 盡量遵守每個類別的建構子只管理自己的成員變數. 如果基礎類別宣告了一個靜態的成員, 那麼在整個繼承體系中, 只存在該成員的唯一定義, 並且靜態成員在繼承中遵守通用的存取控制規則 :

#include <cassert>
#include <string>

struct base {
    int a;
    char b;
    float c;
    static const char *name;
    base(int a, char b, float c) : a {a}, b {b}, c {c} {}
    virtual ~base() {}
};
const char *base::name = "base";
struct derived : base {
    double d;
    void *e;
    derived(int a, char b, float c, double d, void *e) : base(a, b, c), d {d}, e {e} {
        base::name = "derived";
    }
};
int main(int argc, char *argv[]) {
    derived d(1, 2, 3, 4, 0);
    assert(d.name == std::string("derived"));        // assertion pass
    base &b = d;
    assert(b.name == std::string("derived"));       // assertion pass
    base b2(1, 2, 3);
    assert(b2.name == std::string("derived"));      // assertion pass, because derived(int, char, float, double, void *) has changed the value of base::name
}

對於衍生類別的宣告和普通類別的宣告相同, 即衍生類別在宣告的時候無需在其後面添加衍生列表, 否則會產生編碼錯誤. 如果要將以某個類別作為基礎類別, 那麼它必須已經被實作而非僅僅被宣告. 這個規則中包含了一個隱含的意思, 即每一個類別不能衍生其本身. 如果不想一個類別被其它類別所繼承, 在 C++ 11 中可以在類別名稱後添加 final 標識符. 當然, 如果不相讓一個虛擬函式被繼承, 也可以在函式參數列表之後, const 標識之後和參考標識符之後添加 final 標識. 繼承一個帶有 final 標識的名稱將會產生編碼錯誤. 在函式宣告中, finaloverride 的先後順序是不固定的.

如果基礎類別的指標或者參考繫結到了衍生類別的物件上, 如果要把這個物件轉型回衍生類別, 必須使用 dynamic_cast, 這個轉型運算子的運作和檢查都在運作期而不是編碼期. 不存在基礎類別向衍生類別的隱含轉型. 因為在編碼期的時候, 編碼器是無法確定某個特定的轉換在運作的時候是否安全的, 編碼器只能通過檢查指標或者參考的靜態型別來推斷轉型是否合法. 如果使用衍生類別的物件對一個基礎類別的物件進行初始化, 那麼這個衍生類別的物件會首先向基礎類別轉型, 只保留類別內從基礎類別中繼承的那一部分, 然後再進行初始化. 部分轉型可能會因為存取權限的問題而變得不可行 :

class base {
private:
    int a;
protected:
    char b;
};
class derived : base {};
int main(int argc, char *argv[]) {
    derived d;
    base &b = d;        // error
}

public 集成的衍生類別物件無法向基礎類別轉型. 在 Code 58 中, derived 是預設通過 private 繼承 base 的. 除此之外, 有的轉型只針對指標和參考生效 :

struct base {
    constexpr base() = default;
    base(const base &) = delete;
    base &operator=(const base &) = delete;
    virtual ~base() = default;
};
struct derived : public base {};
int main(int argc, char *argv[]) {
    base b {derived {}};        // Error, copy is not allowed
    base *p {new derived {}};       // OK, no copy here
}

一般情況下, 當我們不再使用某些函式的時候, 可以無需在當前的檔案下為其宣告或者實作. 但是對於類別中的虛擬函式來說, 無論是否被用到, 都要對其宣告並且實作. 因為動態繫結是發生在運作時期的, 而並不是編碼時期, 在編碼時期是無法進行確認的. 除此之外, 衍生類別中繼承的虛擬函式不能使用 = delete 標識. 一個衍生類別如果覆蓋了繼承而來的虛擬函式, 那麼回傳型別, 參數和對應的標識必須和繼承而來的虛擬函式一樣. 但是對於回傳型別有一個比較特殊的地方, 就是一個虛擬函式回傳類別本身的參考或者指標 :

struct base {
    virtual base &f() {
        return *this;
    }
};
struct derived : base {
    virtual derived &f() override {     // OK, changed returning type here
        return *this;
    }
};

當通過一個普通型別的物件 (非參考非指標) 使用虛擬函式的時候, 在編碼期就可以確定對應的呼叫版本. 如果基礎類別中的虛擬函式含有預設引數, 而衍生類別中的虛擬函式被覆寫但是將預設引數更改了, 最終採用哪一個預設引數由呼叫物件的型別決定. 對於普通的物件, 在編碼期就可以決定呼叫的版本, 匹配到哪個版本就使用哪個版本的預設引數; 對於指標或者參考物件, 在運作期動態匹配到哪個版本就採用哪一個版本的預設引數. 為了避免錯誤的發生, 對於虛擬函式的預設引數不建議進行改變.

有時, 我們希望虛擬函式在被呼叫的時候, 不進行動態繫結, 而是明確執行某一個類別中的虛擬函式, 那麼只要使用可視範圍運算子指定出對應的類別名稱即可 (例如 object.base::function(parameter-list);). 這種程式碼將在編碼期被解析, 而不是在運作期間被匹配. 不過, 通常情況之下, 只有成員函式或者友誼物件的程式碼才需要使用可視範圍運算子來避免動態繫結機制.

我們可以將一個虛擬函式宣告為純虛擬函式, 即在函式之後增加 = 0 宣告. 我們可以為純虛擬函式進行實作, 但是實作的時候必須在類別外部. 當一個類別中存在 = 0 的純虛擬函式宣告的時候, 此類別將屬於抽象類別, 不能使用抽象類別宣告一個物件. 因為抽象類別只負責宣告介面, 可以使用後續的衍生類別來繼承此抽象類別. 如果衍生類別繼承了抽象類別, 那麼衍生類別必須對抽象類別中的純虛擬函式進行實作, 否則會產生編碼錯誤 (即時抽象類別的純虛擬函式在類別之外已經實作, 衍生類別仍然要實作). 當然, 抽象類別的指標或者參考仍然可以指向後續繼承的衍生類別的物件 :

struct interface {
    virtual void f() = 0;
    virtual void g() = 0;
};
struct impl : interface {
    void f() override {}
    void g() override {}
};
int main(int argc, char *argv[]) {
    interface i;        // Error, interface is an abstract class
    impl i2;        // OK
    interface *p {&i2};     // OK
}

在繼承的過程中, 如果一個類別 A1 有基礎類別 A0, 也有類別 A2 繼承自這個 A1. 現在假設某個函式或者某個類別和類別 A1 具有友誼關係, 那麼具有友誼關係的函式或者類別物件只能存取 A1 中的私用成員 (包括可以繼承過來的私用成員), 對 A0 中的 private 私用成員或者 A2 中的私用成員沒有任何存取特權 :

class A0 {
private:
    int a;
protected:
    char b;
};
class A1 : private A0 {
    friend void f(A1 &);
};
class A2 : public A1 {
private:
    int c;
};
void f(A1 &obj) {
    auto a {obj.a};     // Error, obj.a is not a member of A2
    auto b {obj.b};     // OK, b has been integrated into A1
    auto c {dynamic_cast<A2>(obj).c};       // Error, f is not a friend to A2
}
int main(int argc, char *argv[]) {
    A1 *p {new A2};
    f(*p);
}

一般來說, 只有 public 集成才能夠使得衍生類別的物件向基礎類別轉型. 但是有一個例外, 衍生類別的成員函式與友誼物件都可能使衍生類別物件向基礎類別發生型別轉換. 因為衍生類別可以存取到基礎類別中的 protected 以及 public 成員, 而友誼物件可能存取到基礎類別中的任意成員. 需要注意的是, 友誼關係沒有辦法被繼承.

在繼承中, 我們可以通過 using 宣告來改變繼承過來的成員的對外權限 :

struct base {
    int a;
    char b;
};
struct derived : base {
protected:
    using base::a;
};
int main(int argc, char *argv[]) {
    derived d;
    d.b = 'c';      // OK
    d.a = 42;       // Error, a is protected member in derived
}

classstruct 可以相互進行繼承 :

struct s1 {
    int a;
};
class s2 {
public:
    char b;
};
struct s3 : s2 {};
class s4 : s1 {};
int main(int argc, char *argv[]) {
    s3 obj1;
    obj1.b = 'p';        // OK
    s4 obj2;
    obj2.a = 42;        // Error, a is private member in s4
}

當存在繼承關係的時候, 衍生類別的可視範圍在基礎類別的可視範圍之內. 如果在某個函式在衍生類別中被宣告, 但是基礎類別中沒有這樣的函式, 那麼通過基礎類別的指標或者參考動態繫結存取這個函式, 會發生編碼錯誤. 如果確實有這樣的需求, 我們只需要讓這個函式在基礎類別中被宣告為虛擬函式即可.

當編碼器在搜尋函式名稱的時候, 一旦找到同名函式, 就不會再繼續搜尋了. 因此當引數與參數不匹配的時候, 即使外部可視範圍中有匹配的函式, 編碼器也不會繼續進行搜尋匹配, 而是直接產生編碼錯誤. 這個規則在類別內仍然適用 :

void f();

struct A {
    void f();
};
struct B : A {
    void f(int);
};

int main(int argc, char *argv[]) {
    {
        void f(int);
        f();        // Error
        ::f();      // OK
    }
    B b;
    b.f();      // Error
    b.A::f();       // OK
}

因此對於被多載的虛擬函式來說, 如果繼承的衍生類別想要重寫其中的某一個, 那麼對於其它多載的虛擬函式來說, 這些也需要被重寫. 否則, 根據函式的匹配規則, 這些將被編碼器視為外部可視範圍的函式. 為了解決這樣的限制, 可以使用 using 對同名函式在衍生類別對應權限下進行宣告. 宣告時, 只需要指出名字即可, 無需指定參數列表, 就像改變成員權限那樣. 此時, 基礎類別中被 using 宣告的多載虛擬函式會被全部添加到衍生類別的可視範圍中. 衍生類別中只需要自訂那些有需要自訂的虛擬函式即可, 而無需將全部的多載的虛擬函式都重新實作一遍.

如果基礎類別的解構子並不是虛擬函式, 但是基礎類別中存在虛擬函式, 那麼 delete 一個指向衍生類別物件的基礎類別的指標將產生未定行為.

在基礎類別中, 若預設建構子, 複製建構子, 移動建構子, 複製指派運算子, 移動指派運算子和解構子是被刪除的函式或者 private 私用級別的, 那麼沒有自訂的情況下, 衍生類別中對應的特殊函式也將被編碼器宣告為被刪除的函式.

當衍生類別實作複製或者移動操作的時候, 該操作應該負責複製或者移動所有成員, 包括從基礎類別中繼承過來的成員. 但是我們通常將從基礎類別中繼承過來的成員委託給基礎類別中對應的操作而非重新實作 :

class A {
public:
    A(A &&) noexcept;
    // ...
};
class B : public A {
private:
    std::string str;
public:
    B(B &&rhs) noexcept : A(std::move(rhs)), str {std::move(rhs.str)} {}
    // ...
};

對於解構子來說, 無需在衍生類別的解構子中明確呼叫基礎類別中的解構子, 因為會自動從衍生類別開始執行, 直到最底層的基礎類別的解構子被呼叫為止.

如果建構子或者解構子中呼叫了虛擬函式, 動態繫結不會發生, 只會呼叫本類別的對應函式.

在 C++ 11 中, 衍生類別可以直接通過 using 宣告重用其直接基礎類別的建構子 :

class base {
public:
    base();
};
class derived : public base {
public:
    using base::base;
    /* C++ 98/03 :
    derived() : base() {
        // ...
    }*/
};

當基礎類別中的建構子具有參數的時候, 編碼器會合成這樣的版本 : derived(parameter_list) : base(parameter_list) {}. 如果衍生類別中宣告了自己的成員變數, 那麼這些成員變數會被預設初始化. 與上面提到過的 using 宣告不同的是, 對於基礎類別中的建構子在衍生類別中的宣告將不會改變這些建構子的存取權限, 不管 using 宣告出現在什麼地方. 對於一個 using 宣告來說, 不能額外為其標識 explicit 或者 constexpr. 若基礎類別中有相對應的屬性限定, 那麼通過 using 宣告將會繼承這樣的屬性. 當一個基礎類別的建構子擁有預設引數的時候, 使用 using 宣告不會使得預設引數被繼承, 同時衍生類別將獲得多個建構子, 每個建構子分別省略掉一個函優預設引數的參數. 例如, base 中有一個建構子 base(int a, char b = 'c', long c = 0), 那麼編碼器會為衍生類別產生三個建構子 :

  • derived(int),
  • derived(int, char),
  • derived(int, char, long).

當基礎類別擁有多個建構子的時候, 衍生類別可以重用所有來自基礎類別中的建構子. 但當基礎類別中的建構子與衍生類別中的某個自訂的建構子有相同的參數列表的時候, 將使用自訂的建構子覆蓋基礎類別中繼承而來的建構子.

16. 樣板與泛型程式設計

在宣告一個新的樣板時, 樣板的參數列表不可以為空. 樣板參數的名稱也可以和函式參數名稱一樣, 如果樣板內部用不到的話可以省略掉. 樣板引數的數量必須和樣板參數的數量嚴格匹配. 除了用型別參數宣告樣板之外, 還可以用非型別參數來宣告樣板. 這些參數接受的樣板引數必須是一個常數表達式, 它可以是整型型別或者一個指向物件或者函式的指標或左值參考, 而且指標或者左值參考必須具有靜態生存期. 不能使用一個非 static 的普通局域變數或者動態物件作為指標或者左值參考的非型別參數的引數. 指標可以用字面值為 0 (NULL) 或者 nullptr 的常數表達式. 與普通函式相似, 樣板函式也可以宣告為 inline 或者 constexpr 的, 但是需要跟在樣板的參數列表之後.

在通用程式設計中, 我們需要考慮到那些不存在的情況以及未定行為 :

template <typename T>
bool compare_less(const T &a, const T &b) {
    return a < b;
}

compare_less 看似毫無問題, 但是實際上如果 T 的型別是指標, 直接比較指標就是未定行為. 因此, return 陳述式中可以借助 std::less 來比較.

與樣板相關的程式碼應該儘量放入標頭檔中. 樣板程式應該儘量減少對引數型別的要求. 對於類別樣板來說, 如果想把成員函式實作在類別之外, 在實作的時候應該同時宣告樣板以及型別參數. 在樣板被具現化之前, 必須保證樣板的定義, 包括類別樣板成員的定義都是可視的 :

template <typename T>
struct s {
    void f();
};
#include "s_69.hpp"

template <typename T>
void s<T>::f() {}
#include "s_69.hpp"

int main(int argc, char *argv[]) {
    s<int> object;
    object.f();     // link error
}

Code 69 中, 編碼器在具現化類別樣板 s 的時候只看到了成員函式 f 的宣告, 沒有看到它在 s_69.cpp 中被實作了. 這是類別樣板和普通類別不同的地方. 於是, 連結器找不到函式 f 的實作便擲出了連結錯誤. 正確的做法是在 main_69.cpp#include "s_69.hpp" 的下一行增加 #include "s_69.cpp".

事實上當對樣板進行具現化的時候, 編碼器會為其產生一個等價的函式或者類別. 例如對於類別樣板 A<T>, 當我們使用 int 替換 T 的時候, 編碼器會我們產生一個類別 A<int>, 類別中所有的 T 都被 int 替換. 如果我們再使用 A<char>, 編碼器就會再產生一個 A<char>, 類別中所有的 T 都被 char 替換. 另外, A<int>A<char> 是獨立的. 但是對於類別樣板中的函式, 它只有使用的時候才會被具現化. 也就是說, 如果類別樣板 A 中有一個函式 f, 當用 int 去替換 T 的時候, 這個函式不會產生編碼錯誤; 但是如果用 char 去替換 T 的時候, 這個函式就會產生編碼錯誤. 然而, 對於 A<char>::f, 如果我們宣告 A<char> 型別的物件之後, 沒有去呼叫成員函式 f, 那麼就不會產生編碼錯誤 :

template <typename T>
struct A {
    void f() {
        T value {256};
        // ...
    }
};
int main(int argc, char *argv[]) {
    A<int> a_int;
    A<char> a_char;     // OK, we still have not called A<char>::f
    a_int.f();      // OK
    a_char.f();     // Error, 256 is out of range
}

當我們使用一個類別樣板時, 必須提供一個樣板引數. 但是例外的是, 在類別樣板 A<T> 之內, 所有的 A 都會被編碼器解釋為 A<T>. 也就是說, 在實作類別樣板的時候, 我們可以在類別之內直接省略樣板引數.

類別樣板的友誼關係可以分為以下幾種 :

  • 若在一個類別樣板 A<T> 中宣告了和某個函式或者類別的友誼關係, 那麼這個函式或者類別可以存取到 A<T> 中的私用成員, 不論 T 被替換成什麼;
  • 若在一個類別中, 宣告了和類別樣板 A<T> 的友誼關係, 那麼不論 T 被替換成什麼, 任意 A<T> 都可以存取到這個類別的私用成員;
  • 若在一個類別樣板 A<T> 中宣告了和 B<T> 的友誼關係, 那麼 B<T> 可以存取到 A<T> 的私用成員, B<U> 可以存取到 B<T> 的私用成員, 但是 B<T> 不可以存取 A<U> 中的私用成員. 其中, TU 是不同的型別;
  • 若在一個類別 T 中宣告和類別樣板 A<T> 的友誼關係, 那麼 A<T> 可以存取到 T 的所有私用成員;
  • 在 C++ 11 之後, 若在一個類別樣板 A<T> 中宣告了和 T 的友誼關係, 那麼 T 可以存取到 A<T> 的私用成員 (即時 T 是內建型別, 這種宣告不會產生編碼錯誤, 只是無意義).

C++ 11 允許我們為樣板宣告一個別名 :

template <typename T, typename U, typename V, typename W>
struct S;
template <typename T, typename U, typename V>
using alias_S = S<T, U, V, int>;
template <typename T, typename U>
using re = S<T, T, U, U>;
int main(int argc, char *argv[]) {
    alias_S<char, void *, char *> *p;       // same as S<char, void *, char *, int> *p
    re<int, double> *p2;        // same as S<int, int, double, double> *p2
}

對於類別樣板中的靜態成員變數來說, 它是在同樣板引數間共享, 在不同樣板引數之間不共享. 也就是說, 如果類別樣板 A<T> 中存在一個靜態成員變數 v, 那麼兩個 A<int> 型別的物件共享 A<int>::v, 兩個 A<char> 型別物件共享 A<char>::v, A<int> 無法存取到 A<char> 中的那個靜態成員變數.

樣板參數遵循一般的可視範圍規則, 即內部可視範圍的名稱會覆蓋外部可視範圍的名稱. 預設情況下, C++ 假定通過可視範圍運算子存取的名稱不是一個型別, 而是一個靜態成員. 若我們希望告訴編碼器我們通過可視範圍運算子存取的是型別成員, 那麼可以在前面增加 typename 標識. 例如 typename std::vector<int>::size_type. 此處 typename 不能使用 class 替代.

C++ 11 允許為函式樣板參數提供預設引數, 而 C++ 98/03 只允許為類別樣板參數提供預設引數. 與函式參數的預設引數類似, 對於類別樣板參數來說, 必須把帶有預設引數的參數放到參數列表的最後位置. 但是和類別樣板不同的是, 函式樣板的預設引數卻可以不這樣 :

template <typename T, typename U = int, typename V>     // Error
struct S;
template <typename T = int, typename U>     // OK
void f(T, U);

在樣板的具現化中, 如果我們需要使用樣板預設引數, 那麼可以直接省略. 有時, 當樣板中的樣板參數全部具有預設引數的時候, 可以使樣板參數列表為空, 例如 A<>.

如果一個成員函式是一個函式樣板, 那麼這個成員函式不可以成為虛擬函式. 如果類別樣板或者函式樣板的宣告中帶有預設引數, 那麼接下來的宣告就不需要再次帶有預設引數, 否則會產生編碼錯誤. 當一個類別樣板中的成員函式樣板被實作在類別樣板之外的時候, 必須同時提供類別樣板的樣板參數列表和成員函式樣板的樣板參數列表 :

template <typename T>
class vector {
public:
    template <typename InputIterator>
    vector(InputIterator, InputIterator);
};
template <typename T>
template <typename InputIterator>
vector<T>::vector(InputIterator begin, InputIterator end) {
    // ...
}

當有多份分離檔案使用了一個樣板具現體時, 編碼器會為每一份檔案都生成一個對應的具現體. 此時, 會帶來額外的編碼開銷同時導致程式碼膨脹 :

template <typename T>
bool to_bool(const T &t) {
    return static_cast<bool>(t);
}
#include "74_to_bool.hpp"

void f(int a) {
    auto b = to_bool(a);
    // ...
}
#include "74_to_bool.hpp"

void g(int a) {
    auto b = to_bool(a);
    // ...
}

Code 74 中, 編碼器會分別在 74_f.cpp74_g.cpp 中生成函式樣板 to_bool 的具現體 : bool to_bool(const int &);. 這兩個具現體實際上是一模一樣的, 因此編碼器本來只需要生成一份就可以了. 為了解決這個問題, 在 C++ 中, 我們可以通過手動的方式明確要求編碼器生成一份具現體. 我們可以在其中一份檔案中添加 template bool to_bool(cont int &); 宣告, 在另一份檔案中添加 extern template bool to_bool(const int &); 宣告. 對於添加了 extern template 宣告的那一份檔案, 編碼器首先會去其它檔案尋找匹配的具現體以避免重複生成具現體.

當將陣列或者函式作為引數傳遞給函式樣板的參數中的時候, 引數型別會被推導為陣列指標或者函式指標 :

template <typename T>
void f(T t) {
    T ptr {nullptr};        // OK
    T array {1, 2, 3, 4, 5};        // Error
}
int main(int argc, char *argv[]) {
    int arr[5];
    f(arr);
}

有時, 編碼器可能無法完成樣板參數的型別推導, 這個時候我們要像類別樣板那樣明確給出這個樣板引數 :

template <typename T, typename U>
T add(const U &a, const U &b) {
    return a + b;
}
int main(int argc, char *argv[]) {
    add(1, 2);      // cannot infer template argument T
    auto result = add<int>(1, 2);       // OK
}

這個時候, 可以推導的樣板參數是可以省略的, 例如 Code 76 中的函式樣版 add 的樣板參數 U.

在已經明確給出樣板引數的情況下, 部分轉型是可以發生的 :

#include <string>

template <typename T>
void f(const T &, const T &);
int main(int argc, char *argv[]) {
    f(1, 1.2);      // Error
    f<std::string>(std::string("123"), "abc");        // OK
}

如果某些操作的回傳型別確實無法得知, 我們可以採用尾置回傳型別配合 decltype 來推導 :

template <typename Iterator>
decltype(*Iterator {}) f(Iterator, Iterator);      // OK
template <typename Iterator>
auto g(Iterator it) -> decltype(it->value);       // OK

有時候, 單個樣板參數的函式樣板的樣板參數可能被推導為參考, 如果回傳型別也採用樣板參數的話, 回傳型別也是一個參考型別. 但是如果我們想要回傳一個值, 而不是參考, 這個時候可以借助型別萃取技術 std::remove_reference 來得到樣板引數對應的無參考型別, 它被定義在標頭檔 <type_traits> 中 :

#include <type_traits>

template <typename T>
typename std::remove_reference<T>::type f(T);

除了 std::remove_reference 之外, 還有一些其它類別可供我們使用. 每一個類別中都有一個型別成員 type, 它負責儲存完成轉型之後的型別 :

std::traits_name<T>, 其中 traits_name T typename traits_name<T>::type
remove_reference X & 或者 X && X
否則 T
add_const X &, const X & 或函式型別 T
若以上都不是 const T
add_lvalue_reference X & T
X && X &
否則 T &
add_rvalue_reference X & 或者 X && T
否則 T &&
remove_pointer X * X
否則 T
add_pointer X & 或者X && X *
否則 T *
make_signed unsigned X (signed) X
否則 T
make_unsigned (signed) X unsigned X
否則 T
remove_extent X[N] X
否則 T
remove_all_extent X[N][M]... X
否則 T
add_volatile X &, 函式型別或者 volatile X X
否則 volatile X
remove_volatile volatile X X
否則 T

當我們明確函式樣板引數之後, 我們就可以使用函式指標指向它們 :

template <typename T>
void f(const T &);
int main(int argc, char *argv[]) {
    void (*p)(const int &) = f;     // OK
    auto fp = f;        // Error, f is function template
}

函式樣板的函式參數與函式引數的繫結遵守正常的繫結規則. 即當傳入左值的時候, 可以繫結到 T & 或者 const T & 上; 當傳入右值的時候, 不可以繫結到 T &, 但是可以繫結到 const T & 或者 T && 上. 樣板也可以根據傳入的值推斷出樣板參數是否為 T &&. 但是有一個例外. 通常情況下, 我們不可以將一個左值參考繫結到一個右值上, 也就是對於一個接受 int && 引數的函式, 我們不能傳入一個變數. 但是在樣板中, 卻可以這樣做 :

void f(int &&);
template <typename T>
void g(T &&);
int main(int argc, char *argv[]) {
    int a;
    f(a);       // Error
    g(a);       // OK
}

Code 81 的函式 g 中, 由於接受了一個左值引數 a, 因此 g 的樣板參數 T 要特殊處理. 一般來說, 如果 g 的函式參數如果是 T, 而不是 T &&, 那麼在此處 T 將會被推導為 int, 不論是傳入左值還是右值, 都能夠接受. 但是此處 g 的函式參數為 T &&. 在 C++ 中, 我們把需要推導的型別的右值參考稱為通用參考. 通用參考不是通常意義上的右值參考, 它只是形式和右值參考很像. 判斷一個右值參考是否為通用參考, 就看 T && 中的 T 是否要被推導. 例如 template <typename T> using type = T &&; 這樣的宣告, 因為 T 需要被推導, 所以最終 type 就是通用參考. 像 using int_rref = int &&; 這樣的宣告, int_rref 就不是通用參考, 只是一個純粹的右值參考, 因為 int_rref 的最終型別不需要進行推導.

現在我們來講一下在 Code 81 中, 對於函式 g 中使用通用參考和函式 f 使用的普通參考行為有什麼不一樣. 對於函式 f 來說, 它只接受右值. 但是對於函式 g 來說, 它可以接受左值, 也可以接受右值. 並且對於 template <typename T> void h(T); 這樣的函式來說, g 的行為不但和 f 不同, 也和 h 不同. 在 h 中, 不論傳入的值是左值還是右值, 準確地來說, 假設傳入的值稱為 value, 並且它不是陣列或者函式, 那麼最終 T 的型別是 typename std::remove_reference<decltype(value)>::type. 但是在 g 中, 如果傳入左值, T 會被推導為 T &; 如果傳入右值, T 會被推導為 T &&. 此時, 如果 T 已經被推導為參考型別了, 在後面再加一個右值參考 &&, 從而產生類似於 T & && 或者 T && &&, 那麼肯定會產生錯誤. 因為 C++ 中不存在參考的參考. 此時, 編碼器會讓參考發生折疊 :

  • T & && 會被折疊為 T &;
  • T && & 會被折疊為 T &;
  • T & & 會被折疊為 T &;
  • T && && 會被折疊為 T &&.

因此在 Code 81 中, g(a) 最終會導致 T 會被推導為 int &. 這個規則暗示了如果一個參數型別是通用參考, 那麼它可以接受任意值. 如果函式 g 中存在 ++a 這樣的表達式, 那麼 g(a) 就會改變外部 a 的值.

知道了參考折疊之後, 我們就可以實作出 std::move :

template <typename T>
typename std::remove_reference<T>::type &&move(T &&t) {
    return static_cast<typename std::remove_reference<T>::type &&>(t);
}

因為樣板參數型別繫結的例外規則, 我們可以向上述函式傳遞任何引數, 不管這個引數是左值還是右值, 甚至是左值參考或者右值參考. 通過標準樣板程式庫的型別轉換樣板 std::remove_reference 來移除型別當中的參考. 此時, std::remove_reference 中的 type 型別成員就是一個普通的非參考型別. 然後通過明確型別轉換, 將 t 轉換為對應的右值參考型別, 最終回傳. 這其中, 又包含了一個對於左值參考的一個特例規則 : 儘管無法隱含地從一個左值參考型別轉換為右值參考型別, 但是可以通過 static_cast 進行明確型別轉換, 將一個左值參考轉換為右值參考.

有時候, 我們可能需要寫一個函式中間件 :

template <typename F, typename T, typename U>
void f(F function, T a, U b) {
    function(a, b);
}

假如現在函式 function 的型別為 void (int &, int &&), 那麼直接傳入 a 是沒有問題的, 但是傳入 b 就會出現問題. 但是我們不能直接把 b 的傳入改為 std::move(b). 因為現在 function 的型別為 void (int &, int &&), 之後 function 的型別可能因為傳入的 function 不同而變成 void (int, int &). 不只這樣一個問題. 假如我們又希望傳入的 a 可以被函式 function 改變, 即使 function 的型別為 void (int &, int &&), 而且 function 也確實改變了傳入的 a, 但是這個 a 不是傳入函式 f 來自外部的 a, 而只是函式 f 的參數 a. 我們需要把函式 fa 的型別 T 改為 T & 才可以做到改變外部的 a. 但是如果 function 要求給的第一個引數是一個右值, 那麼我們將 T 改為 T & 又是錯誤的. 現在我們陷入了兩難的境地.

不過, 我們可以將函式 f 的參數 ab 的型別改為通用參考 T &&U &&, 這樣它既可以接受左值也可以接受右值. 為了在傳遞給 function 的時候, 仍然可以保持參數 ab 的左值或者右值甚至 const 性質, 我們需要借助 std::forward 來保持型別, 它被定義在標頭檔 <utility> 中 :

#include <utility>

template <typename F, typename T, typename U>
void f(F function, T &&a, U &&b) {
    function(std::forward<T>(a), std::forward<U>(b));
}

在樣板參數的匹配中, 能夠發生的型別轉型極其少數 :

  • const TT 轉型;
  • Tconst T 轉型;
  • 型別為 T 的左值繫結到 T & 上;
  • 陣列 (包含陣列的參考, 不論陣列的大小甚至大小未標識的陣列型別) 向陣列指標轉型;
  • 函式 (包含函式的參考) 向函式指標轉型.

也就是說, 對於型別提升這樣的轉型, 在樣板參數的匹配上是不可行的. 另外, 在函式的多載中, 如果有涉及到函式樣板, 那麼函式的匹配規則將會發生改變 :

  • 在函式匹配的過程中將會包含所有樣板參數推導成功的函式樣板具現體;
  • 可匹配的函式按照型別轉換規則來排除;
  • 優先選擇匹配的非樣板函式, 而不是樣板函式;
  • 如果沒有匹配的非樣板函式, 那麼在函式樣板中選擇那個更加特製化的.
void f(int);
template <typename T>
void f(T &&);
template <typename T>
void f(T *);
int main(int argc, char *argv[]) {
    f(0);       // choose void f(int);
    int a {};
    f(a);       // choose void f(int);
    int &b = a;
    f(b);       // choose void f(int);
    f('c');     // choose template void f(char &&);
    char c {};
    f(c);       // choose template void f(char &);
    int *p {&a};
    f(p);       // choose template void f(int *);, not template void f(int *&);
}

在字串的匹配上, 特別是原生字串, 我們可能會產生疑惑 :

#include <iostream>
#include <string>

void print(const char *str) {
    std::cout << "const char *";
}
void print(char *str) {
    std::cout << "char *";
}
void print(const std::string &str) {
    std::cout << "const std::string &";
}
template <typename T>
void print(T str) {
    std::cout << "T";
}
int main(int argc, char *argv[]) {
    print("123");
}

那麼 Code 86 最終的輸出是什麼呢? 還是說 Code 86 會產生編碼錯誤? 我們來分析一下. "123" 的型別應該是 const char (&)[4], 也就是說對於帶有樣板的 print 的樣板參數會被推導為 const char (&)[4], 它在函式匹配上和 const char * 是一樣好的. 本來 Code 86 確實應該產生編碼錯誤, 但是編碼器會優先選擇不帶有樣板的, 最終 Code 86 的輸出是 const char *.

在 C++ 11 中, 新增了可變參數樣板, 即樣板的參數可以像函式參數那樣數目可變. 我們使用 "..." 省略號來表示一個樣板參數為一個參數包 :

template <typename ...Args>
void f(Args ...);

我們可以使用 C++ 11 引入的 sizeof... 運算子來計算 Args 這個參數包中有多少個參數. 另外, sizeof...(Args) 是一個常數表達式. 當函式參數個數未知的時候, 如果函式參數的型別都相同, 我們可以考慮使用 std::initializer_list 來接收未知數量的引數; 但是如果函式參數的型別都未知或者不同, 那麼可以考慮使用可變樣板參數. 一般來說, 我們會遞迴地逐個處理引數包中的引數, 首先處理引數包中的第一個引數, 之後以剩餘的引數遞迴地呼叫自身 :

template <typename T>
long long int add(T t) {
    return t;
}
template <typename T, typename U>
long long int add(T t, U u) {
    return t + u;
}
template <typename T, typename ...Args>
long long int add(T t, Args ...args) {
    return t + add(args...);
}
int main(int argc, char *argv[]) {
    auto result = add(1, 2l, 3ll, 4, 5, 6, 7, 8, 9, 10);       // result = 55
}

函式 add 會在樣板參數包的數量為一個或者兩個的時候停止遞迴. 那麼 add(1, 2l, 3ll, 4, 5, 6, 7, 8, 9, 10) 會被解析為 1 + add(2l, 3ll, 4, 5, 6, 7, 8, 9, 10), 然後有 1 + 2l + add(3ll, 4, 5, 6, 7, 8, 9, 10), ..., 最終有 1 + 2l + 3ll + 4 + 5 + 6 + 7 + 8 + add(9, 10).

... 寫在名稱前面, 那麼這個名稱代表參數包或者引數包; ... 寫在名稱後面表示展開一個包中的型別或者引數. 一個引數包的參數可以是一個通用參考的型別參數包, 我們只需要把 Code 87 中的 Args ... 改為 Args &&... 即可. 為了保持型別進行轉遞, 同樣可以借助 std::forward :

#include <utility>

template <typename T, typename ...Args>
T construct(Args &&...args) {
    return T {std::forward<Args>(args)...};
}

當我們特製化一個樣板函式的時候, 只需要將樣板參數置空, 之後將原來的樣板參數用已知的型別填充即可. 樣板函式特製化的實質是人為地對樣板函式進行具現化, 而並非多載樣板函式. 因此, 特製化並不影響函式的匹配. 使用任何樣板具現體之前, 最好將各種特製化的具現體宣告在可視範圍中. 對於普通的類別與函式來說, 缺少宣告而無法匹配的情況下, 編碼器可以通過編碼錯誤的方式來提示我們. 但是特製化的樣板則不同, 如果編碼器無法找到它們, 那麼編碼器會通過樣板具現化的方式為我們生成一個, 這可能會導致編碼器生成的並不是我們想要的具現體, 而這種錯誤比一般的編碼錯誤更難查找.

對於樣板的特製化, 其中的成員函式如果實作在函式之外, 那麼不需要 template <> 開頭. 但是如果我們想要特製化某個成員函式針對某個特別型別的行為, 這個時候是需要 template <> 開頭的 :

template <typename T>
struct A {};
template <>
struct A<int> {
    void f();
};
void A<int>::f() {}     // OK
template <>
void A<int>::f() {}     // Error

template <typename T>
struct B {
    void f();
};
void B<int>::f() {}     // Error
template <>
void B<int>::f() {}     // OK

類別樣板的特製化還有一個特殊的地方是函式樣板無法做到的, 就是類別樣板支援部分特製化. 我們可以為其指定一部分樣板參數, 而並非全部參數 :

template <typename T, typename U>
struct S {};
template <typename T>
struct S<T, int> {};

除了部分特製化之外, 類別樣板甚至支援只特製化型別的一部分, 也就是偏特製化 :

struct alias {
    using type = int;
};

template <typename T>
struct S {};
template <typename T>
struct S<T *> {};
template <>
struct S<alias::type> {};       // 此處 typename 可以省略, 因為這裡只接受型別

學會了偏特製化之後, 我們就可以嘗試自己實作 std::remove_reference :

template <typename T>
struct remove_reference {
    using type = T;
};
template <typename T>
struct remove_reference<T &> {
    using type = T;
};
template <typename T>
struct remove_reference<T &&> {
    using type = T;
};

17. 用於大型程式的工具

17.1 例外處理

C++ 的例外處理機制具有堆疊回溯的性質. 如果一個例外情況於一個 try 內被擲出, 那麼檢查與此 try 繫結的 catch. 如果沒有辦法匹配, 那麼就繼續尋找外層巢狀 try 繫結的 catch. 若此時還不匹配, 繼續嘗試到更外層去找. 最終, 如若還是沒有一個 catch 匹配這個例外情況, 那麼此時程式將呼叫 std::terminate, 由它負責終結程式. std::terminate 被定義在標準樣板程式庫標頭檔 <exception> 中. 在堆疊回溯的過程中, 局域變數也會像函式終結那樣被銷毀. 在例外情況發生的時候, 我們應該保證所有自訂的物件和配置的記憶體都被正常回收.

一般來說, 解構子都不會發生例外情況, 因為其僅僅用於資源的回收. 但是, 如果解構子需要例外情況的話, 我們應該儘量將例外情況放於解構子的內部 try 範圍之內, 由解構子自己去處理, 而不應該讓這個例外情況超出結構子的範圍.

throw 表達式中的物件必須是一個完全型別, 如果這個物件是一個陣列或者函式, 將被自動地轉型為對應的陣列指標或者函式指標. 因為例外處理中堆疊回溯的性質, 擲出一個局域物件的指標或者參考的 throw 表達式幾乎可以存在問題. 這就如同函式回傳一個局域物件的指標. 因為指標所指向的物件位於某個可視範圍之內. 在 catch 之前, 這個範圍已經終結, 並且局域物件已經被銷毀了. 所以, 當我們擲出一個指標的時候, 必須保證該指標指向的記憶體位址沒有被回收. 當我們擲出一條表達式的時候, 該表達式的靜態編碼型別決定了這個例外物件的型別. 若一個表達式擲出的是一個解參考的基礎類別的指標, 而這個指標實際指向的是衍生類別, 但是擲出的物件仍然將會被切去衍生類別的部分, 只保留基礎類別的部分.

catch 表達式的參數列表中的名稱也可以和函式參數列表中的名稱一樣被省略. catch 參數列表中宣告的型別也同樣需要是一個完全型別, 它可以是左值參考, 但是它不可以是右值參考. 傳入 catch 的引數與函式的引數相類似, 若其參數型別為非參考型別, 那麼 catch 內部的任何更改都不會影響外部對應的物件. catch 的參數還有一個與函式參數相類似的性質是如果 catch 的參數型別為基礎類別而不是基礎類別, 那麼可以使用派生類別對其進行初始化. 若型別非參考型別的話, 其衍生部分會被切割掉. 一般情況下, 如果 catch 接受的引數型別來自某個繼承體系, 最好將這個 catch 的參數型別宣告為參考型別以避免衍生部分被切割. 由於例外情況的匹配是由 catch 出現的順序進行的, 所以我們最終匹配到的 catch 可能未必是最佳的. 因此, 越是專門的 catch 越應該放置於整個 catch 列表的前端. 當程式中使用了一個具有繼承關係的多個例外物件時, 我們應該對 catch 陳述式的順序進行組織, 將衍生類別例外處理放置在基礎類別例外處理的前面. 與函式引數和參數的匹配規則相比, 例外物件型別和 catch 宣告中的物件型別匹配的規則受到了更多的限制, 絕大多數的型別轉型都不被允許 :

  • 允許 Tconst T 轉型;
  • 允許 const TT 轉型;
  • 允許衍生類別向基礎類別進行轉型;
  • 允許陣列型別向陣列指標轉型;
  • 允許函式型別向函式指標轉型.

有時, 一個單獨的 catch 可能不能完整地處理一個例外情況. 在執行部分校正操作之後, 當前的 catch 可能通過重新擲出例外情況的方式在呼叫串列中匹配更上層的 catch 繼續進行例外情況處理. 這裡重新擲出仍然使用 throw, 不過不包含任何表達式, 即 throw;. 一個重新擲出的 throw 不包含任何表達式的原因是因為它可以將當前 catch 到的例外物件沿呼叫串列向上進行轉遞. 如果在處理例外情況之外的任何地方出現空的 throw, 那麼將直接呼叫 std::terminate 終結程式.

int main(int argc, char *argv[]) {
    int a {};
    try {
        try {
            throw &a;
        }catch(int *a) {
            *a = 41;
            throw;
        }
    }catch(int *a) {
        ++*a;
    }       // a = 42
}

有時, 我們希望不管例外物件是什麼型別, 程式都可以進行捕獲. 此時, 我們可以使用省略號運算子 "..." 來替代 catch 中的參數列表, 即 catch(...). catch(...) 可以單獨出現, 也可以和其它幾個 catch 一起出現. 如果它和其它幾個 catch 一起出現, 那麼 catch(...) 必須在最後的位置, 否則在它之後的其它幾個 catch 將永遠不會被匹配.

建構子在執行初始值列表的時候, 可能會擲出例外情況, 而我們並不能通過普通的方式捕獲在初始化列表中進行初始化的時候擲出的例外情況. 此時, 我們可以通過將建構子寫為函式 try 的形式, 使 catch 既可以處理函式內部的例外情況, 也可以處理初始化列表中的例外情況 :

#include <exception>

struct A {
    int *p;
    A(int a) try : p {new int {a}} {}catch(std::exception &e) {
        std::clog << e.what() << std::endl;
        std::terminate();
    }
};

對應地, 函式的解構子也可以寫為一個函式 try 的形式.

當我們明確某個函式不會擲出例外情況的時候, 使用之前所提到的 noexcept 標識符有助於編碼器簡化程式碼和執行某些特殊的優化, 而這些優化並不適用於可能擲出例外情況的函式. noexcept 應同時出現在函式的宣告及實作中, 並且應位於尾置回傳型別, = 0 宣告, override 標識和 final 標識之前, const 標識和參考標識之後 (對於成員函式來說). 在 C++ 11 中, noexcept 標識不會影響函式型別 (但是自 C++ 17 開始, noexcept 就開始影響函式型別了, void (*)() noexceptvoid (*)() 在 C++ 17 中是屬於不同型別). 因此在 C++ 11 中, 如果 noexcept 出現在型別別名的宣告中, 即 using 宣告或者 typedef 宣告中, 是會產生編碼錯誤的. 但是如果在函式指標中的使用中, 出現 noexcept 是沒有問題的. 一旦一個不帶有 noexcept 的指標指向一個函式, 不論這個函式有沒有 noexcept 標識, 編碼器都不會作出通過指標呼叫函式是不擲出例外情況的假設 :

void f() noexcept;
int main(int argc, char *argv[]) {
    using fp = void (*)() noexcept;     // Error
    void (*p)() noexcept {f};       // OK
}

編碼器並不會阻止一個標識了 noexcept 的函式擲出例外情況, 也不會去檢查. 一旦一個 noexcept 函式擲出了例外情況, 程式會直接呼叫函式 std::terminate 終結程式. 對於函式呼叫者而言, 一個 noexcept 函式因為擲出例外而導致程式異常終結, 函式的呼叫者無需對此進行負責.

在 C++ 11 之前, C++ 中有一套更加詳細的例外情況說明方案, 該方案使我們可以指定某個函式可能會擲出的例外情況的物件的型別. 函式可以指定一個關鍵字 throw, 在其後跟緊一個括號, 其中包括了例外物件型別列表, 其出現的位置和 noexcept 出現的位置相同 (例如 void f() throw(int, char, std::exception); 只會擲出型別為 int, charstd::exception 型別的例外情況). 這樣的方案在 C++ 11 中已經被遺棄 (這個特性在 C++ 17 中被移除, 在 C++ 17 中僅僅保留了 throw(), 但是它被 C++ 17 遺棄, 在 C++ 20 中被移除). 但儘管如此, 如果函式被 throw() 標識的, 那麼也意味著函式保證不會擲出任何例外情況. 即在 C++ 11 中, noexceptthrow() 的效果是一樣的, 但是我們不推薦使用 throw(), 而應該優先使用 noexcept.

noexcept 還可以接受一個表達式, 這個表達式必須能夠轉型或者隱含地轉型至 bool 型別, 用於表示 noexcept 標識是否生效 :

constexpr bool nothrow_spec {false};
constexpr bool throw_spec {true};
void f(int) noexcept(nothrow_spec);      // noexcept takes effect
void f(std::string) noexcept(throw_spec);       // noexcept failed

noexcept 除了作為標識符, 還可以用作運算子. 它接受一個表達式, 用於檢查這個表達式是否會擲出例外情況. 如果這個表達式不擲出例外情況, 那麼 noexcept(expression) 就會回傳 true; 否則, noexcept(expression) 回傳 false. 和 sizeof 運算子類似, noexcept 不會真的去運作表達式. 由於 noexcept 標識符接受一個表達式, 因此我們可以混用 noexcept 標識符和 noexcept 表達式 :

template <typename F, typename T, typename U>
void f(F function, const T &t, const T &u) noexcept(noexcept(function(t, u)));      // noexcept when function(t, u) is nothrow

若一個虛擬函式被 noexcept 標識, 那麼其衍生類別中對應的被覆寫的虛擬函式也要被 noexcept 標識; 若一個虛擬函式沒有作出 noexcept 說明, 那麼其衍生類別對應的被覆寫的函式既可以被 noexcept 標識, 也可以不被 noexcept 標識.

當編碼器合成特殊函式的時候, 同時也為其生成了一個例外情況標識. 如果對所有成員和基礎類別的對應操作都是 noexcept 的, 那麼這個特殊的成員函式 noexcept 的; 否則, 這個函式是 noexcept(false) 的. 對於解構子來說, 如果我們自訂了解構子但是沒有對其的例外情況擲出情況作出說明, 那麼編碼器將為其假設一個, 這個假設和成員的解構是否擲出例外情況和基礎類別的解構子是否擲出例外情況有關.

標準樣板程式庫標頭檔 <exception> 提供了一個例外情況類別 std::exception. 僅僅提供了複製建構子, 複製指派運算子和一個虛擬的解構子, 除此之外還有一個名為 what 的虛擬成員函式. 其中, 成員函式 what 回傳一個型別為 const char * 的字面值字串, 這個字串提示了程式運作過程中的例外情況細節. 另外, 成員函式 whatnoexcept 的.

Figure 1. 標準樣板程式庫例外情況繼承體系

17.2 名稱空間

當使用多個程式庫的時候, 特別是每個程式庫都由不同的作者提供, 並且將所有名稱都放置於全域名稱空間內, 將可能會引發大規模名稱污染. 名稱空間可以是不連續的, 當我們定義一個名稱空間的時候, 若這個名稱空間並不存在, 編碼器將創建一個新的名稱空間; 當這個名稱空間已經存在的時候, 將打開這個名稱空間為其增添新的名稱. 一般情況下, 我們不把 #include 放入名稱空間中. 如果將 #include 放入名稱空間中, 這將意味著將標頭檔中的所有全域名稱納入這個名稱空間下. 樣板的特製化在原始樣板所屬的名稱空間之內或者使用可視範圍運算子 :: 明確宣告名稱空間. 名稱空間支援巢狀, 內層的名稱空間中的名稱將會覆蓋外層名稱空間中的同名名稱.

C++ 11 引入了內嵌名稱空間, 內嵌名稱空間中的名稱可以直接被外層名稱空間使用, 而不需要使用可視範圍運算子 (當然, 一定要使用可視範圍運算子是可以的). 宣告內嵌名稱空間的方式是在名稱空間宣告之前加上 inline. 在名稱空間第一次被定義的時候, 若它是一個內嵌的名稱空間, 那麼一定要加上 inline. 在後續打開名稱空間的時候, 可以加上 inline, 也可以不加 inline. 內嵌名稱空間具有轉遞的性質, 即假設 BA 的內嵌名稱空間, CB 的內嵌名稱空間, 那麼可以直接通過 A:: 來使用 B 名稱空間和 C 名稱空間中的名稱. 內嵌的名稱空間通常用於版本控制. 例如在 v1 版本中, 我們定義了函式 f, 但是在 v2 更新之後, 我們重寫了函式 f. 那麼在 v1 版本中, 我們可以直接創建一個名稱空間 v1, 並且設定其為內嵌名稱空間. 這樣, 我們就可以直接存取 v1 版本中的名稱. 但是在 v2 版本升級之後, 我們希望直接存取 v2 版本中的名稱, v1 版本中的名稱並不是直接移除, 而是保留, 但是不能直接存取, 要通過可視範圍運算子 :

namespace game {
    inline namespace v1 {
        void f();
        // ...
    }
}
int main(int argc, char *argv[]) {
    game::f();      // call game::v1::f
}
namespace game {
    inline namespace v2 {
        void f();       // rewrite
        // ...
    }
    /*inline */namespace v1 {
        void f();
        // ...
    }
}
int main(int argc, char *argv[]) {
    game::f();      // call game::v2::f
}

這樣, 就不需要更改外界的程式碼.

不具名的名稱空間是指 namespace 後名稱 : namespace {}. 在不具名的名稱空間中宣告的變數具有靜態的生命週期, 直到程式終結之前, 它們才開始被銷毀. 一個不具名名稱空間可以在一個檔案內不連續, 但是不可以跨越多個檔案. 若兩個檔案內部都存在不具名的名稱空間, 那麼這兩個名稱空間互不相關. 兩個不同檔案的不具名名稱空間中可以宣告同名變數, 並且該變數的可視範圍僅在該檔案之內. 若兩個檔案都具有一個在全域名稱空間下的不具名的名稱空間, 並且兩個不具名的名稱空間中都存在一個同名的變數, 那麼它們不能被同時包含在另外一個檔案內, 否則將會產生一個變數多次定義的編碼錯誤. 若一個不具名的名稱空間在全域名稱空間中, 那麼在不具名名稱空間中已經宣告的名稱在全域可視範圍之內不可以再次宣告, 否則將產生一個名稱多次定義的編碼錯誤. 不具名的名稱空間也支援被巢狀於其它名稱空間中. 在 C++ 引入名稱空間之前, 通常需要將全域名稱空間中的變數宣告為 static 使其僅對該檔案內都有效. 這種做法由 C 繼承而來, 並且最終產生的效果與不具名名稱空間下的變數產生的效果類似. 這種做法雖然仍然可行, 但是推薦使用不具名名稱空間替代.

當一個名稱空間具有很長的名稱的時候, 可以對其宣告一個名稱空間別名 : namespace B = A;. 當然, 名稱空間的別名也可以指向巢狀的名稱空間 : namespace A = B::C::D;. 一個名稱空間可以有無限個別名, 它們於原來的名稱相互等價.

在一個類別之內, using 宣告只能用來把基礎類別中的名稱提升到當前可視範圍, 而不能在類別內使用 using namespace 來提升名稱空間內的名稱到類別可視範圍. 在多個由不同程式庫作者實現的程式庫中, 我們應儘量使用 using 宣告單條地引入名稱而避免使用 using 指示直接一次性引入名稱空間中的全部名稱. 當多個程式庫都使用 using 指示引入全部名稱到同一個可視範圍之內的時候, 相當於就把全部名稱放在全域名稱空間下, 有些編碼錯誤將難以查找. 這種錯誤可能一開始沒有任何影響, 直到很久以後引入新的名稱的時候才會爆發.

如果一個類別有友誼函式的存在參數接受這個類別物件, 那麼編碼器在進行名稱查找的時候會有一個例外規則 :

namespace N {
    class A {
        friend void f();
        friend void f(A);
    };
}
int main(int argc, char *argv[]) {
    N::A object;
    f(object);      // OK, but void f(A); is still not declared in this scope
    f();        // Error, cannot find void f();
}

我們發現函式 void f(A); 並沒有在 main 函式的可視範圍之內宣告, 但是編碼器卻可以找到這個函式. 這便是一個名稱查找的例外. 當一個類別的友誼函式有參數接受該類別的物件的時候, 如果編碼器在當前或者更外層的可視範圍內找不到這個函式, 那麼還會去這個類別的可視範圍中去尋找. 這個例外規則帶來一個好處, 例如我們只引入了標頭檔 <string><iostream>, 沒有宣告 using namespace std;, 但是我們卻可以使用 std::cin 或者 std::cout 來讀入或者輸出一個 std::string 物件. 如果沒有這個例外規則, 我們還需要明確寫出一條宣告 : using std::operator>>; 或者 using std::operator<<;.

不只是友誼函式 :

namespace A {
    struct base {
        virtual ~base() = default;
    };
    void f(base &);
}
class derived : public A::base {};
int main(int argc, char *argv[]) {
    derived d;
    f(d);
}

當編碼器在 main 函式, 類別 derived 中和全域名稱空間中都找不到 void f(A::base &); 的時候, 就會嘗試去 derived 的基礎類別 base 中和 base 所在的名稱空間去尋找.

當使用 using 來提升函式名稱的可視範圍的時候, 它會將所有多載的版本都提升, 我們也不能指定單獨提升哪一個多載版本.

17.3 多繼承

多繼承指的是一個衍生類別從多個基礎類別中繼承. 對於衍生類別能夠繼承的基礎類別個數, C++ 並沒有作特殊的規定. 但是, 在衍生類別的繼承列表中, 同一個基礎類別只能出現一次. 在衍生類別中, 基礎類別的初始化順序與衍生列表中基礎類別出現順序保持一致, 與衍生類別建構子中初始化列表中的基礎類別順序無關. 對於解構子來說, 其解構順序與建構順序相反, 即首先解構衍生類別部分, 然後根據衍生列表的相反順序對基礎類別部分進行解構.

儘管 C++ 11 允許衍生類別從一個或者幾個基礎類別中繼承建構子, 但如果多個基礎類別中的建構子具有相同的參數列表 (沒有參數的建構子除外), 將會產生編碼錯誤 :

class A {
public:
    A() = default;
    explicit A(int) {}
};
class B {
public:
    B() = default;
    explicit B(int) {}
};
class C : public A, public B {
public:
    using A::A;
    using B::B;
};
int main(int argc, char *argv[]) {
    C c;        // OK
    C c(0);     // Error, ambiguous between A(int) and B(int)
}

這個時候, 如果我們要避免 C c(0); 這個宣告的編碼錯誤, 我們可以在類別 C 中主動實作 C(int) 這樣的建構子.

對於多個基礎類別衍生而來的衍生類別來說, 任何一個基礎類別型別的參考或者指標都可以繫結到衍生類別物件上. 因為編碼器會將一個衍生類別向任何一個基礎類別上的轉型都視為一樣好, 那麼在函式多載的過程中, 我們要避免因此而產生的歧義.

在多繼承下, 對於名稱的查找將在所有基礎類別中同時進行. 但即使對於一個衍生類別來說, 從多個基礎類別中繼承了相同名稱的成員也是被允許的. 但是在存取的時候, 必須在其之前加上類別名稱和可視範圍運算子. 例如 CAB 中繼承了 A::aB::a, 這樣不會產生編碼錯誤, 但是如果通過 C 的物件 object 來存取成員 a 的時候, 我們必須通過 object.A::a 或者 object.B::a 來明確存取. 比這個情況更加複雜的是, 衍生類別通過私用繼承和公用繼承來讓其中一個同名名稱對外部不可見, 但是衍生類別的物件在使用同名名稱的時候仍然會發生編碼錯誤. 另外, 即時有一個同名成員在某個基礎類別中是 private 級別的私用成員, 任何繼承方式都不會被繼承, 但是一旦另外一個基礎類別中出現和這個私用成員同名的名稱, 那麼通過衍生類別使用這個名稱的時候也會產生編碼錯誤 :

class A {
private:
    void g();
public:
    void f(int);
};
class B {
public:
    void f(double);
    void g();
};
class C : public A, protected B {};
class D : public A, protected B {};
int main(int argc, char *argv[]) {
    C {}.f(0);      // Error
    D {}.g();       // Error
}

17.4 虛擬繼承

在繼承的過程中, 兩個繼承體系中的中間類別都繼承自同一個基礎類別, 然後一個衍生類別從這個中間類別再繼承, 這種情況時常會發生. 一般來說, 這並不存在什麼太大的問題. 一些統一且唯一的工作由基礎類別進行管理, 然後讓中間類別負責稍微高級一些的功能, 最後的衍生類別負責合併高級功能. 但是這種繼承體系可能存在一些瑕疵. 因為在這種繼承體系中, 最低層的基礎類別被中間類別繼承了兩次, 相當於最後的衍生類別中有兩個最低層的基礎類別. 這可能會造成最後的衍生類別的功能紊亂. 以標準樣板程式庫中來自標頭檔 <iostream>std::basic_iostream 作為範例 : std::basic_iostream 繼承了來自標頭檔 <istream>std::basic_istream 和來自標頭檔 <ostream>std::basic_ostream. 而 std::basic_istreamstd::basic_ostream 這兩個類別是獨立的, 但是都繼承了來自標頭檔 <ios>std::basic_ios. 粗看其實沒什麼問題, 但是實際上如果真的這樣做的話, std::basic_iostream 中就會存在兩份 std::basic_ios, 一份來自 std::basic_istream, 另一份來自 std::basic_ostream. 本來想法非常好, std::basic_istream 負責輸入, std::basic_ostream 負責輸出, 然後 std::basic_ios 負責管理緩衝區, 最後 std::basic_iostream 既負責輸入也負責輸出. 但是如果 std::basic_iostream 中存在兩個緩衝區, 那麼對於資料流的管理會帶來額外的很多麻煩. 為了避免這樣的麻煩, 在 C++ 中可以通過虛擬繼承的機制解決上述的問題. 虛擬繼承的目的是令某個類別作出宣告, 承諾願意共享它的基礎類別. 其中, 共享的基礎類別被稱為虛擬基礎類別. 在這樣的機制下, 不管虛擬基礎類別在繼承體系中出現多少次, 在最終的衍生類別中將會永遠只包含一個.

虛擬繼承的方式是在衍生列表的基礎類別之前增加 virtual 標識, 它與成員存取權限標識符的位置可以相互調換, 即 class B : private virtual A {};class B : virtual private A {}; 是等價的.

struct A {
    int a;
};

struct B : A {};
struct C : A {};

struct D : virtual A {};
struct E : virtual A {};

struct F : B, C {};

struct G : D, E {};
int main(int argc, char *argv[]) {
    F o1;
    G o2;
    o1.a = 42;      // Error, there are two member a in class F
    o1.B::a = 42;       // OK
    o1.C::a = 42;       // OK
    o2.a = 42;      // OK, equal to o2.D::a = 42 or o2.E::a = 42
}

在虛擬繼承體系中, 如果中間類別宣告了和基礎類別重名的名稱, 那麼最終的衍生類別在使用這個名稱的時候, 會採用中間類別宣告的這個名稱, 因為中間類別宣告的這個名稱已經覆蓋了基礎類別中的名稱. 但是如果每一個中間類別都宣告了一個同名的名稱, 那麼衍生類別物件在使用的時候就會產生編碼錯誤, 因為編碼器根本不知道使用哪一個中間類比的名稱. 這個時候, 我們最好在最終的衍生類別中再宣告這樣一個同名名稱, 以此覆蓋掉所有基礎類別中的同名名稱.

在虛擬繼承中, 虛擬基礎類別是由最後衍生的類別進行初始化的. 否則, 在初始化的過程中, 虛擬基礎類別將多次被初始化. 例如 Code 104 中的類別 G, 其虛擬基礎類別 A 的初始化就由 G 負責, 而不是 DE 負責. 在實際的繼承中, 任何一個類別都可能成為 "最後一個類別". 所以, 一旦我們從一個基礎類別中虛擬衍生出一個新的衍生類別, 我們就應該讓這個類別對其虛擬基礎類別進行初始化, 並修改原最後一個類別的建構. 含有虛擬基礎類別的物件的建構順序與一般順序稍有不同 : 首先, 使用最後衍生的類別建構子提供的虛擬基礎類別建構部分對虛擬基礎類別部分進行建構, 然後再按照衍生列表中出現的順序依次進行初始化. 如果最後衍生的類別並未對虛擬基礎類別進行明確初始化, 那麼將使用虛擬基礎類別的預設建構子. 若虛擬基礎類別沒有提供預設建構子, 那麼將會產生編碼錯誤. 當一個類別擁有多個虛擬基礎類別的時候, 那麼其初始化順序將按照在衍生列表中出現的順序從左到右從上到下進行建構. 合成的複製建構子, 移動建構子, 複製指派運算子和移動指派運算子也是按照相同的順序執行, 解構操作將以相反的順序對類別物件進行解構.

在虛擬衍生類別中, 建構子不可以是 constexpr 的.

18. 特殊工具與技術

18.1 記憶體分配控制

當我們使用 new 表達式 (new Tnew T[]) 的時候, 實際上程式幫我們完成了三步 :

  1. 首先使用標準樣板程式庫標頭檔中 <new> 中的全域函式 ::operator new 或者 ::operator new[] 進行記憶體配置, 此時還沒開始建構;
  2. 使用對應的建構子對每一個物件進行建構;
  3. 建構完成之後回傳對應物件的指標, 對於陣列回傳指向陣列頭部的指標.

當我們使用 delete 表達式 (deletedelete[]) 的時候, 實際上程式幫我們完成了兩步 :

  1. 對指標對應的記憶體中的所有物件進行解構;
  2. 使用標準樣板程式庫標頭檔中 <new> 中的全域函式 ::operator delete 或者 ::operator delete[] 對指標指向的記憶體進行回收.

當對記憶體的配置有特殊需求的時候, 通常需要對 new 運算子和 delete 運算子進行多載. 但是 newdelete 運算子的多載和其它運算子的多載有些不同. 我們並不是直接多載 new 或者 delete, 而是多載 ::operator new 系列函式和 ::operator delete 系列函式. 當我們對全域的 ::operator new::operator delete 進行多載之後, 這些多載的函式就負擔起了控制記憶體配置的作用, 因此我們必須要完全保證這些多載函式一定正確. 我們既可以將 ::operator new::operator delete 多載為全域函式或者限制在某個名稱空間下, 也可以將其放置於成員函式中. 當編碼器發現一條 new 表達式或者 delete 表達式後, 首先會在可視範圍之內查找自訂的版本, 如果找不到才會到外層的可視範圍內查找, 直到全域下的自訂版本. 此時, 如果還是找不到自訂版本的情況下, 才會呼叫標準程式庫的版本. 也就是說, 即使我們多載的 operator newoperator delete 參數列表和標準樣板程式庫提供的衝突, 編碼器也會優先選用我們的, 而不是擲出編碼錯誤. 當然, 我們也可以使用可視範圍運算子直接呼叫對應可視範圍中的多載函式.

標準樣板程式庫 <new> 中定義了不少 ::operator new::operator delete 的多載函式, 其中比較常用的有 :

  • void *::operator new(std::size_t count);
  • void *::operator new[](std::size_t count);
  • void *operator new(std::size_t count, const std::nothrow_t &tag);
  • void *operator new[](std::size_t count, const std::nothrow_t &tag);
  • void *operator new(std::size_t count, void *ptr);
  • void *operator new[](std::size_t count, void *ptr);
  • void operator delete(void *ptr) noexcept;
  • void operator delete[](void *ptr) noexcept;
  • void operator delete(void *ptr, const std::nothrow_t &tag) noexcept;
  • void operator delete[](void *ptr, const std::nothrow_t &tag) noexcept;
  • void operator delete(void *ptr, void *place) noexcept;
  • void operator delete[](void *ptr, void *place) noexcept;

std::nothrow_t 是被定義在標頭檔 <new> 中的一個類別, 這個類別沒有任何成員. 除此之外, 標頭檔 <new> 中還宣告了一個 std::nothrow 的物件, 其型別就是 std::nothrow_t, 用於呼叫 new 表達式或者 delete 表達式不會擲出例外情況的版本. 不接受 std::nothrow 物件的 ::operator new 可能會擲出例外情況 std::bad_alloc, 它也來自標頭檔 <new>. 多載的任何 operator delete 都會自動被 noexcept 標識. 當將 operator newoperator delete 定義為類別成員函式的時候, 它們是隱含的 static 函式, 因為 operator new 將用於物件建構之前, 而 operator delete 用於物件解構之後.

對於 operator new 的多載來說, 它的回傳型別必須為 void *, 第一個參數型別必須為 size_t, 而且不可以有預設引數. 當使用 operator new 的時候, 需要把物件對應型別所需的具體大小傳給第一個參數 (一般是 sizeof(T)); 當使用 operator new[] 的時候, 需要把陣列大小傳給第一個參數. 對於 operator new 的自訂, 可以為其提供額外的參數, 但是 void *::operator new(std::size_t, void *)void *::operator new[](std::size_t, void *) 版本是標準程式庫所專有, 不可以被自訂.

對於 operator delete 的多載來說, 第一個參數必須為 void * 型別. 當將 operator delete 定義為類別成員的時候, 如果函式包含另外一個型別為 std::size_t 的參數, 那麼該參數是為了指定指標指向物件的大小. 並且這個多載的 operator delete 可以用於回收繼承體系中的物件. 若基礎類別中有一個虛擬解構子, 那麼傳遞給 operator delete 的大小將由實際指標所指向的物件的對應型別所確定.

我們自己提供的 operator newoperator delete 的目的是在於改變記憶體的配置方式, 但是我們不能改變 newdelete 運算子是用於記憶體配置的基本含義.

C++ 標準樣板程式庫的標頭檔 <cstdlib> 中有兩個從 C 中繼承的記憶體配置函式 std::mallocstd::free.

operator newoperator delete 只是用於配置記憶體, 並沒有對指標所指向的記憶體進行任何物件建構. 在 C++ 中, new 還有另外一種形式, 稱為放置 new 表達式, 它是用於建構的 : new (pointer) type(argument-list)new (pointer) type[array-size] {initializer-list}. type(argument-list) 就是呼叫了型別 type 的建構子, 當然, 引數列表可以省略, 如果寫成 new (pointer) type, 那麼就是呼叫 type 的預設建構子. 另外, 陣列建構的初始化列表也可以省略, 接下來也將會對陣列中的物件進行預設建構. 放置 new 回傳一個 type * 型別的指標. 如果不是通過放置 new 表達式回傳的指標來使用放置 new 建構的物件將會導致未定行為.

對於一個類別物件來說, 如果想要清除給定記憶體中的物件, 但是並不想讓記憶體被回收的話, 可以通過直接呼叫類別的解構子的方式 : T {}.~T(). 但是內建指標不支援這樣的操作, int {}.~int() 這樣的操作會導致編碼錯誤. 如果在函式具現化中, 遇到了對內建指標使用這樣的表達式, 編碼器會特殊處理, 不會擲出編碼錯誤, 而是會忽略這個表達式. 這樣, 我們就可以寫出一個記憶體建構和解構的範例 :

template <typename T, typename ...Args>
T *my_new(Args &&...args) {
    auto memory = static_cast<T *>(::operator new(sizeof(T)));
    auto result = new (memory) T(std::forward<Args>(args)...);
    return result;
}
template <typename T>
void my_delete(T *pointer) noexcept {
    pointer->~T();
    ::operator delete(pointer);
}

18.2 運行時型別識別

typeiddynamic_cast 這兩個運算子共同組成了 C++ 的運作期型態識別 (RTTI). 一般來說, 我們想使用基礎類別物件的指標或者參考去呼叫某個衍生類別才擁有的函式或者使用衍生類別才擁有的操作的時候, 在函式並非虛擬函式的情況之下, 我們才使用 RTTI 運算子. 但是一般來說, 在可以使用虛擬函式的情況之下, 我們應該儘量使用虛擬函式. 相比於虛擬函式來說, RTTI 運算子的使用存在更多的風險, 使用者必須清楚地知曉轉型的目標型別並且必須對是否轉型成功進行檢查. 所以在可能的情況之下, 應該儘量使用虛擬函式, 而不是直接接管型別管理的任務.

18.2.1 typeid

樣板參數, autodecltype 的推導都是編碼期的. 但是在動態繫結中, 編碼期給出的型別推導可能是不正確的. 這個時候我們就需要在運作時對型別進行識別. C++ 中有一個 typeid 運算子來回傳一個表達式的型別 : typeid(type) 或者 typeid(expression). 在 typeid 運算中, 頂層 const 將被忽略. 對於陣列或者函式進行 typeid 運算的時候, 陣列或者函式並不會被轉型為對應的指標型別. 一般來說, typeid 也在編碼器進行計算, 但是如果遇到一個物件對應的型別中存在虛擬函式, 那麼 typeid 的計算會被延遲到運作期. typeid 回傳的並不是型別, 而是一個定義在標頭檔 <typeinfo> 中類別 std::type_info 的物件. std::type_info 支援相等和不相等比較操作, 還有一個回傳型別為 const char * 的成員函式 name 回傳型別的名稱. 但是 std::type_info 是實作定義的, 也就是每一個編碼器實作的可能都有些不同, 甚至有些結果是取決於作業系統的. 比如對於程式碼 :

#include <iostream>

struct base {
    virtual ~base() = default;
};
struct derived : base {};
int main(int argc, char *argv[]) {
    base *p = new derived;
    std::cout << typeid(*p).name() << std::endl;
}

在 macOS 12 和 Apple Clang 14.0.0 以及 GCC 11.2 下的輸出為 7derived, 在 Windows 10 和 MSVC v19.28 下的輸出為 struct derived.

對於運作時判斷兩個指標是否指向衍生類別 (要求動態繫結的指標不是空的, 否則會擲出 std::bad_typeid 例外情況, 它被定義在標頭檔 <typeinfo> 中), 我們就可以用 typeid 運算子進行判斷 :

#include <stdexcept>

struct base {
    virtual ~base() = default;
};
struct derived : base {};
int main(int argc, char *argv[]) {
    base *p = new derived;
    base *p2 = new base;
    if(typeid(*p) == typeid(*p2)) {
        throw std::runtime_error("different pointer");
    }
}

std::type_info 還有一個成員函式 before 用於判斷一個型別是否為另外一個型別的基礎類別 : typeid(T).before(typeid(U)). 如果 TU 的基礎類別, 那麼就會回傳 true; 否則, 會回傳 false. std::type_info 類別沒有預設的建構子, 而且複製建構子、移動建構子、複製指派運算子和移動指派運算子都被宣告為被刪除的函式, 而創建 std::type_info 物件的唯一方法就是使用 typeid 運算子.

18.2.2 dynamic_cast

我們之前提到過 dynamic_cast 轉型運算子, 它僅僅針對指標和參考 (包含右值參考) 生效. 一般來說, 轉型的類別體系中應該存在虛擬函式. 如果要對一個指標使用 dynamic_cast 運算子, 那麼這個指標必須是一個有效的指標. dynamic_cast 對要轉型的物件只要滿足

  • 物件的型別是轉型目標型別的 public 衍生類別;
  • 物件的型別是轉型目標型別的基礎類別;
  • 物件型別和轉型目標型別相同,

三個條件中的任意一個, dynamic_cast 就可以成功進行轉型.

dynamic_cast 轉型的目標為指標型別, 轉型失敗回傳的結果是 nullptr; 若轉型的目標為參考型別, 轉型失敗將導致擲出 std::bad_cast 例外情況, 它被定義在標頭檔 <typeinfo> 中. 我們應該將向參考型別的 dynamic_cast 轉型放在 try 區塊之內. 和 typeid 運算子不同, 對於一個空指標執行 dynamic_cast 轉型是被允許的, 不過結果還是空指標. 在可能的情況下, 我們應儘量將指標的 dynamic_cast 放在條件陳述式內, 轉型和檢查一併進行.

struct base {
    virtual ~base() = default;
    virtual void vf();
};
struct derived : base {
    void vf() override;
    void f();
};
int main(int argc, char *argv[]) {
    base *p = new derived;
    p->vf();        // call derived::vf
    dynamic_cast<derived *>(p)->f();        // member function f is not exist in base
}

18.3 列舉 enum

列舉 enum 允許讓我們將一組整型常數表達式使用一些名稱組織在一起, 它通常被用於提高程式碼的可讀性. 列舉中的成員也是屬於字面值常數表達式. 不限定可視範圍的列舉由 enum 直接進行宣告. C++ 11 引入了限定可視範圍的列舉, 必須使用 enum class 或者 enum struct 進行宣告. 我們可以為列舉成員提供指定的值, 如果沒有提供指定的值, 那麼其值預設是前面的值加一. 如果第一個值沒有被指派指定的值, 那麼其預設值為 0. 對於限定可視範圍的列舉來說, 列舉成員的名稱遵循可視範圍的一般準則. 對於不限定可視範圍的列舉來說, 列舉成員的可視範圍和列舉所在的可視範圍相同 :

enum E {
    A, B = 42, C, D
};
enum class EC {
    A, B, C, D
};
int a = C;      // value = 43, C refers to E::C, not EC::C
int b = E::A;       // b = 0
int c = EC::D;      // Error

不限定可視範圍列舉中的所有成員型別都是列舉名稱, 例如 Code 109 中的 E::A 型別就是 E. 只要列舉是具名的, 那麼這個名稱就可以作為一個型別. 想要初始化列舉物件或者為列舉物件進行指派, 必須使用該列舉型別裡面的列舉成員進行初始化, 不可以使用數值. 不限定可是範圍列舉中的所有成員都可以隱含地向數值型別轉型, 包括整型型別和浮點數型別. 但是值為 0 的列舉成員不能用來初始化指標. 限定可視範圍的列舉成員不能向數值型別發生隱含型別轉化, 但是我們可以通過 static_cast 來轉型.

列舉成員本身就是一個整型的場數表達式, 所以任何需要用到常數表達式的地方都可以嘗試使用列舉成員. 在 switch 內的 case 條件中, 也可以使用列舉成員; 將列舉型別作為樣板參數, 然後將列舉成員作為樣板引數傳遞給對應參數也是允許的.

儘管每一個 enum 都定義了一種新型別, 但是實際上列舉中的成員是用某種整型型別來表示的. 在不指定 enum 的整型型別的情況下, 限定可視範圍的列舉中成員的整型型別預設為 int 型別. 不限定作用範圍的 enum 成員的預設型別取決於成員中的最大值. 一旦某個列舉成員的值超過其預設型別可容納的範圍, 就會產生編碼錯誤. C++ 11 允許我們自訂列舉成員的型別 :

enum class E1 {
    e = 0xFFFFFFFFFFFF      // Error, out of range
};
enum class E2 : unsigned long long {
    e = 0xFFFFFFFFFFFF      // OK
};
enum E3 {
    e = 0xFFFFFFFFFFFF      // OK, underlyiny type is unsigned long
};
enum E4 : char {
    A = 'a', B, C, D
};

C++ 11 允許我們對列舉型別提前進行宣告, 就像類別那樣. 從宣告開始到被實作為止, 列舉型別都屬於不完全型別. 對於限定可視範圍的列舉, 在宣告的時候可以不指出其整型型別, 預設為 int. 但是對於不限定可視範圍的列舉, 宣告時必須明確寫出其整型型別. 在列舉被實作的時候, 如果整型型別和宣告時的不匹配, 將會產生編碼錯誤.

由於列舉型別是獨立型別, 因此列舉成員被用在函式匹配的時候, 即便其可以向整型型別發生隱含型別轉化, 但是仍然會選擇精准匹配的那一個函式 :

enum E {
    A, B, C
};
void f(E);
void f(int);
int main(int argc, char *argv[]) {
    f(A);       // call void f(E)
}

但是在函式匹配中, 如果需要發生隱含型別轉化的話, 真正匹配的函式需要由列舉的潛在整型型別所決定 :

enum E1 {
    e1 = 254, e2
};
enum E2 : char {
    ch
};
enum E3 : long long {
    ll
};

void f(int);
void f(unsigned char);
void f(unsigned long long);

int main(int argc, char *argv[]) {
    unsigned char c {e1};       // OK
    f(e1);      // call void f(int)
    f(ch);      // call void f(int)
    f(c);       // call void f(unsigned char)
    func(ll);       // Error
}

18.4 類別成員指標

類別成員指標是指向類別中的非靜態成員的指標, 而不是指向類別的物件, 指向類別中靜態成員的指標與普通的指標並沒有什麼區別. 類別成員指標在初始化的時候, 我們無需指定成員所屬的物件, 只有當使用成員指標的, 才提供成員所屬的物件. 類別成員指標的基本形式是 T class-name::*. 其中, T 是指標的基本型別, class-name 是類別的名稱. 如果要指向類別成員變數, 我們可以寫成 T class-name::*pointer-name = &class-name::member-variable;. 其中, pointer-name 是指標名稱, member-variable 是成員變數的名稱. 如果要指向類別成員函式, 我們可以寫成 T (class-name::*pointer-name)(parameter-list) specifier-list = &class-name::member-function. 其中, parameter-list 是成員函式的參數列表, member-function 是類別成員函式的名稱, specifier-list 是標識列表, 包含了, 這裡 & 不可以像普通函式指標那樣省略. 在 C++ 11 中, 為了簡化, 我們一般可以使用 auto 或者 decltype 來宣告 :

struct S {
    int a;
    void f();
};
int main(int argc, char *argv[]) {
    int S::*pa {&S::a};
    void (S::*pf)() {&S::f};
    auto auto_pa {&S::a};
    decltype(&S::f) decltype_pf {&S::f};
}

當我們初始化一個類別成員指標之後, 這個指標並沒有指向任何物件或者資料, 不論這個指標看起來是指向了類別中的某個成員還是指向了 nullptr. 類別成員指標在解參考的時候需要提供一個類別物件, 此時這個類別成員指標就會指向這個物件中對應的記憶體. 為了存取指標指向的類別成員, 我們使用成員指標存取運算子 .* 或者 ->* :

struct S {
    int a;
    void f() {}
};
int main(int argc, char *argv[]) {
    int S::*pa {&S::a};
    void (S::*pf)() {&S::f};
    S obj;
    (obj.*pf)();       // same as obj.f();
    S *ptr {&obj};
    (ptr->*pf)();        // same as ptr->f();
    obj.*pa = 42;        // same as obj.a = 42;
    ptr->*pa = 0;      // same as ptr->a = 0;
}

我們要注意, 由於運算子優先級問題, 在使用指向成員函式的類別成員指標的時候, 函式呼叫運算子會優先於成員指標存取運算子, 因此不給成員指標存取運算子加上括號提升優先級的話, 就會產生編碼錯誤.

一般來說, 類別的私用成員是不對外公開的, 在類別建構完成之後如果我們想要存取私用成員或者修改私用成員的值, 我們會額外實作一些函式, 委託給這些函式來完成. 對於類別成員指標也是類似, 如果想要通過指標存取的話, 可以讓一個成員函式回傳這個指標 :

class S {
private:
    int a;
public:
    static decltype(&S::a) get() noexcept {
        return &S::a;
    }
};
int main(int argc, char *argv[]) {
    auto ptr {S::get()};
    S s;
    s.*ptr = 42;        // OK
}

使用類別成員指標可以獲得一個新的類別設計模式. 假設我們現在要設計某個遊戲中的目標移動操作, 目標可以向上, 向下, 向左和向右移動 :

namespace game {
    class person {
    private:
        static void (person::*move_function[])();
        // some member variable...
    private:
        void move_up();
        void move_down();
        void move_left();
        void move_right();
    public:
        enum move {
            up, down, left, right
        };
    public:
        void operate(move action) {
            (this->*move_function[action])();
        }
    };
    void (person::*person::move_function[])() {&person::move_up, &person::move_down, &person::move_left, &person::move_right};
}
int main(int argc, char *argv[]) {
    game::person p;
    p.operate(p.up);        // OK
}

我們在介紹泛型演算法和標頭檔 <algorithm> 的時候說過, 部分標準樣板程式庫中的泛型演算法接受一個可呼叫物件以自訂部分操作. 普通的函式指標當然可以作為可呼叫物件, 但是類別成員指標不可以, 因為類別成員指標必須繫結到一個確實存在的物件上才能發揮作用. 例如, 相比起 std::find, std::find_if 可以自訂具有哪些特徵的值才符合搜尋要求. 假如現在我們需要使用 std::find_if 來查找一個 std::vector<std::string> 中是否存在空的字串, 那麼本來可以借助 std::string 的成員函式 empty, 但是實際上它不能作為可呼叫物件. 為了解決這個問題, 我們可以借助 std::function. 這個時候, std::string 的成員函式 empty 的型別必須明確為 bool (const std::string &) 才可以放入 std::function 中 :

#include <vector>
#include <functional>
#include <string>
#include <algorithm>

int main(int argc, char *argv[]) {
    std::vector<std::string> v {"aaa", "bbb", "ccc", "ddd", {}, "123"};
    std::function<bool (const std::string &)> functor {&std::string::empty};
    auto empty_string = std::find_if(v.cbegin(), v.cend(), functor);
    auto distance = empty_string - v.cbegin();        // offset = 4
}

使用 std::function 將指向成員函式的類別成員指標轉換為可呼叫物件的時候, 樣板引數列表中的函式型別必須在第一個參數中增加類別型別. 另外, 我們還應該明確是否帶有 const 標識和參考. 如果 Code 117 中的 std::function<bool (const std::string &)> 改為 std::function<bool (std::string)>, 這樣並不會產生編碼錯誤, 但是會導致搜尋的每一個 std::string 物件都被複製一邊.

除了使用 std::function 之外, 還有一種方法. 標準樣板程式庫標頭檔 <functional> 中還提供了函式 std::mem_fn, 它也可以將指向成員函式的類別成員指標轉換為可呼叫物件 :

#include <vector>
#include <functional>
#include <string>
#include <algorithm>

int main(int argc, char *argv[]) {
    std::vector<std::string> v {"aaa", "bbb", "ccc", "ddd", {}, "123"};
    auto empty_string = std::find_if(v.cbegin(), v.cend(), std::mem_fn(&std::string::empty));
    auto offset = empty_string - v.cbegin();        // offset = 4
}

std::bind 也可以做到類似的事情 :

#include <vector>
#include <functional>
#include <string>
#include <algorithm>

using std::placeholders::_1;
int main(int argc, char *argv[]) {
    std::vector<std::string> v {"aaa", "bbb", "ccc", "ddd", {}, "123"};
    auto empty_string = std::find_if(v.cbegin(), v.cend(), std::bind(&std::string::empty, _1));
    auto offset = empty_string - v.cbegin();        // offset = 4
}

std::placeholders::_1 會被 std::stringthis 隱含地繫結.

18.5 巢狀類別

巢狀類別就是在類別中宣告另外一個類別, 這個類別和外層類別相互獨立, 沒有什麼特權. 外部類別無法存取到巢狀類別的私用成員, 巢狀類別也不支援存取到外部類別的私用成員.

巢狀類別的提前宣告必須在外層類別內部, 實作可以放置於外層類別之外, 只要在巢狀類別名稱之前加上外層類別名稱以及可視範圍運算子即可 :

struct A;
struct A::B;        // Error, A is a incomplete type

struct A {
    struct B;       // OK
    struct B {
        void f();
    };
};
void A::B::f() {}

18.6 局域類別

實作在函式內部的類別被稱為局域類別, 局域類別的所有成員都必須完整地實作在類別內部, 不支援內部宣告外部實作. 局域類別不允許擁有靜態成員變數.

局域類別對外層可視範圍中的名稱的存取是受到限制的, 它只能存取到外層可視範圍中宣告的型別別名, 靜態變數, 列舉成員以及具有 constexpr 標識的變數. 也就是說, 局域變數不能被局域類別所存取到.

局域類別同樣支援有巢狀類別, 甚至巢狀類別可以僅僅在局域類別中先進性宣告, 然後在局域類別之外進行實作, 但是必須與局域類別在相同的可視範圍之內 :

int main(int argc, char *argv[]) {
    struct outer {
        struct inner;
    };
    struct outer::inner {
        void f();
    };
}

局域類別的巢狀類別也是局域類別, 同樣要遵守局域類別所需要遵守的規則.

在實際的程式設計中, 由於局域類別的限制, 一般局域類別的複雜性都不太高.

18.7 等位 union

等位 union 是一種特殊的類別. 一個 union 可以擁有多個成員變數, 但是它們不能是參考型別. 在 C++ 11 新標準中, 擁有建構子和解構子的類別也可以成為等位成員變數的型別. 和普通的類別一樣, 等位也適用 private, protectedpublic 來指定成員的存取權限. 預設情況下, 等位成員的存取權限和 struct 一樣, 是 public 的. 等位和普通的類別相比, 特殊的地方就在於等位型別的 sizeof 值. 等位型別的 sizeof 值由成員變數中 sizeof 值最大的那一個決定 :

struct S {
    int a[2];
    long b;
    long long c;
    void *p;
};      // sizeof(S) is 32
union U {
    int a[2];
    long b;
    long long c;
    void *p;
};      // sizeof(U) is 8

也就是說, 普通類別中每一個成員變數都擁有屬於自己的空間, 但是等位中每一個成員變數共享一片空間. 也因為這樣的特殊性, 因此等位不支援繼承, 也不支援其中存在虛擬函式.

當想要方便地表示一組不同型別相互排斥的值的時候, 就可以使用等位, 就如同 Code 122 中的 U 那樣. 預設情況下, 等位的值是未初始化的, 當使用 union 宣告新物件的時候, 也可以使用初始化列表進行明確初始化 :

#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {
    union U {
        int a;
        char b;
        void *p;
    };
    U u {'a'};
    cout << u.a << endl;        // 輸出結果 : 97
    cout << u.b << endl;        // 輸出結果 : a
    cout << u.p << endl;        // 輸出結果 : 0x61
}

等位和類別一樣也可以不具名. 不具名等位不可以包含私用成員, 也不能為其宣告任何的成員函式. 當一個不具名的等位被宣告在一個函式當中, 成為局域類別的時候, 編碼器會自動幫我們創建這個不具名等位的一個物件. 等位內的所有成員就像不具名列舉那樣, 可以在函式中直接存取 :

#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {
    union {
        int a;
        char b;
        long c;
    };
    a = 88;
    cout << a << endl;      // 輸出結果 : 88
    cout << b << endl;      // 輸出結果 : X
    cout << c << endl;      // 輸出結果 : 88
}

當等位內部的成員都是內建型別的時候, 編碼器會按照所有成員合成預設建構子和複製建構子. 但是如果等位內部含有類別型別的成員, 並且該成員對應的類別自訂了預設建構子或者複製建構子, 那麼等位對應的合成版本建構子將被編碼器宣告為被刪除的函式. 對於那些預設建構子和複製建構子標識為被刪除的函式的等位, 要使用它們非常困難 :

#include <string>

union U {
    std::string str;
    int a;
};
int main(int argc, char *argv[]) {
    U u {"123"};        // Error
    U u2 {1};       // Error
    U u3;       // Error
}

因為我們沒有辦法判斷當前等位使用的是 a 還是 str. 如果當前使用的是 str, 接下來要切換到使用 a 的話, 必須解構 str, 否則會導致未定行為. 為了解決這個問題, 我們通常使用類別對這樣的等位進行包裝 :

#include <string>
#include <utility>

class union_u {
private:
    enum class current_using {
        using_int, using_std_string, using_void_pointer
    } token;
    union {
        int a;
        std::string b;
        void *c;
    };
public:
    union_u() : token {current_using::using_int}, a {} {}
    union_u(const union_u &rhs) : token {rhs.token}, a {} {
        switch(rhs.token) {
            case current_using::using_int:
                this->a = rhs.a;
                break;
            case current_using::using_std_string:
                new (&this->b) std::string(rhs.b);
                break;
            case current_using::using_void_pointer:
                this->c = rhs.c;
                break;
        }
    }
    union_u(union_u &&rhs) noexcept : token {rhs.token}, a {} {
        switch(rhs.token) {
            case current_using::using_int:
                this->a = rhs.a;
                break;
            case current_using::using_std_string:
                new (&this->b) std::string(std::move(rhs.b));
                break;
            case current_using::using_void_pointer:
                this->c = rhs.c;
                break;
        }
    }
    ~union_u() {
        if(this->token == current_using::using_std_string) {
            using std::string;
            b.~string();
        }
    }
    union_u &operator=(const union_u &rhs) {
        if(&rhs not_eq this) {
            if(this->token == current_using::using_std_string) {
                using std::string;
                switch(rhs.token) {
                    case current_using::using_int:
                        this->b.~string();
                        this->a = rhs.a;
                        break;
                    case current_using::using_std_string:
                        this->b = rhs.b;
                        break;
                    case current_using::using_void_pointer:
                        this->b.~string();
                        this->c = rhs.c;
                        break;
                }
            }else {
                switch(rhs.token) {
                    case current_using::using_int:
                        this->a = rhs.a;
                        break;
                    case current_using::using_std_string:
                        new (&this->b) std::string(rhs.b);
                        break;
                    case current_using::using_void_pointer:
                        this->c = rhs.c;
                        break;
                }
            }
        }
        return *this;
    }
    union_u &operator=(union_u &&rhs) noexcept {
        if(&rhs not_eq this) {
            if(this->token == current_using::using_std_string) {
                using std::string;
                switch(rhs.token) {
                    case current_using::using_int:
                        this->b.~string();
                        this->a = rhs.a;
                        break;
                    case current_using::using_std_string:
                        this->b = std::move(rhs.b);
                        break;
                    case current_using::using_void_pointer:
                        this->b.~string();
                        this->c = rhs.c;
                        break;
                }
            }else {
                switch(rhs.token) {
                    case current_using::using_int:
                        this->a = rhs.a;
                        break;
                    case current_using::using_std_string:
                        new (&this->b) std::string(std::move(rhs.b));
                        break;
                    case current_using::using_void_pointer:
                        this->c = rhs.c;
                        break;
                }
            }
        }
        return *this;
    }
    // other functions...
};

18.8 不可攜特性

為了支援與硬體接近的低層程式設計, C++ 中有一些不可攜的特性. 不可攜的特性是指因硬體或者編碼器而異的特性. 當將程式碼從一台硬體遷移到另外一台硬體, 從一個編碼器切換到另外一個編碼器, 甚至從同一編碼器的某個版本切換到另外一個版本的時候, 通常這部分不可攜特性需要因新硬體或者新編碼器特性而重新編寫.

例如

#include <string>

int main(int argc, char *argv[]) {
    if(typeid(int).name() == std::string("int")) {
        // do something...
    }
    // ...
}

這樣的程式碼就是高度不可攜的. 因為這不只是切換作業系統, 甚至切換不同編碼器和切換同一編碼器不同版本都有可能導致這個判斷回傳不同的結果.

18.8.1 位元欄位

在 C++ 標準中並沒有規定一個型別應該有多大, 例如一個 int 型別在某些裝置上的 sizeof 大小可能是 2, 在另外一些裝置上可能是 4 (甚至有可能是 3).

當一個程式向其它程式或者硬體傳送資料的時候, 通常會使用二進制, 此時會用到其中一個不可攜的特性, 即位元欄位. 類別可以將其成員變數宣告為位元欄位, 此時成員變數只能是整型型別或者列舉型別. 位元欄位在記憶體中的分佈是和硬體相關的. 位元欄位的基本宣告形式為 T member-variable : size;. 其中, T 為整型型別或者列舉型別, member-variable 是位元欄位成員變數的名稱, size 是位元欄位的大小. size 的大小是告知編碼器這個位元欄位用了多少位元組, 它不可以超過 T 的最大位元大小 (一般來說, 一個 sizeof 值表示 8 個位元, sizeof 值也被稱為位元組, T 的位元大小為 sizeof(T) * 8). 如果 size 超過 T 的最大位元大小, 超出的部分將被自動切掉. 另外, size 必須是常數表達式. 取位址運算子不可以作用於位元欄位. 任何指標都無法直接指向位元欄位中的成員變數, 任何參考也無法參考至一個位元欄位變數.

struct bit_set {
    unsigned a : 1;     // the maximum of a is 1, the minimum of a is 0
    unsigned b : 4;     // the maximum of b is 15 (1111b), the minimum of b is 0 (0000)
};
int main(int argc, char *argv[]) {
    bit_set b1 {1, 15};
    bit_set b2 {2u, 0};      // b2.a is 0. compile warning, implicit truncation from 'int' to bit-field changes value from 2 to 0
    unsigned &r = b1.a;      // Error
}

18.8.2 volatile

與硬體相關的程式設計中可能會包含幾個變數的具體值由程式之外的過程控制, 例如我們按下某個開關就是通過外部控制某個變數改變. 這種改變在程式碼中是沒有辦法體現出來的. 當變數的值可能被程式之外的控制或者檢測之外被改變的時候, 應該將變數宣告為 volatile 變數. 它告訴編碼器不應該對這樣的變數進行任何優化. volatileconst 的用法相同, 但是和 const 的宣告順序沒有先後要求. 如果某個陣列或者某個類別物件被 volatile 標識, 那麼和 const 類似, 陣列中的所有元素和類別中的所有成員變數都會隱含 volatile 屬性.

一個類別的成員函式也可以被 volatile 所標識. 當一個類別的成員函式被 volatile 所標識的時候, 它只可以被 voaltile 標識的物件所呼叫. 和 const 類似, 當一個物件帶有 volatile 標識的時候, 我們必須使用帶有 volatile 的參考或者指標指向它們. 和 const 不同的是, 編碼器不會為我們合成那些參數帶有 volatile 標識的複製建構子, 移動建構子, 複製指派運算子和移動指派運算子. 如果要用到這些建構子, 我們必須自訂 :

struct S {};
int main(int argc, char *argv[]) {
    volatile S s {};
    S s2 {s};       // Error
}

18.8.3 連結指示 extern ""

C++ 程式有時候需要呼叫來自其它語言的函式, 最常見的就是呼叫來自 C 的函式. 與 C++ 的函式宣告一樣, 來自其它語言的函式也需要進行宣告, 並且明確回傳型別和參數列表. 對於使用其他語言編寫的函式來說, 編碼器的呼叫與處理 C++ 的呼叫相同, 但是生成的程式碼有所不同.

連結指示採用 extern + 加上一個字面值字串來宣告函式. 這個字面值字串指示了函式來源於哪個程式設計語言. 例如 extern "C" void f(void); 表示函式 void f(void) 來自 C. 我們也可以使用複合連結來替代單個連結, 簡化程式碼的編寫 :

extern "C" {
    void f(void);
    void g(void *, ...);
    int printf_d(...);
    void *scan_safe(void *, const void *);
}

對於連結指示的語言, 必須由編碼器支援. 例如, GCC 支援 C++ 來連結 C 和 Fortran 語言, 但是連結到 Python 就不支援. 連結到不支援的語言會產生編碼錯誤.

連結指示支援巢狀. 因此如果標頭檔中自帶了連結指示, 那麼在連結指示中匯入不會產生問題. 連結指示中的可視範圍有一個例外, 就是雖然複合連結指示採用 {} 的形式, 但是它不代表可是範圍 :

extern "C" {
    extern "C" {
        void f(void);
    }
    #include <stdio.h>
}
int main(int argc, char *argv[]) {
    int c;
    scanf("%d", &c);        // OK
    f();        // OK
}

C++ 從 C 中繼承的標準程式庫函式可以將其宣告為 C 函式, 但是並非必須. 決定使用 C 還是 C++ 的方式匯入 C 標準程式庫是每個 C++ 程式碼編寫者個人的事情.

編寫函式所用的語言也是函式型別的一部分, 因此若想要宣告一個指標指向其它語言編寫的函式, 也應該使用連結指示明確. 例如在 Code 131 中, 我們向宣告一個指向函式 f 的指標, 那麼首先使用 using 或者 typedef 宣告一個連結指示的型別別名 extern "C" using fp_type = void (*)(void);, 然後使用 fp_type 宣告一個指標指向 f 即可. 嚴格來說, 省略掉型別別名前面的 extern "C" 宣告是錯誤的, 也就是讓 C++ 函式指標直接指向 C 的函式是不正確的, 但是很多編碼器為了 C 和 C++ 的相容性, 也允許了這種行為.

連結指示不但對函式的宣告有效, 對函式參數列表中的函式也有效. 比如 extern "C" void f(void (*)()) 中, extern "C" 不僅僅對 f 生效, 也對 f 的參數中接受的函式指標也生效. 因為連結指示對宣告中的所有函式都有效, 因此如果我們希望給一個 C++ 函式傳入一個 C 函式的時候, 需要使用型別別名 :

extern "C" using fp = void (*)(void);
void f(fp);

通過連結指示進行函式實作, 可以令一個 C++ 函式在其它語言編寫的程式中也有效 :

extern "C" int f(int) {
    return 42;
}

Code 133 中的函式 f 也支援被 C 程式碼呼叫, 編碼器將會為其聲稱 C 程式設計語言適用的程式碼. 但是被多種語言共享的函式, 其回傳型別和參數列表會受到很多約束. 例如, C 並不太可能接受一個來自 C++ 的型別, 因為有些在 C++ 中獨有的語言特性無法被 C 語言所理解.

有時候, 使用 C 和 C++ 兩個程式設計語言編寫同一個檔案的時候, 需要對 C++ 程式碼作出一些處理, 此時我們可以使用 C++ 巨集標識符 __cplusplus. 這個巨集不被定義在任何標頭檔, 只要是一個 C++ 程式碼, 它天然存在 :

#ifdef __cplusplus
extern "C"
#endif
void f(int, char);

連結指示與多載函式的相互作用依賴於目標語言. 如果目標語言支援函式的多載, 那麼該語言實現連結指示的編碼器也很可能支援多載這些來自於 C++ 的函式. C 的連結指示只支援宣告多載函式的其中一個, 因為 C 並不支援函式的多載. 如果有一組同名的函式, 其中一個來自 C, 那麼其它必定要宣告它們來源於 C++.