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

Tips :

  • 本篇文章創建於 2018 年 2 月 9 日
  • 本篇文章可能需要一定的 C++ 基礎才能閱讀, C++ 新人不建議閱讀
  • 本篇文章可能需要以從下到上的順序閱讀
  • 如若本篇文章有閣下需要尋找的信息, 請直接使用 command (control) + f 搜尋
  • 本文中所有的程式碼於 Apple LLVM version 10.0.0 (clang-1000.11.45.5) 中測試

本來一直想著怎麼去學好 PHP, 怎麼去學好 Swift. 幾個月前, 我是看不起 C++ 的. 但是, 隨著知識面的增長, 我發現與其去了解一些新東西, 倒不如將還沒有學好的老東西學紮實. 我重新拿起了 C++ 這門語言, 想要把它學好, 因為我發現 C++ 其實並不像我想像的這麼難用. 任何一門語言都不應該被人看不起, 它既然現在還是這麼熱門, 有人用, 就說明有的領域用這門語言比用其它語言要好

曾經在 C++ 2D 遊戲實驗時, 我說過一句話 "樣板沒學過" (樣板在中國被稱作模板). 於是我自認為這應該是我說過最可笑的一句話, 沒學過的不去學習, 還要理直氣壯

對面目前來說, 指標與類別的合用已經夠用了, 不過是代碼難寫一點而已

到目前為止, 本篇文章已經全部更新完成

推薦閱讀 : 《Effective C++》讀後感 – 從自己的角度講一講《Effective C++》

2018-02-09 23:54:45 by Jonny, 更新內容 :

1. 概論

輸出流 :

  • cerr : 主要用於警告與錯誤的輸出
  • clog : 主要用於日誌型態的輸出, 即程式運行時的一般性信息

 

endl 操作符 : 結束當前行並且將與裝置關聯的緩衝區的內容刷到裝置中

 

/* */ 注釋不可以是巢狀的, 也就是不支援內嵌

// 注釋中的所有內容都會被忽略, 包括注釋的內嵌

 

前置增量運算元 (++i) : ++i == (i = i + 1) == (i += 1)

後置增量運算元 (i++) : i++ == (i = i + 1)

如若想要 i 增加 1, 那麼

i++、++i、i = i + 1 與 i += 1 都是一樣的效果

但是如果當成表達式, 那麼就有所區別了

x = i++ 相當於 x = i, i = i + 1

x = ++i 和 x = i += 1 相當於 i = i + 1, x = i

 

實例 :

#include <iostream>

using namespace std;

int main(int argc, char *argv[]) {
    int a = b = 0;
    while(cin >> b) {
        a += b;
    }
    cout << a << endl;
}

實例中, 迴圈結束的條件是當 b 遇到文件結束標誌 EOF (End Of File) 或者輸入錯誤

輸入錯誤的情況又分為兩種, 當然這可能視編譯器的情況而定

當輸入非 int 非浮點數的時候, 會直接結束

當輸入浮點數的時候, 會將浮點數轉換為 int 然後執行一次迴圈內的語句

C++ 學習筆記-Jonny'Blog

2018-02-10 00:56:29 by Jonny, 更新內容 :

來自標準庫的標頭檔應該使用 (<>) 包圍, 非標準庫的標頭檔應當使用 ("") 包圍

 

() 的名稱為調用運算元

2. 變數與型態

wchar_t 寬字元

char16_t / char32_t Unicode 字元

 

unsigned int 可以縮寫為 unsigned

 

字元型態應當明確指示是否有號數

char (0 - 255) | signed char (-128 - 127)

 

算術表達式中避免使用 char / bool

浮點數儘量使用 double 而不選用 float

long double 只有在 double 不夠用的時候才採用

當給一個無號數型態超出本身範圍的數, 結果是原始資料對無號數型態表示數字總和總的模後餘數

C++ 學習筆記-Jonny'Blog

2018-02-11 01:41:52 by Jonny, 更新內容 :

給一個號數型態一個超出範圍的數, 結果是未定義 (undefined), 程式可能會出現未知錯誤

 

實例 :

#include <iostream>

using namespace std;
int main(int argc, char *argv[]) {
	unsigned u = 10;
	int i = -42;
	auto s = u + i;
	cout << s << endl;		//Result : 4294967264
}

上述實例中的 u + i 為什麼最後會變成這麼大的結果? 這就是之前說過的給一個無號數型態超出本身範圍的數. 無號數本身是不能接受負數的, 所以才會出現這樣的情況

如果分解開來, 這個表達式可以這麼去算

u + i = (unsigned)i + u

(unsigned)i = pow((double)2, (double)32) - 42        //Result : 4.29497e + 09

當然這個事 double 型態的結果, 如果將它強制轉換成 long 型態的話, 就是如上注釋中的數字了

不過需要留意的是, pow() 函式需要包含標頭檔 <cmath>

所以, 我們的代碼中應該儘量避免 unsigned 型態做減法運算之後出現結果為負數的情況; 還有, 需要避免 unsigned 型態與 signed 型態混用

 

C++ 中, 0 打頭表示八進制

0x / 0X 打頭表示十六進制數

 

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

實例 :

#include <iostream>

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

 

前綴 :

  • u => Unicode 16, char16_t
  • U => Unicode 32, char32_t
  • L => 寬字元, wchar_t
  • u8 => UTF-8, char

後綴 :

  • u / U => unsigned
  • f / F => float
  • ll / LL => long long
  • l / L (非浮點數) => long
  • l / L (浮點數) => long double

 

初始化不是變數賦值, 要區分兩個概念

初始化是宣告變數的時候賦予一個初始數, 賦值是將當前物件的資料擦出並且用新的資料替代. C++ 11 中, 列表初始化得到全面的運用

要向初始化一個變數, 現在我們有 4 種方法 :

  • int a = 0;       //最常用, 但是最容易與賦值相混
  • int a = {0};        //容易與數組的初始化相混
  • int a{0};        //正確的 C++ 11 初始化
  • int a(0);        //容易與函式的調用相混

根據上述的解釋, 最推薦使用的初始化方式為 int a{0}; 這樣的方法

但是, 不同的物件型態假如沒有作轉換, 那麼使用 {} 初始化將會報錯 (這個視具體的編譯器而定, 有一些編譯器可能會幫你自動轉換)

 

默認初始化 : int a

在函式外的任何地方, 變數進行默認初始化都會被賦予 0

函式內有一些內置型態將不會被初始化 :

  1. long
  2. char
  3. wchar_t
  4. char16_t
  5. char32_t
  6. short
  7. int
  8. long
  9. long long
  10. float
  11. double
  12. long double

 

函式內使用 extern 關鍵詞將會報錯

如若一個變數沒有被宣告, 那麼使用 extern 宣告 :

extern int i = 1;

將會無視 extern 的作用

如果變數已經在其它檔案中被宣告, 不管是否已經初始化, 使用 extern 的時候, 如若進行賦值操作, 將會導致錯誤

 

《C++ Primer》中提到 :

用戶自訂的識別符號不能連續出現兩個下劃線, 也不能以下劃線緊連著大寫英文字母

在函式外宣告的變數, 不能以下劃線打頭

此部分目前在 CLion 與 CodeRunner 中進行測試之後, 暫時未發現有錯誤提示. 所以暫時有待證實

 

C++ 替換關鍵字 :

  • and <-> &&
  • bitand <-> &
  • compl <-> ~
  • not_eq <-> !=
  • or_eq <-> |=
  • xor_eq <-> ^=
  • and_eq <-> &=
  • bitor <-> |
  • not <-> !
  • or <-> ||
  • xor <-> ^
  • <% <-> {
  • %> <-> }
  • <: <-> [
  • :> <-> ]
  • %: <-> #
  • %:%: <-> ##
C++ 學習筆記-Jonny'Blog

2018-02-18 17:32:10 by Jonny, 更新內容 :

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

int a = 1;

int &b = a;    //b = 1, 相當於給變數 a 取別名

 

* : 解參考運算元

 

int *p = NULL;

需要包含 <cstdlib> 標頭檔. CLion 在 UNIX 下不需要, 可以直接使用

C++ 11 中, 空指標應該儘量使用 nullptr, 同時避免使用 NULL

 

void * 指標只能用於儲存記憶體位址, 不能通過 * 運算元訪問位址內的具體物件

 

const 物件僅在檔案內有效, 若其它文件需要使用, 可以用 extern 聲明

 

不能使用參考使一個變數指向常數

 

int a = 0;

const int &b = a;

a = 1;    //a = 1, b = 1

b 常數本身不能變化, 但是 b 參考的 a 變數可以變化, 則 a 變化使 b 也發生變化

 

常數的指標參考只能使用常數的指標

const int a = 0;

const int *p = &a;    //正確

int *ptr = &a;    //錯誤

 

const int *p : *p 為常數, p 為變數

int *const p : p 為常數, *p 為變數

const int *const p : p 與 *p 都為常數

頂層 const 表示指標本身是常數

底層 const 表示指標所指向的物件是一個常數

頂層 const 和 底層 const 可以結合上述實例理解

 

常量表達式表示值不會改變並且在編譯過程中就可以得到結果

constexpr 在編譯過程中初始化

如若在編譯過程中就可以得到確定的數據, 那麼就儘量使用 constexpr 修飾

 

物件形態的別名定義除了使用 typedef 之外還可以使用 using

using INT = int

 

若用 auto 在一個語句中宣告多個變數, 那麼只能由一個型態

auto 一般會忽略頂層 const, 保留底層 const

 

decltype : 選擇並回傳操作數據的物件形態

string::size_type a = 0;

const string::size_type &b = a;

decltype(b) x = 10;    //x 為 const string::size_type 型態>

decltype(__x) 若為參考型態, 那麼它必須被初始化. 如果不想初始化, 可以宣告為 decltype(__x + 0)

 

int __x;

decltype(__x) a;    //a 為 int 型態, 可以不進行初始化

decltype((__x)) a {__x};    //a 為 int & 型態, 不初始化會產生錯誤

 

C++ 11 規定可以為資料成員提供一個類別內的初始資料, 初始資料不可以使用圓括弧進行初始化

 

#ifdef : 若且唯若變數已經被宣告時為真

#ifndef : 若且唯若變數沒有被宣告時為真

一旦監測到結果為真, 則執行後續的操作, 直到遇到 #endif 為止

前處理變數會無視 C++ 中關於變數可視範圍的規則

前處理變數必須唯一並且一般都會使用全部大寫的形式

 

3. 字串、向量與陣列

string 初始化 :

string var1(var 2)

string var("")

string var(int, char) : 將 char 拷貝 int 次給 var

 

使用 getline(cin, string &) 函式讀取整行, 包括 space

enter 也會被讀入, 但是不會被儲存

 

string 中多載的 "+" 運算字兩側的運算物件至少要有一個是 string 型態

string var = "a" + "b" 是錯誤的

若要執行

string var = "a" + "b" + string(c), 可以嘗試修改為

string var = "a" + ("b" + string(c))

 

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 為大寫字母, 則輸出對應的小寫字母; 否則, 原式輸出

toupper(c) : 若 c 為小寫字母, 則輸出對應的大寫字母; 否則, 原式輸出

 

C++ 11 : Rang for (範圍架構的 for)

for(declaration : expression) {

    statement

}

expression : 物件 (陣列和類別等)

declaration : 變數, 用於訪問序列中的基礎元素

如若想要改變物件中的資料, 可改為

for(ElementType &c : expression) {

    statement

}

 

vector 初始化 :

vector<T> v(v0)

vector<T> v(n, v0) : 包含 n 個重複的 v0

vector<T> v(n) : 包含 n 個執行了資料初始化的物件

vector<T> v {a, b, c, ...} / v = {a, b, c, ...}, 花括弧不可以使用圓括弧替代

C++ 學習筆記-Jonny'Blog

2018-03-09 14:34:58 by Jonny, 更新內容 :

如果迴圈內部有向 vector 添加元素的操作, 那麼要避免使用 range-for. 在 range-for 中, 向 vector 添加元素或者任何一種可能改變 vector 容量的操作都可能使 vector 的疊代器失效

 

要使用 vector 中的 size_type 型態, 要明確宣告是由哪種型態定義的

vector<T>::size_type        //正確

vector::size_type        //錯誤

 

只有當元素的型態可比較的時候, vector 才可以被比較

如果兩個 vector 類別容量不同, 但是對應位置的元素都相同, 那麼元素較少的 vector 小於元素較多的 vector; 若元素有區別, 那麼 vector 的大小由對應不同元素的大小決定

 

vector 和 string 類別的陣列註標運算子都可以用於訪問已經存在的元素, 但是不能用於添加新的元素

如果用陣列註標運算元去訪問一個不存在的元素, 那麼會引發未知錯誤, 並且這個錯誤在編譯期間很不會被發現. 所以, 確保陣列註標合法的有效手段, 就是使用範圍構架的 for

 

vector 和 string 中都定義了疊代器 iterator

  • ::iterator - 讀寫權限
  • ::const_iterator - 讀權限

vector<T> a;

const vector<T> b;

a.begin() => vector<T>::iterator

b.begin() => vector<T>::const_iterator

a.cbegin() => vector<T>::const_iterator

疊代器之間的距離型態 : difference_type, 是一種帶號數整型型態

 

資料結構與 C++ 疊代器的二分搜尋

二分搜尋原理 : 既定有序陣列, 從陣列中間開始搜尋, 若被搜尋元素小於搜尋元素, 則中間值向較小方向的中間值移動, 最大值設定為原中間值

假定現在有一個有序 vector, 且 vector 內的元素可被比較 : vector<T> vec;

#include <iostream>

#include <vector>


using namespace std;

int main(int argc, char *argv[]) {

	vector<T> vec;

	auto vecBegin {vec.begin()};

	auto vecEnd {vec.end()};

	T sought;		//被搜尋的數據

	auto vecMid {(vecBegin + vecEnd) / 2};

	while(vecMid != vecEnd and *vecMid != sought) {

		if(sought > *vecMid) {

			vecBegin = ++vecMid;

		}else {

			vecEnd = vecMid;

		}

		vecMid = (vecBegin + vecEnd) / 2;

	}

}

 

對陣列進行列表初始化時, 允許設定陣列維度為空. 編譯器會根據陣列中的元素數量自動計算陣列維度. 但是使用無維度的 char 型態陣列, 進行初始化的時候必須以空字元為結束, 否則會產生嚴重錯誤

 

陣列的實際運用中, 要儘量避免陣列的直接拷貝與指派, 因為這屬於編譯器拓展, 目前並不是 C++ 標準特性. 這種程式碼可能在其它編譯器中無法通過編譯, 無法運作

 

不存在參考的陣列

  • int arr[N];        //陣列
  • int *ptr[N];        //元素型態為 int * 的陣列
  • int (*ptr)[N];        //指向一個擁有 N 個 int 型態元素的陣列, 是一個指標陣列
  • int (&ref)[N];        //參考一個擁有 N 個 int 型態元素的陣列
  • int *(&ref)[N];        //指向一個 【參考擁有 N 個 int 型態元素的陣列】

 

使用陣列註標時, 通常將其宣告為 size_t 型態. size_t 是一種與設備相關連的無號數型態

 

在陣列的宣告中, 要注意 auto 與 decltype 的不同

int arr[]; => arr 為 int [] 型態

auto p {arr} => p 為 int * 型態

decltype(arr) declArr => declArr 為 int [] 型態

 

STL 中定義了 begin()end() 函式, 這和容器中的 begin()end() 方法有些類似

int arr[10];

auto arrBegin {begin(arr)};        //arrBegin 為 int * 型態, 指向 arr[0]

auto arrEnd {end(arr)};        //arrEnd 為 int * 型態, 指向 arr[9] 的下一個位址

可以運用這兩個函式, 使用陣列初始化 vector

#include <iostream>

#include <vector>

using namespace std;

int main(int argc, char *argv[]) {

    T arr[N] = {elem1, elem2, elem3, ...};

    vector<T> vec(begin(arr), end(arr));

}

 

指標可以進行減法運算, 結果的型態為 ptrdiff_t, 定義在 <cstddef&gt; 中, 是一種帶號數型態

假設指標 p 為空指標, 那麼允許給 p 加上或者減去一個值為 0 的整型常數表達式

兩個空指標允許做減法運算, 結果為 0

 

現代的 C++ 程式應該儘量使用 vector 和 string, 使用疊代器 iterator. 同時避免使用 C 式 char * 陣列, 內建陣列和指標. 因為內建陣列容易出現陣列註標越界, 而指標是被公認非常危險的一種操作

 

在陣列的參考中, 如果表達式中含有的陣列註標與陣列的維度一樣, 那麼表達式的結果將是既定型態的元素; 如果表達式中含有的陣列註標小於陣列維度, 那麼表達式的結果將是既定索引處的一個內置陣列

假設現有陣列 int a[5][5][5], 那麼 int &ref[5] 等價於 a[1][1], int &ref[5][5] 等價於 arr[1]

 

C++ 學習筆記-Jonny'Blog

2018-03-09 16:44:34 by Jonny, 更新內容 :

4. 表達式

當一個物件被用於右值的時候, 使用的是物件的具體資料內容; 當一個物件被用於左值的時候, 使用的是物件的資料形態 (在記憶體中的位址)

 

布林值不應該被寫入運算中

bool var {true};

var = -var;        //此時 var 被轉換為 1, 取反操作使得 var 暫時為 -1, 最後被轉換為布林值. 因為 -1 在 C++ 中的布林值還是 true, 所以 var 最終還是為 true

 

根據餘數運算子的定義, 則有 (m / n) * n + m % n == m (n != 0)

 

根據運算元優先順序, 我們可以將下面程式碼

#include <iostream>


using namespace std;

T func(T num) {

	//function statement

	return DATA of type_T;

}

int main(int argc, char *argv[]) {

	T x;

	auto f {func(x)};

	while(f) {

		//while statement

		f = func(x);

	}

}

簡化為

#include <iostream>


using namespace std;

T func(T num) {

	//function statement

	return DATA of type_T;

}

int main(int argc, char *argv[]) {

	T x;

	auto f {func(x)};

	while((f = func(x))) {

		//while statement

	}

}

請仔細觀察 while 語句中的條件判斷

 

使用算術指派運算元在性能上會比直接使用算術運算元要高. 並且算數指派運算元滿足右結合律

從性能方面考慮, 如非必須, 儘量使用前置遞增或者前置遞減運算元. 因為後置遞增或者後置遞減運算元會先保存原來的值並且回傳後才執行遞增或遞減運算

 

根據運算子優先順序, *p++ 已經被運用得非常多

#include <iostream>


using namespace std;

int main(int argc, char *argv[]) {

	T arr[N] {elem1, elem2, ...};

	T *p {&arr};

	cout << *p++;		//輸出內容為 elem1 的具體資料

}

但是, 假設現在有函式 func(T *p), 執行 *p = func(*p++) 會造成未定義行為. 並且不同的編譯器可能會有不同的處理方式

 

條件操作符 "?:" : condition ? expression1 : expression2. 當 condition 為 true, 則回傳 expression1 的值; 否則, 回傳 expression2 的值

條件操作符支持巢狀型態, 並且滿足右結合律

 

現在假設有兩個 unsigned 型態的數 : a = 0145, b = 0257 (都為八進制)

位元運算子

運算

十進制結果

二進制結果

~

~a

4294967140

( 24 位都為 1) 01100100

<<

a << 1

310

100110110

>>

a >> 1

77

01001101

&

a & b

37

00100101

^

a | b

239

11101111

|

a ^ b

202

11001010

同時, "<<" 與 ">>" 運算子又稱 IO 運算子, 並且滿足左結合律

 

sizeof 運算子有兩種型態

  • sizeof(type)
  • sizeof expression

sizeof 運算子中解參考一個無效指標是一個安全的行為, 並且 sizeof 運算子不會將陣列轉換為指標處理

對 string 或 vector 進行 sizeof 運算, 只會回傳該型態固定部分的大小, 不會計入物件中的資料占用的空間

 

在大多數表達式中, 比 int 型態小的整數型態通常會被晉升為較大的整數型態

整形晉升會將較小的整數型態晉升為較大的整數型態

如果某個運算元運算對象不一致, 這些運算元對象將隱含地轉化為同一種型態

轉化的具體型態取決於編譯器本身

常量整數值 0 或者字面值 nullptr 可以被轉化為任意型態的指標

指向任意非常數的指標能轉化為 void *

指向任意物件的指標能轉化成 const void *

 

任何具有明確定義的轉化, 只要不包含底層 const, 都可以使用 static_cast

#include <iostream>


using namespace std;

int main(int argc, char *argv[]) {

	int i, j;

	double f = static_cast(j) / i;

}

static_cast 也可用於找回 void * 指標中的資料, 轉化時應該保證型態相同並且指標指向的資料不變

#include <iostream>


using namespace std;

int main(int argc, char *argv[]) {

	int i = 10;

	void *p = &i;

	auto *ip = static_cast(p);

}

 

const_cast 只能改變運算對象的底層 const

#include <iostream>


using namespace std;

int main(int argc, char *argv[]) {

	const char *c;

	char *p = const_cast(c);

}

但是通過 p 寫入數據是未定義行為, 不同的編譯器可能有不同的處理方式

 

reinterpret_cast 能夠進行型態轉化

#include <iostream>


using namespace std;

int main(int argc, char *argv[]) {

	int *ip;

	char *cp = reinterpret_cast (ip);

}

但是實際上, cp 所指向的物件是 int 型態的, 而並不是 char 型態

reinterpret_cast 本質上是依賴於裝置的, 若要安全使用 reinterpret_cast, 則要對要轉化的型態和編譯器轉化的過程非常了解

 

在實際的程式設計中, 我們應該儘量避免強制轉換型別, 尤其是 reinterpret_cast (如果程式中存在 reinterpret_cast 形式的強制轉換型別, 那麼說明程式存在設計缺陷). 如果一定要使用, 那麼也應該控制變數可視範圍

 

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

運算元

運算型態

名稱

優先順序

::

::name

全局命名空間可視範圍

0

class::name

類別可視範圍

namespace::name

命名空間可視範圍

.

object.member

成員存取

1

->

pointer->member

成員存取

[]

expression[expression]

陣列註標

()

function(expression_list)

函式構造

type(expression_list)

型別構造

++

variable++

後置遞增

2

--

variable--

後置遞減

typeid

typeid(type)

型別 ID

typeid expression

表達式型別 ID

cast_name<>

cast_name<type> (expression)

型別轉換

++

++variable

前置遞增

3

--

--variable

前置遞減

~

~expression

位元補數

!

!expression

取反

-

-expression

取負

+

+expression

取正

*

*experssion

解參考

&

&expression

取位址

()

(type)expression

型別轉換

sizeof

sizeof expression

物件大小

sizeof(type)

型態大小

sizeof...(name)

引數包大小

new

new type

申請記憶體空間

new type[size]

申請一段記憶體空間

delete

delete expression

釋放記憶體空間

delete []expression

釋放一段記憶體空間

noexcept

noexcept(expression)

是否丟擲異常情況

->*

pointer->*pointer_to_member

指向成員存取的指標

4

.*

object.*pointer_to_member

指向成員存取的指標

*

expression * expression

5

/

expression / expression

%

expression % expression

取餘

+

expression + expression

6

-

expression - expression

<<

expression << expression

位元左旋

7

>>

expression >> expression

位元右旋

<

expression < expression

小於

8

>

expression > expression

大於

<=

expression <= expression

小於等於

>=

expression >= expression

大於等於

==

expression == expression

等於

9

!=

expression != expression

不等於

&

expression & expression

位元 and

10

^

expression ^ expression

位元 xor

11

|

expression | expression

位元 or

12

&&

expression && expression

and

13

||

expression || expression

or

14

?:

expression ? expression : expression

條件運算

15

=

variable = expression

指派

16

*=

expression *= expression

複合指派

17

/=

expression /= expression

%=

expression %= expression

+=

expression += expression

-=

expression -= expression

<<=

expression <<= expression

>>=

expression >>= expression

&=

expression &= expression

|=

expression |= expression

^=

expression ^= expression

throw

throw exception

丟擲異常情況

18

,

expression, expression

逗號

19

 

C++ 學習筆記-Jonny'Blog

2018-03-14 18:12:44 by Jonny, 更新內容 :

5. 陳述式

switch 中, case 後必須是一個整型表達式

如若想在 switch 內部宣告變數, 不應進行初始化, 變數可視於整個 switch 內. 若需要進行初始化, 則可以將變數宣告於一個可視範圍內 ("{}")

#include <iostream>


using namespace std;

int main(int argc, char *argv[]) {

	int choice;

	cin >> choice;

	switch(choice) {

		case 1 :

			{

				int var = 0;

			}

	}

}

如果沒有將變數 var 限定在區塊 "{}" 內, 會產生編譯錯誤

 

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

 

C++ 例外處理 :

  • throw : 例外處理檢測部分通常使用 throw 來拋出例外, 表示遇到了程式無法處理的例外問題
  • try : try 區塊由 try 和一個或者一個以上的 catch 共同構成. try 區塊中通常用於丟出例外, 並且可能被某個 catch 捕捉 (因為可能遇到 catch 捕捉不到的意外情況). 當 catch 區塊捕捉到例外之後, 由區塊內的程式碼進行例外處理
#include <iostream>

using namespace std;

int main(int argc, char *argv[]) {

    /* 宣告兩個 runtime_error 型態的變數, 設定具體的錯誤提示 */

    runtime_error errA("Error A");

    runtime_error errB("Error B");

    int choice;

    cin >> choice;

    /* 例外處理 */

    try {

        /** 當 choice 為 1 或者 2 的時候丟出例外 **/

        switch(choice) {

            case 1 :

                throw errA;

            case 2 :

                throw errB;

            default :

                break;

        }

        cout << choice << endl;

    }catch(runtime_error &errA) {/** 捕捉 errA 型態的例外 **/

        /*** 使用 cerr 輸出例外提示 ***/

        cerr << errA.what() << " has been caught!" << endl;

    }catch(runtime_error &errB) {/** 捕捉 errB 型態的例外 **/

        cerr << errB.what() << " has been caught!" << endl;

    }

    /* runtime_error::what() 函式是用於保存錯誤提示, 可以使用上述變數宣告的形式進行自訂 */

}

 

在程式碼中, 程式設計者應該將使用者提示與例外處理的程式碼分離

以這條為基礎, 我們將下列程式碼

#include <iostream>


using namespace std;

int main(int argc, char *argv[]) {

	char a;

	cin >> a;

	if(a == '1') {

		cerr << "a cannot be '1'!" << endl;

	}else {

		cout << a << endl;

	}

}

改寫為

#include <iostream>


using namespace std;

int main(int argc, char *argv[]) {

	char a;

	cin >> a;

	try {

		if(a == '1') {

			throw runtime_error("a cannot be '1'!");

		}

		cout << a << endl;

	}catch(runtime_error &e) {

		cerr << e.what() << endl;

	}

}

 

幾乎所有例外類別都一定在 STL 的 <stdexcept>

  • exception : 常見的例外. 這個類別只能被默認初始化
  • runtime_error : 程式運行時監測到的例外
  • range_error : 運行時, 報告獲得結果超過變數範圍
  • overflow_error : 運行時, 報告算術溢位
  • underflow_error : 運行時, 報告算術反向溢位
  • logic_error : 邏輯錯誤, 程式執行前假定可偵測的錯誤
  • invalid_argument : 邏輯錯誤, 報告無效的引數
  • domain_error : 邏輯錯誤, 報告網域錯誤 (引數結果不存在)
  • length_error : 邏輯錯誤, 報告嘗試產生的物件太長而無法指定
  • out_of_range : 邏輯錯誤, 報告引數超出其有效範圍
  • bad_cast : 轉型為參考類型失敗的時候擲出
  • bad_alloc : new 表達式失敗

exception 、bad_castbad_alloc 類別外, 其它類別都應該使用 string 物件或者 const char * 進行初始化

runtime_errorlogic_error 沒有預設的建構子

所有的例外類別都只有一個 what() 方法, 回傳 const char *

 

C++ 學習筆記-Jonny'Blog

2018-03-15 17:40:52 by Jonny, 更新內容 :

6. 函式

如若想讓一個變數的可視範圍在函式區塊結束之後還能夠存在, 可以在函式內將變數宣告為 static 型態. 靜態空間變數如果沒有被初始化, 則會被值初始化. 內建型別的靜態空間變數會被初始化為 0

 

函式的宣告可以省略參數名, 但不可以省略引述型別

void print(long a, int b, char c) {



}

可以被宣告為

void print(long, int, char);

但不可以被宣告為

void print();

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

 

拷貝大型別物件或者向量物件的時候, 效率比較低, 甚至有的物件根本不支持拷貝. 此時, 可以使用參考型態的方式進行物件訪問. 當無需要更改物件的具體數據的時候, 可以使用常數參考

 

可以將函式的參數定義為陣列的參考, 並且使用 range-for 避免越界

void arrayOperator(T (&arr)[N]) {



}

N 不可以省略, 也不可以定義為 T &arr[N] (此時, 參數將代表參考的陣列, 不存在參考的陣列). 不過這個函式限定了呼叫範圍, 只可以傳送 N 個元素的 T 型別陣列這樣的引數

 

當在 main() 函式中定義了參數 argv, 則一定要從 argv[1] 開始. 因為 argv[0] 通常用於保存程式的名稱, 而並不是用戶輸入

 

實作函式的過程中, 可能會出現無法確定的參數個數. 如果參數型別相同的情況之下, 可以使用 C++ 11 新標準中的 initializer_list 標準庫型別

initializer_list 的操作與 vector 類似. 都具有 size()begin() end() 方法

不過, initializer_list 中的元素永遠都是常數, 無法改變

initializer_list 的初始化 :

  • initializer_list<T> initList;
  • initializer_list<T> initList {T_elem1, T_elem2, ...};
  • initializer_list<T> initList(initList2);
  • initializer_list<T> initList = initList2;        //拷貝或者指派一個 initializer_list<T> 物件

 

省略符號 ... 在 C 和 C++ 中通用, 大多數 C++ 型別的物件在傳遞給 "..." 時, 都無法正確拷貝

省略符號 ... 只能出現在函式參數定義的最後一個位置

 

函式儘量不返回空間內物件的參考或者空間內指標物件

 

當函式返回一個左值, 且這個左值不是一個常數引用, 則可以將呼叫後的結果用於指派

 

在 C++ 11 中, 可以在函式回傳的時候, 回傳一個列表初始化的臨時變數

vector<T> getError(const string &error) {

    if(error == "1") {

        return {1001, 1002, 1003};

    }else {

        return {};

    }

}

 

main() 函式的回傳值可以看作程式是否成功執行的標誌. 在標準中, 應該只回傳 0EXIT_FAILUREEXIT_SUCCESS, 並且這兩個前處理變數宣告於 <cstdlib> 中. 因為 main() 函式回傳非 0 值的具體定義取決於機器, 所以不建議使用非 0 值回傳. 因為在某些機器上, 可能會發生無法預料的情況

 

遞迴函式中, 一定有某條路徑是不包含遞迴的, 有時候稱為遞迴迴圈

main() 函式不可以自己回呼自己

 

遞迴與 vector

#include <iostream>

#include <vector>



using namespace std;

template<typename T>

void recursivePrint(T iterator, T end) {

	if(iterator != end) {

		cout << *iterator++ << endl;

		recursivePrint(iterator, end);

	}else {

		cout << endl;

	}

}

int main(int argc, char *argv[]) {

	vector<int> vec;

	for(auto i {0}; i < 10; i++) {

		vec.push_back(i + 1);

	}

	recursivePrint(vec.begin(), vec.end());

}

/*

	輸出結果 :

			1

			2

			3

			4

			5

			6

			7

			8

			9

			10

*/

 

回傳 T (*)[] 陣列指標

T (*function(parameter_list))[dimension];

在 C++ 11 中, 可以使用尾置回傳型別 : auto function(parameter_list) -> int (*)[dimension];

假如已經知道回傳的陣列指標指向的陣列, 那麼可以使用 decltype

decltype(ARRAY_NAME) *function(parameter_list)

 

main() 函式不可以被多載

 

不允許兩個函式在多載時, 除了回傳型別不同, 其它元素都相同

 

在函式多載重, 一個擁有頂層 const 的參數無法與另一個沒有頂層 const 的參數分別開 (因為在引數傳遞的時候, 兩者可以隱含地相互轉換)

以下函式互相都是無法區分的, 屬於重複宣告

  • Type function1(T _T);
  • Type function1(const T _T);
  • Type function2(T *_T);
  • Type function2(T *const _T);

當函式的參數為底層 const, 就可以進行多載

以下四個都是不同的函式, 不屬於重複宣告

  • Type function1(T &_T);
  • Type function1(const T &_T);
  • Type function2(T *_T);
  • Type function2(const T *_T);

建議在函式功能相似的時候, 才採用函式多載

 

有一段程式碼

const string &getShorter(const string &str1, const string &str2) {

    return str1.size() <= str2.size() ? str1 : str2;

}

當想要對上面程式碼的回傳物件進行修改的時候, 就會產生編譯錯誤. 那麼如果確實想要修改的話, 我們可以進行函式多載與 const_cast 的運用來解決這個問題

const string &getShorter(string &str1, string &str2) {

    auto &r = getShorter(const_cast<const string &>str1, const_cast<const string &>str2);

    return const_cast<string &>r;

}

此時, 回傳的 string 物件將可以進行修改

在 const string &getShorter(string &str1, string &str2); 中, 當我們將傳入的 string & 通過 const_cast 進行顯示型別轉換之後, 再呼叫 getShorter(), 此時通過最佳配對, 將不會呼叫自己進行遞迴, 那麼呼叫的就是 const string &getShorter(const string &str1, const string &str2); 函式. 最後將運用 const_castconst string & 轉換為 string & 就可以對回傳的物件進行修改了

 

在函式多載中, 當有多餘一個函式可以配對, 但是每一個都不是最佳的選擇的時候, 將會發生編譯錯誤. 稱為 ambiguous call (模稜兩可的呼叫)

#include <iostream>


using namespace std;

int getNumber(int num) {

	return num;

}

short getNumber(short num) {

	return num;

}

int main(int argc, char *argv[]) {

	long a = 1;

	cout << getNumber(a) << endl;		//錯誤的函式呼叫 : ambiguous call (模稜兩可的呼叫). 無論是 int getNumber(int num); 還是 short getNumber(short num); 都不是最佳的選擇

}

當編譯器拋擲 ambiguous call 的信息時, 可以通過強制型別轉換來實現函式的配對

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

  1. 精準配對 :
    • 引數與參數型別相同
    • 引數從陣列型態或者函式型態轉換為對應的指標型態
    • 向引數添加頂層 const 或者從引數中丟棄頂層 const
  2. 通過 const 轉換實現的配對
  3. 通過型別提升實現的配對
  4. 通過算術型別轉換或者指標轉換實現的配對 (所有算數型別轉換的級別都是相同的)
  5. 通過類別型別轉換實現的配對

如果函式多載的區分與參考型別的參數是否使用了 const, 或者指標型別的參數是否指向 const, 那麼呼叫時, 編譯器通過引數是否為常數來確定呼叫的函式

#include <iostream>


using namespace std;

void func(const T &);

void func(T &);

int main(int argc, char *argv[]) {

    const T a;

    T b;

    func(a);        //呼叫 void func(const T &);

    func(b);        //呼叫 void func(T &);

}

 

函式可以通過宣告, 將其限定在一個可視範圍內. 我們使用一個例子說明 :

#include <iostream>


using namespace std;

void print(long a);

int main(int argc, char *argv[]) {

	{

		void print(int a);		//此處的宣告掩埋了 void print(long a);

		long a = 1;

		print(a);		//輸出結果 : 2. 調用 void print(int a);

	}

	int b = 1;

	print(b);		//輸出結果 : 1. 調用 void print(long a); 因為 main() 的可視範圍內並沒有宣告 void print(ing a); 所以, main() 的可視範圍內無法呼叫 void print(int a); (此處的 main() 可視範圍時指 main 區塊內, 並不包括子可視範圍 ("{}")

}

void print(int a) {

	cout << a + 1 << endl;

}

void print(long a) {

	cout << a << endl;

}

在 C++ 中, 儘管支持將函式限定在一個可視範圍內, 但是實際中, 並不支持這樣去做

 

一旦一個函式內的參數被指派了默認值, 那麼後面的參數必須都要被指派默認值

我們應當合理設定被指派了默認值的參數的順序, 讓使用最多的參數儘量靠前, 使用較少的參數儘量靠後

在一個既定的可視範圍內, 一個函式的參數只可以被指派一次默認值, 在作用域中在此宣告函數並且改變一設定的默認參數值, 將會產生編譯錯誤

多次宣告中, 允許為沒有指派默認值的參數進行指派默認值

void print(long a, int b, char c = '1');

當第二次宣告的時候, 可以給 b 或者 a 和 b 指派默認值, 可以宣告為

void print(long a, int b = 1, char c);

或者

void print(long a, int b, char);

的形式

 

一個限定的局部範圍的變數不應該被指派為函式的參數默認值

當使用變數作為函式的默認值的時候, 當變數改變時, 函式的默認參數值將會也隨之改變

 

可以使用 inline 指定一個函式為內嵌函式. 內嵌函式可以避免在函式呼叫的時候的開銷

#include <iostream>


using namespace std;

inline const string &getShorter(const string &str1, const string &str2) {

	return str1.size() <= str2.size() ? str1 : str2;

}

int main(int argc, char *argv[]) {

	string a = "asd";

	string b = "vedsvc";

	cout << getShorter(a, b) << endl;		//在呼叫的時候, getShorter() 會展開, 類似於 cout << a.size() <= b.size() ? a : b 的形式

}

inline 只是向編譯器發出一個請求, 編譯器可以不接受這個請求

一般地說, 內嵌機制用於優化規模比較小、流程直接和頻繁被呼叫的函式. 大多數編譯器都不支持內嵌函式的遞迴

完全被定義在 classstruct 或者 union 內的函式, 無論是成員函式還是 friend 函式, 都是一個隱含的 inline 函式

 

如果要實作 constexpr 函式, 要確保回傳型別以及所有的參數的型別都是字面值, 且函式內部只能有一個 return

帶有 constexpr 的函式也是一個隱含的 inline 函式

constexpr 函式內也可以包含其它陳述式, 只要這些陳述式在程式運行的過程中, 不執行其它任何操作即可, 例如空陳述式、型別別名和 using 宣告

 

C++ 允許 constexpr 回傳一個非常數

/* Run in CLion with maxOS */

#include <iostream>


using namespace std;

constexpr int get() {

    return 5;

}

constexpr int getNumber(int num) {

    return num * get();

}

int main(int argc, char *argv[]) {

    int a[getNumber(3)];

    int b = 1;

    int c[getNumber(b)];

    cout << "OK" << endl;       //輸出結果 : OK

}

 

一般將 inline 函式和 constexpr 函式的宣告直接放在標頭檔中. 但是 inline 函式和 constexpr 函式是可以在程式中被多次宣告的

 

當程式中含有一些只在除錯時使用的程式碼, 而實際發布的時候需要屏蔽的程式碼, 可以使用 NDEBUGassert 來完成

assert 是一種前處理宏, 可以使用一個表達式作為其的條件. 它被定義在 <cassert> 中. 它通常用於檢測不可能發生的條件

assert(expression)

當 expression 回傳 true, 那麼 assert 什麼都不做; 否則, 就會直接終結程式並且輸出錯誤信息

assert 依賴於一個叫做 NDEBUG 的前處理變數. 假設宣告了 NDEBUG 這個前處理變數, 那麼 assert 什麼都不做; 否則, assert 將會正常執行檢測

#include <iostream>

#define NDEBUG    //它必須宣告在 #include <cassert> 之前, 否則它將毫無作用

#include <cassert>


using namespace std;

int main(int argc, char *argv[]) {

    int a = 10;

    assert(a < 5);    //此時 assert 什麼也不做, 可以類似看成跳過了 assert

}

 

當然, 我們也可以不通過 NDEBUG 進行開發除錯, 自己定義自己的除錯前處理變數

#include <iostream>

#include <cassert>

//#define DO_NOT_DEBUG    //此時, 這個前處理變數可以在 #include <cassert> 前或者後宣告


using namespace std;

int main(int argc, char *argv[]) {

    int a = 10;

#ifndef DO_NOT_DEBUG    //假如沒有宣告 DO_NOT_DEBUG 這個前處理變數, 就執行 assert 檢查; 假如 DO_NOT_DEBUG 被宣告, 那麼直接跳過 #endif 前的所有陳述式

    assert(a < 5);

#endif

}

 

編譯器內部內建了一些靜態空間變數

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

 

假設有函式

bool lengthCompare(const string &str1, const string &str2);

宣告一個函式指標指向 lengthCompare()

bool (*pf)(const string &str1, const string &str2) = lengthCompare;

或者

bool (*pf)(const string &str1, const string &str2) = &lengthCompare;

取位址運算子 & 在函式指標的指派中可以被省略

但是不可以將函式指標宣告為

bool (*pf) = lengthCompare;

當 *pf 非 nullptr 值, 在呼叫 lengthCompare() 函式時, 可以使用以下三種形式, 三種形式相互等價

  • pf(const_string_Elem1, const_string_Elem2);
  • (*pf)(const_string_Elem1, const_string_Elem2);
  • lengthCompare(const_string_Elem1, const_string_Elem2);

在使用函式指標呼叫函式時, 解參考運算元 * 可以省略. 但是, 當我們使用解參考運算元 * 時, 括號必不可少

函式指標指向的函式回傳型態要與函式指標的宣告型別相同

函式指標指向多載的函式時, 必須明確指出參數的型別

 

函式的參數可以是函式指標

首先我們使用 typedef 定義函式及函式指標, 簡化後面的參數

typedef T func(parameter_list);

typedef T (*funcp)(parameter_list);

此處, func 為 T() 函式型別, funcp 為 T 型別的函式指標

我們可以看成 : 將 [T(parameter_list)] 宣告為名稱為 (*funcp) 的函式指標

將宣告用於函式 :

void function(T param1, T param2, func);

void function(T param1, T param2, funcp);

上面兩個看似是多載的 function 函式, 實際上是同一個函式. 第一個 function 函式中的參數 func 會被轉換為函式指標

 

同樣也可以讓一個函式回傳一個函式指標

void (*func(parameter_list1))(parameter_list2);

func 有參數列表, 那麼 func 是一個函式; func 前有解參考運算子 *, 那麼 func 是一個指標; 指標後面跟著第二個參數列表, 那麼 func 是一個回傳 void * 型別的函式指標

同樣可以使用 using 或者 typedef 來簡化

當然, 也可以使用 C++ 11 的尾置回傳型別來宣告一個回傳函式指標的函式

auto func(parameter_list1) -> T (*)(parameter_list2);

C++ 學習筆記-Jonny'Blog

2018-03-22 23:44:47 by Jonny, 更新內容 :

7. 類別

this 是一個常數指標, 所以保存的地址不能變更

 

在類別內函式名稱緊跟著 const 限定符, 用於隱含地修改 this 指標的型別

假定有類別 class Foo;

this 指標的型別為 Foo *const, 當在函式方法加上 const 限定符, this 指標的型別將變成 const Foo *const, 可以訪問類別內被 const 限定的方法

一般當一個方法不變更屬性成員, 那麼就應該在方法後面加上 const 限定符

 

任何執行輸出的函式都應該儘量減少對輸出格式的控制, 將控制權轉交給使用類別的程式設計者

 

當操作會更改資料流的內容的時候, 應該儘量使用參考, 而不是常數參考

 

建構子在對 const 物件建構時, 可以傳入值, 知道建構終結之後, 此 const 物件才可以獲得常數的屬性

 

C++ 11 規定, 可以對一個類別屬性成員進行初始化

 

在 C++ 11 中, 如果需要默認行為, 就可以在參數列表後增加 "= default", 它既可以被宣告與類別內, 也可以可以被宣告與類別外. 在類別內被宣告時, 它是內嵌的; 當它被宣告與類別外時, 它默認情況之下不是內嵌的

 

建構在不必要的情況之下, 不應該輕易變更已經被初始化的屬性成員. 如果不使用已經被初始化的值, 那麼所有的建構子都應該明顯地初始化每一個內建的型別屬性

 

當類別中包含容器屬性的時候, 類別的合成版本可以正常工作 (編譯器將會自動對於每一個屬性成員進行拷貝、指派和銷毀操作)

 

struct 的默認成員權限是 public, class 內默認成員權限是 private

 

類別外允許訪問類別內的私用成員, 如果有需要, 讓其稱為友誼類別或者友誼方法即可, 即在宣告之前增加 friend 關鍵字

夥伴關係的宣告只可以出現在類別的內部, 並且 friend 宣告的任何成員都不是真正的成員, 不受類別的訪問權限的限制

一般, 在類別開始或者尾部集中宣告 friend 成員

當想要呼叫類別內部的友誼函式時, 應該在類別外再進行多一次的宣告, 儘管許多編譯器支援無需再次宣告

 

用來定義型別的成員, 都必須被定義先然後才可以使用

 

有時候, 如果希望類別內的某個屬性成員在任何情況之下都是可以被修改的, 那麼可以在屬性成員宣告的時候加上 mutable 關鍵字. 一個帶有 mutable 宣告的屬性成員不能被 const 限定, 否則會產生編譯錯誤. 即是在一個被 const 限定的方法中, 帶有 mutable 關鍵字宣告的屬性成員也能在其中被變更

 

#include <iostream>

using namespace std;

class Foo {

	private:

		int a;

		char b;

		long c;

	public:

		Foo &setAandB(int a, char b) {

			this->a = a;

			this->b = b;

			return *this;

		}

		Foo &setC(long c) {

			this->c = c;

			return *this;

		}

};

int main(int argc, char *argv[]) {

	Foo a;

	a.setAandB(1, 'a').setC(10);		//a->a = 1, a->b = 'a', a->c = 10

}

當將上述程式碼中的 Foo::setC(long) 與 Foo::setAandB(int, char) 修改為

inline Foo Foo::setC(long c) {        //增加 inline 宣告為了模擬在類別內實作

    this->c = c;

    return *this;

}

inline Foo Foo::setAandB(int a, char b) {

    this->a = a;

    this->b = b;

    return *this;

}

那麼在此執行 main() 函式中的程式碼, 最終的結果就會不太一樣 :

a->a 與 a->b 不變, 但是 a->c = 0

如若讓 Foo::setAandB() 返回 const Foo & 時, 此時再次呼叫 Foo::setC() 方法就會出現編譯錯誤

 

class Foo {

    private:

        int a;

    public:

        void out() {

            cout << a << endl;

        }

};

假設 main() 函式中宣告了一個叫做 const Foo f 的物件, 呼叫 f.out() 會產生編譯錯誤. 因為當物件已經被 const 限定的時候, 非常數版本的方法對帶有 const 的物件是不可用的. 所以, 一般為此定義多一個 const 限定的多載版本 :

class Foo {

    private:

        int a;

    protected:

        void outProtected() {

            cout << a << endl;

        }

    public:

        void out() {

            this->outProtected();

        }

        void out() const {

            this->outProtected();

        }

};

定義多一個 Foo::outProtected() 方法並非沒有原因, 並且還存在不少的好處 :

  • 將方法限定在 protected 內放置類別外被呼叫, 類別外限定只可以用 Foo::out() 這個多載版本的方法. 當類別會被繼承的時候, 還可以保留到繼承之後繼續使用; 當類別不需要繼承的時候, 可以限定在 private
  • 可以防止相同的程式碼重複多次, 並且如果程式碼需要修改, 那麼只需要修改一個方法就可以了
  • 方法內呼叫並不需要增加額外的開銷. 因為在類別內實作的方法都會被當成 inline 函式直接展開. 假如方法實作在類別之外, 那麼也可以加上 inline 宣告函式微內嵌函式

 

在 C++ 中, class T _T; 和 T _T; 是等價的宣告, struct 也相同

 

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

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

 

當一個類別內有一個方法想要訪問另外一個類別的私用成員時, 可以在另一個類別內用 friend 對此方法 (包含類別作用範圍) 進行宣告, 而且這個方法必須被實作在類別之外

#include <iostream>


using namespace std;

class B;		//宣告 B, 讓接下來的 A 類別內部的 B 可以被識別

class A {

	public:

		void func(B &a);		//此時, B 可以僅被宣告, 而不需要實作

};

class B {

	private:

		int a;

	friend void A::func(B &a);		//其中, A 必須已經被實作, 否則將會產生編譯錯誤

};

inline void A::func(B &a) {

	cout << a.a << endl;		//B::a 是 B 的私用成員, 在類別的作用範圍之外被訪問本應該會產生編
譯錯誤

}

int main(int argc, char *argv[]) {

	A a;

	B b;

	a.func(b);		//輸出 0, 因為 B::a 被值初始化

}

 

如果在類別 A 中有如下宣告 :

friend class B;

那麼, B 類別中的所有方法都將獲得 A 類別的 friend 存取權限

#include <iostream>


using namespace std;

class A {

	friend class B;

	private:

		int a;

};

class B {

	public:

		void func(A &a) {		//此時, 可以將函式實作在類別以內

			a.a++;

			cout << a.a << endl;

		}

};

/*inline B::func(A &a) {		//類別內已經被實作, 此處進行註銷

	a.a++;

	cout << a.a << endl;

}*/

int main(int argc, char *argv[]) {

	A a;

	B b;

	b.func(a);		//輸出結果 : 1

}

如果一個類別想要將方法宣告為 friend, 那麼必須在類別內分別宣告. 當函式不再同一個檔案內實作時, 應當使用 extern 進行類別外的宣告, 然後再次在類別內宣告

當類別內方法呼叫友誼函式或者友誼函式呼叫類別內函式, 那麼函式實作於類別外, 此時應該寫明定義回傳型別的類別

 

因為編譯器在處理完所有宣告之後, 才開始處理方法的實作, 所以方法中可以使用任何已經宣告的名字

 

類別中的成員如果使用外層作用範圍中的某個名字, 而且這個名字代表著一種型別, 那麼類別內不需要也不可以重新宣告或者定義

 

當成員中存在參考或者 const 的時候, 必須在建構子中執行明確初始化

class Foo {

	private:

		int a; 

		int &i;

		const int b;

	public:

		Foo(int a, int &i, const int b) : a(a), i(i), b(b) {

			//a 與 i 至少要出現, 在這裡進行明確初始化, 並且每個屬性成員只能夠出現一次. 否則, 會產生編譯錯誤
		}

};

最好讓建構子的初始化順序與類別內的屬性成員的宣告順序保持一致, 而且儘可能避免使用某些屬性成員初始化另外一個屬性成員

#include <iostream>


using namespace std;

class Foo {

	private:

		int a; 

		int b;

	public:

		Foo(int a, int b) : b(1), a(b) {

			//看似是 b 被 1 先初始化, 然後 a 由 b 的值進行初始化. 但是實際上, 是按照屬性成員的宣告順序進行初始化的, 也就是 a 被 b 先初始化, 然後由 b 再被 1 初始化. 此時, b 是未定義的

		}

};

int main(int argc, char *argv[]) {

	

}

如果可能的話, 儘量使用傳入建構子的引數作為屬性成員的初始化值. 此時, 就無需要再考慮初始化時的順序問題

 

假如一個建構子提供了所有的參數, 並且參數也提供了默認的值, 那麼它實際上也是定義了默認的建構方法

C++ 11 支援將一個建構子的功能委託給其它的建構子. 基本的委託建構子形式是

T(parameter_list1) : T(parameter_list2)

其中, 第一個 T 是由委託行為的建構子, 第二個 T 是之前已經被定義的建構子

class Foo {

	private:

		int a;

		int b;

		string c;

	public:

		Foo(string c, int a, int b) : a(a), b(b), c(c) {

			

		}

		Foo() : Foo("", 0, 0) {

			

		}

		Foo(string c) : Foo(c, 0, 0) {

			

		}

};

 

當實作一個類別, 但是該類別不存在默認的建構子, 而且編譯器也無法自動合成的情況之下, 當屬於該類別的變數被宣告的時候, 而且沒有被初始化的情況下會產生編譯錯誤

class A {

    public:

        A(int a) {


        }

};

class B {

    B() {


    }

    A a;        //產生編譯錯誤

};

 

我們不應該在用類別型別定義物件的時候, 在變數後面增加函式呼叫符 "()", 因為這回使這個變數變成一個返回類別型態的函式

 

當類別和直播啊結合時, 可明確地添加或者不添加建構函式呼叫符

#include <iostream>


using namespace std;

class Foo;		//假設有個 Foo 類別被實作

int main(int argc, char *argv[]) {

	Foo *f;

	f = new Foo;		//等價於 f = new Foo();

}

 

C++ 支援往一個建構子傳送一個引數, 這個引數的型別必須與建構子的第一個參數型別相同. 此時, 編譯器呼叫建構子並且傳入這個引數, 並且生成一個臨時的物件傳入, 即由一個與建構子第一個參數相同型別的引數向類別型態進行隱含型別轉換

 

#include <iostream>


using namespace std;

class Foo {

	private:

		string a;

		int b;

		char c;

	public:

		explicit Foo(string a = " ", int b = 0, char c = 0) : a(a), b(b), c(c) {

			

		}

		Foo(string a) : Foo(a, 1, 'f') {

			

		}

		Foo &combine(Foo temp) {

			this->a = temp.a;

			this->b = temp.b;

			this->c = temp.c;

			return *this;

		}

};

int main(int argc, char *argv[]) {

	Foo a(" ", 1, 3);

	string f {"accc"};

	a.combine(f);		//正確, 此處由 string 隱含地被轉換 Foo 物件, 呼叫 Foo(string) 建構子. 
也可以將此陳述式修改為 a.combine(string("accc")); 進行明確轉換. 如果替換為 a.combine(Foo("accc")); 會產生編譯錯誤 : ambiguous call

	/* 編譯器只能接受一步的隱含型別轉換 (C++ 標準), 若將上述程式碼修改為 a.combine("accc"); 會產生編譯錯誤 */

}

從上述實例中可以總結, 我們使用類別型態轉換的時候應該儘量小心, 當方法回傳的是傳入的引數參考, 則應該儘量避免類別型態轉換. 因為臨時物件會在方法終結的時候被銷毀, 此時相當於回傳一個已經被銷毀的地址

我們可以使用 explicit 關鍵字防止類別型態轉換

explicit 只針對一個參數的建構子有效, 多個參數的建構子無需要使用 explicit, 因為多個參數不會發生類別型態轉換

explicit 不可以在類別外宣告, 也無需要在類別外進行宣告, 只需要在類別內進行宣告

不可以使用拷貝的形式向帶有 explicit 的建構子進行初始化

#include <iostream>


using namespace std;

class A {

	public:

		A(int a) {

			

		}

};

class B {

	public:

		explicit B(int b) {

			

		}

};

int main(int argc, char *argv[]) {

	A a = 1;		//合法

	B b = 1;		//編譯錯誤, 因為建構子 B(int b); 被 explicit 限定

}

假如之前的 Foo 類別中的 Foo &combine(Foo temp); 方法被 explicit 限定, 那麼可以通過明確型別轉換的方式呼叫 :

a.combie(string("str"));

或者通過 static_cast<T> 進行明確轉換

 

聚合類別 :

  • 所有成員都是 public
  • 沒有建構子
  • 沒有類別內屬性初始值
  • 沒有基底類別, 也沒有虛擬函式
C++ 學習筆記-Jonny'Blog

2018-03-24 22:20:40 by Jonny, 更新內容 :

可以使用 "{}" 初始化聚合類別. 列表的順序必須和屬性成員的宣告順序相同. 如若列表的值數量少於屬性成員數量, 那麼沒有被初始化的屬性成員將會執行值初始化, 且列表的值數量不能多過屬性成員的數量

 

建構子雖然不可以被 const 限定, 但是可以為 constexpr

帶有 constexpr 的建構子可以宣告為 "= default" 或者 "= delete"

constexpr 建構式一般來說, 函式體內是空的, 否則它將要同時滿足建構子無 return 陳述式的要求, 還要滿足 constexpr 函式內有且僅有一句可以執行的 return 陳述式的要求

constexpr 建構子必須初始化所有屬性成員

constexpr 建構子用於生成 constexpr 物件以及 constexpr 函式的參數或者回傳型別

 

靜態屬性成員被所有相同類別物件共享, 靜態方法不可以使用 this 指標, 也不可以宣告為 const

 

雖然習慣上, 我們常用作用範圍運算元 ("::") 來訪問類別中的靜態成員, 但是同樣可以i 通過成員訪問運算元 ("."、"->"、".*" 和 "->*") 進行訪問

 

通常, 我們不建議在類別內部直接對靜態屬性成員進行初始化

當在類別內部初始化靜態屬性成員時, 要求靜態屬性成員必須是字面值的常數型別, 而且初始化的值必須是一個常數表達式

class Foo {

    static constexpr int a {1};

}

通常情況下, 即是一個常數靜態成員在類別內已經被初始化, 但是也應該在類別外宣告一下這個靜態屬性成員

 

靜態屬性成員可以是不完全型別 :

#include <iostream>


using namespace std;


class A;

class B {

private:

    static class A a;       //正確, 靜態屬性成員可以是不完全型別

    class B a;      //非靜態屬性成員是一個不完全型別將會產生一個編譯錯誤

};


int main(int argc, char *argv[]) {



}

 

非靜態屬性成員不可以作為類別內函式的默認參數, 但是靜態屬性成員可以

 

8. I/O

不可以對 IO 物件進行拷貝或者指派, 所有傳入函式的 IO 物件引數都應該是參考的形式, 而且 IO 物件會因為讀取或者寫入操作而被更改狀態. 所以有讀寫與寫入操作時, 不能傳入或者回傳被 const 限定的 IO 物件的參考

 

當一個資料流發生錯誤, 那麼對於後續的操作全部都會失敗

如果當一個資料流發生錯誤, 還想繼續使用資料流, 那麼可以使用 clear() 方法復位資料流中的所有錯誤標識 :

#include <iostream>


using namespace std;


int main(int argc, char *argv[]) {

    int a;

    RESTART: cin >> a;

    /* 當輸入的 a 不是 int 型別且不能隱含地轉換為 int 型別的時候, 就會使資料流發生錯誤 */

    if(cin.fail()) {        //資料流的 fail() 方法回傳資料流是否發生錯誤

        cin.clear();

        cin.ignore();       //忽視所有之前的鍵入

        goto RESTART;

    }

}

 

在每個輸出操作之後, 可以使用操縱符 unitbuf 來設定資料流的內部狀態, 清空緩衝區. 默認情況下, 對 cerr 是設定了 unitbuf 的, 因此寫入到 cerr 的內容都是立即刷新的

 

一個輸出流可能會被繫結到另一個資料流. 在這種情況下, 當讀取或者寫入被繫結的資料流時, 繫結到的資料流的緩衝區會被立即刷新. 默認情況下, cincerr 都被繫結到了 cout. 因此, cincerr 都會導致 cout 的緩衝區被立即刷新

 

  • endl : 換行且立即刷新緩衝區
  • flush : 刷新緩衝區
  • ends : 輸出一個空字元, 並且立即刷新緩衝區

 

如果想在每次資料操作之後都立即刷新緩衝區, 可以使用 unitbuf 操縱符

cout << unitbuf;        //之後的輸出都會立即刷新緩衝區

當無需再每次都刷新的時候, 可以使用 nounitbuf 重設

cout << nounitbuf;        //回到正常的緩衝模式

在 macOS 和 CLion 下, 在除錯的時候, 所有 cout 都會等到一個區塊內的所有程式碼都被執行完畢之後統一進行輸出, 也就是刷新緩衝區. 但是使用 unitbuf, 可以在除錯的時候立即進行輸出

 

當程式異常終結的時候, 輸出緩衝區不會被刷新

 

交互式系統通常應該關聯輸入和輸出資料流, 這意味著所有輸出包括提示都會在操作之前都被影印出來

 

可以使用資料流中的 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 : 寫, 通常同時使用 trunc
  • app : 每次寫之前都定位到末尾
  • ate : 打開之後立即定位到末尾
  • trunc : 將之前的檔案內容移除
  • binary : 二進制

 

以下兩個程式碼演示了如何使用 <sstream>

#include <iostream>

#include <sstream>


using namespace std;


struct Information {

    string name;

    string ID;

    string position;

    string registerYear;

};

void read(istream &is, Information *info) {

    is >> info->name >> info->ID >> info->position >> info->registerYear;

}

int main(int argc, char *argv[]) {

    string infoString("Jonny 100001 Administrator 2018");

    istringstream istring(infoString);

    Information info;

    read(istring, &info);

    cout << info.name << endl << info.ID << endl << info.position << endl << info.registerYear << endl;

}

/* 輸出結果 :

    Jonny

    100001

    Administrator

    2018

 */
#include <iostream>

#include <sstream>


using namespace std;


struct Information {

    string name;

    string ID;

    string position;

    string registerYear;

};

void read(istream &is, Information *info) {

    is >> info->name >> info->ID >> info->position >> info->registerYear;

}

void print(ostringstream &ostring, ostream &out, Information *info) {

    ostring << info->name << " " << info->ID << " " << info->position << " " << info->registerYear << endl;

    out << ostring.str() << endl;

}

int main(int argc, char *argv[]) {

    string infoString("Jonny 100001 Administrator 2018");

    istringstream istring(infoString);

    Information info;

    read(istring, &info);

    ostringstream ostring;

    print(ostring, cout, &info);      //輸出結果 : Jonny 100001 Administrator 2018

}

 

9. 順序容器

  • vector : 向量, 支持快速隨機訪問
  • deque : 雙端佇列, 支持快速隨機訪問
  • list : 雙向鏈結串列, 插入刪除任一元素較快
  • forward_list : 單向鏈結串列, 插入和刪除任一元素較快
  • array : 陣列, 支持快速隨機訪問, 不可以添加或者刪除元素
  • string : 字串, 與 vector 類似

 

當程式在寫入操作之後, 才需要進行讀取操作的, 而且寫入具有隨機性, 位置不固定的, 可以使用 list. 完成之後, 再將所有元素拷貝到 vector

 

在較老的 IDE 中, 對於巢狀容器, 需要在兩個 ">" 之間添加空格 (這裡使用 "_" 替代空格進行演示)

vector<vector<string>_>

 

當將沒有默認建構子的物件放入容器的時候, 應該提供元素的初始化列表

#include <iostream>

#include <vector>


using namespace std;


class A {

public:

    A(int a, char b, long c, string d) {



    }

};


int main(int argc, char *argv[]) {

    vector<A> a;

    vector<A> b(10, A(1, 2, 3, " "));

    vector<A> c(10);        //錯誤, 10 個元素並沒有被初始化

}

 

array 被初始化之後, 不再支援使用 "{}" 進行指派

 

a.swap(b);

等價於

swap(a, b);

 

emplace() 方法允許向容器中傳入多個元素

 

forward_list 不支援反向容器的操作, 其中包括 :

  • reverse_iterator
  • const_reverse_iterator
  • rbegin()
  • rend()
  • crbegin()
  • crend()
  • 遞減運算元 "--"

 

若疊代器 begin == end, 那麼說明範圍是空的; 否則, 至少包含一個元素

 

array 進行拷貝的時候, 兩個 array 的大小必須相同. 對於 array 進行列表初始化的時候, 列表中的元素應該小於或者等於 array 的大小

 

array 不支援 array<T, _Size> arr(int, T) 或者 array<T, _Size> arr(int) 的形式進行初始化

 

可以使用疊代器將兩種型別不同但型別之間可以進行相互轉換的容器相互進行拷貝, 但是不可以直接進行拷貝

#include <iostream>

#include <list>

#include <deque>


using namespace std;


int main(int argc, char *argv[]) {

    list<const char *> A {elem1, elem2, ...};

    deque<string> B(A.begin(), A.end());

    deque<string> C(A);     //錯誤, 不可以直接進行拷貝

    deque<const char *> D {B.begin()->c_str(), B.end()->c_str()};

}

更改 end 疊代器可以改變傳入的範圍

 

在使用 array 內部型別的時候, 必須明確指出型別與大小

 

雖然不可以讓陣列拷貝給陣列, 但是可以讓陣列拷貝給 array 進行初始化, 但是陣列的型別與大小必須和 array 相同

 

指派會使容器內的疊代器、參考和指標失效, 而 swap() 不會 (除去 arraystring 之外, 因為 arraystring 會交換具體元素)

容器的 swap() 方法只是交換了容器的資料結構, 並沒有交換元素

建議使用非成員版本的 swap() 方法

 

assign() 方法可以將容器中的元素替換為傳入的疊代器的範圍內的引數或者替換為 n 個值為 t 的元素

順序容器中還有 assign() 方法支援容器可以從不同但是相互相容的另一種容器中指派或者從另一個容器的子序列中指派. 例如, vector<string>vector<const char *> 為不同型別, 但是可以通過 assign() 方法進行指派.

因為舊元素被替換, 因此傳遞給 assign() 方法的疊代器不可以使指向呼叫 assign() 方法的容器, 即使用 assign() 方法的容器不可以傳遞與自己相關的疊代器

多載的 assign() 接受一個整型數值與一個元素, 類似於建構子. 但是 assign() 用於替換掉原來容器中的對應元素

 

可以將一個初始化的列表通過 insert() 方法插入到容器中

 

C++ 11 新標準下, 接受元素個數或者範圍的 insert() 方法回傳一個指向第一個新加入元素的疊代器. 若實際並沒有插入任何元素, 那麼回傳第一個引數. 舊標準下, insert() 方法並不回傳任何值, 是一個 void 型別的方法

可以通過 insert() 方法的回傳值, 向一個位置重複插入元素

#include <iostream>

#include <vector>


using namespace std;


int main(int argc, char *argv[]) {

    T var;

    vector<T> vec;

    auto it {vec.begin()};        //若容器是 list、deque 或者 forward_list 型別, 那麼這個陳述式等價於呼叫它們的 push_front() 方法

    while(cin >> var) {

        it = vec.insert(it, var);

    }

}

 

呼叫容器的 emplace() 方法加入元素實際上是 emplace() 使用傳入的引數呼叫元素型別的建構子, 直接在容器管理的內存中進行建構元素

#include <iostream>

#include <list>


using namespace std;


class A {

private:

    string a;

    int b;

    double c;

public:

    A(string a, int b, double c) : a(a), b(b), c(c) {



    }

};

int main(int argc, char *argv[]) {

    list<A> l;

    //l.push_back(1, 2);      //錯誤, push_back() 方法是對元素的拷貝

    l.push_back(A("Asd", 2, 1.2));       //正確

    l.emplace(l.begin(), "asd", 2, 1.2);        //正確

}

emplace() 方法有三種形式 :

  • emplace() <=> insert()
  • emplace_front() <=> push_front()
  • emplace_back() <=> push_back()

 

當需要獲取容器的元素的時候, 應該確保對應位置存在元素

 

使用容器的 at() 方法訪問元素, 如果註標越界, 那麼將會擲出 out_of_range 例外情況. 此時, 可以自己設定捕捉程式碼

 

容器訪問元素的方法, 回傳的總是容器的參考

 

刪除容器成員的方法並不會檢查被刪除的位置是否存在元素, 應該確保被刪除的位置是存在元素的

 

刪除 deque 中的除首尾意外的元素都會讓疊代器、參考或者指標失效

指向 vector 或者 string 刪除位置之後的疊代器、參考或者指標會失效

 

容器的 erase() 方法支持傳入疊代器, 可以傳入一個以疊代器為代表的元素範圍, 最後回傳刪除元素之後位置的疊代器

 

forward_list 與普通容器不同, 它具有 before_begin()cbefore_begin() 方法, 用於回傳第一個元素之前的位置 (實際上 forward_list 是一個帶有頭部結點的單向鏈結串列, 這兩個方法回傳的就是這個結點), 但是這不是真正的容器元素. 其 insert_after()emplace_after()erase_after() 方法用法與其它容器對應的 insert()emplace()earse() 方法是相似的. 不過, 之前容器的對應方法都是在傳入的疊代器這個位置開始操作, 並且插入位置之後的元素會跟在插入位置的後面, 刪除的也是對應位置的元素. 而 forward_list 則是在疊代器之後開始操作, 即新元素會跟在插入位置的後面, 刪除操作是刪除疊代器之後的元素

#include <iostream>

#include <forward_list>


using namespace std;


int main(int argc, char *argv[]) {

    forward_list<int> fl {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    fl.erase_after(fl.begin());

    for(const auto c : fl) {

        cout << c << "\t";

    }

    /*輸出結果 :

            1  3  4  5  6  7  8  9  10

     */

}

 

array 之外, 容器可以使用 resize() 方法對容器的大小進行改變. 當增加元素但是沒有傳入新的元素對應的值的時候, 那麼就執行值初始化. 當減少元素的時候, 容器會從後面開始回收. 那麼對於 vectorstringdeque 來說, 減少元素的 resize() 操作可能會讓疊代器、參考和指標失效

 

當容器發生了添加或者刪除元素的操作可能會讓疊代器、指標或者參考失效. 使用失效的疊代器、指標和參考會造成嚴重錯誤

當添加元素之後 :
  1. 對於 vector 或者 string 來說, 當存儲空間重新被分配的時候, 那麼指向容器的所有疊代器、指標和參考都會失效. 如果存儲空間沒有被重新分配, 那麼插入位置之後的疊代器、指標和參考會失效
  2. 對於 deque 來說, 在首尾位置添加元素, 會導致疊代器失效, 指向已經存在的元素的引用和指標不會失效
  3. 對於 listforward_list 來說, 指向容器的疊代器 (包括頭部和尾部結點)、指標和參考都不會失效
當刪除元素之後 :
  1. 對於 listforward_list 來說, 與添加的結果相同, 所有疊代器、指標和參考都不會失效
  2. 對於 deque 來說, 刪除除了首尾之外的元素會使指向其它元素的疊代器、參考和指標失效. 如果是刪除尾部元素, 那麼尾後疊代器也會一起失效, 但是其它不受影響. 如果刪除首部元素, 其它不受影響
  3. 對於 vectorstring 來說, 指向被刪除元素之前的疊代器、引用和參考仍然有效. 當刪除元素時, 尾部後的疊代器總是會失效

因為添加或者刪除操作會造成疊代器失效, 那麼在循環中, 應該即時更新疊代器. 這也就是 insert()erase() 方法回傳新的疊代器的原因. 我們可以依靠 insert()erase() 方法回傳的疊代器進行更新

不要嘗試保存 end() 方法回傳的疊代器, 因為部分容器的插入或者刪除操作都會使原來 end() 方法回傳的疊代器失效 (特別是針對 dequestring 或者 vector)

 

每次 vectorstring 被分配空間之後, 都會比實際的使用空間大. 當空間滿之後, 再次插入元素, 會讓容器重新進行分配空間, 申請更大的空間並且搬移元素, 並且釋放掉舊的空間

 

vectorstring 中有兩個方法, capacity() 方法會回傳容器在不重新分配空間的情況之下還可以容納多少個元素; 向 reserve() 方法傳入引數告訴容器至少應該有多少個空間

 

shrink_to_fit() 方法適用於 vectorstringdeque, 回收多餘的空間, 讓使用的空間與分配的空間一樣. 但是此方法只是發送一個請求, 並不保證空間一定可以被回收

 

reserve() 方法傳入一個比當前空間小的引數並不會讓此方法生效

 

每個 vector 可以有自己的內存分配機制, 但是必須遵守 : 只有迫不得已的時候, 才重新分配空間

 

除去之前介紹的, string 還有三個建構子 :

  • string(const char *, unsigned) : 向 string 中傳入 char * 陣列中的字元, 傳入 n 個, n 可以不填寫. 此時, string 的拷貝在遇到空字元的時候結束
  • string(const string &, unsigned) : 向 string 中傳入 string, 從第 n 個開始拷貝. n 個可以不填寫
  • string(const string &, unsigned, unsigned) : 向 string 中傳入 string, 從第 position 個位置開始, 拷貝 n 個字元

所有操作都要確保陣列或者容器的大小合法, 否則函式的行為將會是未定義的

 

string 提供註標版本的 insert()erase(). 同時, stringinsert()assign() 方法也支援傳入 char * 陣列. insert() 方法也可以接受其它 string

 

stringappend() 方法是向 string 末尾插入

 

string 搜尋操作, 如果無法搜尋到則返回 string::npos (一個 string 類別內部的 static 屬性成員), 值為 -1, 型別為 unsigned. 那麼不建議使用帶號數型別接受這個返回值. 例如, 使用 int 接受, 那麼值將會為 -1; 使用 unsigned 型別接受, 那麼值為 4294967295

 

string 的搜尋, 除了有 find() 方法之外, 還有 :

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

 

string 提供 compare() 方法, 用法與 strcmp() 函式類似

 

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

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

傳入的參數模型為 "+-.0123456789" (十進制)

#include <iostream>

 

using namespace std;

 

int main(int argc, char *argv[]) {

    string s("The price is .23");

    string num("+-.0123456789");

    float price {stof(s.substr(s.find_first_of(num)));        //price = 1.23

}

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

如果 string 不可以被轉換為一個數, 那麼會擲出 invalid_argument 例外情況

如果 string 轉換得到的數值無法用任何型別標示, 那麼擲出 out_of_range 例外情況

 

STL 中定義了三個適配器 : stackqueuepriority_queue, 它們可以接受一個順序容器

deque<int> deq;

satck<int> s(deq);

對於一個既定型別的適配器, 可以接受哪些容器是有限制的 :

  • stack 擁有 puosh_back()pop_back()back() 方法, 因此必須可以訪問最後一個元素. 那麼可以通過除了 arrayforward_list 外的任何順序容器建構 stack
  • queue 擁有 back()push_back()front()push_front() 方法, 那麼只能通過 list 或者 deque 進行建構
  • priority_queue 擁有 front()push_back()pop_back() 方法, 除此之外還要求具有隨機訪問元素的能力, 那麼可以建構於 vector 或者 deque 之上

 

queuepriority_queue 都被定義在 <queue>

priority_queue 是優先佇列 (之前的《資料結構》中是沒有介紹過的, 之後會有介紹). 默認情況下, STL 中使用 "<" 運算子來確定元素的相對優先性

 

10. 泛型演算法

演算法是不依賴於元素型別的操作

演算法不改變底層容器的大小, 但可能會改變容器中保存元素的值, 也可能會移動容器中的元素, 但是不會直接添加或者刪除容器中的元素

哪些只接收一個單一疊代器來表示第二個序列的演算法, 都假定第二個序列至少要和第一個序列一樣長

 

accumulate() 函式的第三個參數是求和的初始值, 傳入的第一個和第二個引數必須支援通過明確轉換或者隱含轉換可以增加到第三個參數上, 即必須支持 "+" 運算元. 例如將第三個參數設定為 const char * 就是錯誤的, 因為 string 支持 "+" 運算元, 但是 const char * 並不支持, 所以應該設定為 string. 不僅僅是 accumulate(), 所有第三個參數與 accumulate() 函式類似的函式都一樣

 

因為演算法並不會執行容器操作, 即演算法不可能主動去改變容器大小, 那麼向容器使用演算法進行寫入操作的時候, 都應該確保序列本身空閒的大小不小於要寫入的元素數目

 

back_inserter 是插入疊代器, 定義在 <iterator>

#include <iostream>

#include <vector>

 

using namespace std;

 

int main(int argc, char *argv[]) {

    vector<int> vec;

    auto it {back_inserter(vec)};

    *it = 1;

}

可以在演算法中傳入插入疊代器來向容器添加元素, 每次插入實際上都是呼叫容器的 push_back() 方法

 

多種演算法都提供 ALG_copy() 的版本 (其中, ALG 為演算法的函式名稱), 即第三個參數改為接收拷貝的容器

 

lambda 表達式 :

[capture list](parameter_list) -> type {};

其中, capture list 是一個 lambda 所在函式中已經被宣告的局部變數列表

lambda 表達式中, 參數不可以有默認值

當演算法中, 有需要傳入函子的函式, 那麼可以使用 lambda 表達式來替代函子

lambda 的捕獲列表, 使用局部變數可以減少 lambda 的參數, 使 lambda 可以傳送給一些只接收一個參數函子的演算法

lambda 只可以捕獲函式內部的局部變數, 如果變數沒有被捕獲, 將會產生編譯錯誤

lambda 可以直接使用定義在函式之外的變數或者局部 static 變數, lambda 的捕獲只針對局部的非 static 變數

當定義 lambda 的時候, 編譯器會聲稱一個與 lambda 對應的新的未命名的類別

被捕獲的變數的值在 lambda 創建時拷貝, 而不是被呼叫的時候才拷貝. 因此, 之後對值進行修改不會影響 lambda 內部的對應變數的值

當捕獲是參考捕獲的時候, 隨後對值的修改會影響 lambda 內部的對應值. 因此, 必須確保在 lambda 執行的時候, 被參考的物件是存在的

可以從一個函式回傳 lambda, 但是 lambda 中不能包含函式內的局部變數的參考

可以讓編譯器根據 lambda 中使用的變數來自動判斷捕獲列表 :

  • 在捕獲列表中寫 "&" 告訴編譯器採用參考捕獲
  • 在捕獲列表中寫 "=" 告訴編譯器採用值捕獲

當混合使用明確捕獲或者隱含捕獲的時候, 捕獲列表的第一個元素必須是 "&" 或者 "="

當隱含捕獲是採用參考的方式, 那麼明確捕獲中不能有參考捕獲; 當隱含捕獲是採用值捕獲的方式, 那麼明確捕獲中不能有值捕獲的方式

當一個變數被 lambda 值捕獲, lambda 不會改變本身. 但是如果希望 lambda 內部可以更改值, 那麼可以加上 mutable 關鍵字

#include <iostream>


using namespace std;


int main(int argc, char *argv[]) {

    auto a {0};

    auto f = [a]() mutable -> int {

        ++a;

        return a;

    };

    cout << f() << endl;        //輸出結果 : 1

}

因為沒有採用參考捕獲, 所以 lambda 中的更改不會影響外部 a 變數的值

如果沒有 mutable 關鍵字, 在 lambda 內部直接對 a 的值進行更改, 那麼將會產生編譯錯誤. 並且, 當被限定 mutable 的時候, lambda 中的函式呼叫符 "()" 不能被省略, 否則也會產生編譯錯誤

如果不給 lambda 指定回傳型別, 那麼 lambda 內部如果包含 return 以外的任何陳述式, 編譯器都將認為 lambda 回傳的是 void 型別. 那麼, 加入 lambda 中包含了 return 陳述式, 並且回傳了具體的物件, 那麼就會產生編譯錯誤

 

bind() 函式被定義在 <functional> 中, 可以將其看做一個通用的函式適配器. 它接收一個可以被呼叫的物件, 將引數繫結至可呼叫物件, 回傳的可呼叫物件可以適配一些函式. 相當於對一些函式進行了包裝

bind() 中可能會包含 "_n", 其中 n 為第 n 個傳入的引數. "_n" 被包含在命名空間 std::placeholders

可以利用 bind() 函式將一些無法傳遞給標準庫演算法的函式傳遞到演算法中

bool check(const string &str, string::size_type size) {

    return str.size() >= size;

}

這個函式是無法傳遞給標準庫函式 find_if() 的, 也就是說

find_if(itBegin, itEnd, check);

這樣的呼叫是錯誤, 因為 find_if() 中傳入的 check 只可以接受一個引數. 那麼, 此時可以使用 bind() 將其修改為 :

find_if(itBegin, itEnd, bind(check, _1, size);

其中, 傳入的 _1 將會被用來替換 check 中的參數 const string &str

 

假定 f 是一個可呼叫的物件, f 具有 5 個 參數, 有 :

auto g {bind(f, a, b, _1, c, _2)};

則 g 是一個含有 2 個參數的可呼叫物件, 兩個參數分別用 _1_2 來表示. 這個新的可呼叫物件將它自己的參數作為 f 的第三個與第五個參數

傳遞給 g 的參數按照位置順序分別綁定給 _n, 即假如有

auto g {bind(f, _2, _3, a, b, _1)];

則呼叫

g(x, y, z);

將會被映射為

f(y, z, a, b, x);

即 "_n" 並不一定要按照順序來宣告

所以, 我們可以使用 "_n" 來重新對參數進行排序

bool check(const string &str1, const string &str2) {

    return str1.size() >= str2.size();

}

check() 函式用於檢查傳入的 str1 的長度是否大於等於 str2 的長度, 可以利用 bind() 來檢查 str2 的長度是否大於等於 str1 的長度 (可能有人會覺得多此一舉. 如果 return 中, 是回傳 str1 是否大於 str2, 那麼確實多此一舉. 但是現在不僅僅是比較大於, 還要判斷雙方是否相等. 結果可能截然不同)

auto g{bind(check, _2, _1)};

假設有 string Astring B, 當這兩個 string 物件被傳入 g() 的時候 :

g(A, B);

相當於

check(B, A);

 

有時候, 有的參數並不支持拷貝操作, 那麼直接使用 bind() 函式就會產生編譯錯誤. 這個時候, 可以使用標準庫函式 ref() 或者 cref(), 一個是將其轉換為參考的形式, 另一個是將其轉換為常數參考

#include <iostream>


using namespace std;

using std::placeholders::_1;


int main(int argc, char *argv[]) {

    auto &os = cout;        //ostream 物件無法被拷貝, 所以只能使用參考或者指標的方式

    auto f {bind([](const string &str, ostream &os) {

        os << str;

    }, _1, ref(os))};

    f("123");       //輸出結果 : 123

}
C++ 學習筆記-Jonny'Blog

2018-04-11 17:12:51 by Jonny, 更新內容 :

插入疊代器總共有三種, 每種都會使用容器對應的方法進行插入, 只有容器支持對應的操作的時候, 才能支持對應的插入疊代器

  • back_inserter : 使用容器的 push_back() 方法
  • front_inserter : 使用容器的 push_front() 方法
  • inserter : 使用容器的 insert() 方法

所有資料流疊代器都沒有遞減的操作

對插入疊代器進行解參考或者遞增操作並不會生效, 每一個操作都會返回疊代器本身

 

IOstream 疊代器有兩種

  • istream_iterator 使用 >> 來讀取資料流
  • ostream_iterator 使用 << 來寫入資料流

創建 istream 疊代器必須明確讀取或者寫入物件的具體型別

istream_iterator<T> NAME(ISTREAM)

其中, ISTREAM 是要繫結的資料流; 當沒有繫結時, 默認是尾後疊代器

 

當一個繫結到資料流的疊代器, 一旦遇到資料流錯誤或者檔案的末尾, 疊代器的的值就會與對應資料流的尾後疊代器相等

#include <iostream>
#include <vector>


using namespace std;


int main(int argc, char *argv[]) {

    vector<int> vec;

    istream_iterator<int> it(cin);

    istream_iterator<int> end;

    while(it != end) {

        vec.push_back(*it++);

    }

}

vector 有個建構子, 接受一個疊代器表示的範圍, 所以上述的代碼可以進行簡化

#include <iostream>

#include <vector>


using namespace std;


int main(int argc, char *argv[]) {

    istream_iterator<int> it(cin);

    istream_iterator<int> end;

    vector<int> vec(it, end);

}

istream_iterator 允許使用懶惰求值 : 當我們將一個 istream_iterator 繫結到一個資料流的時候, 標準庫並不保證疊代器立即從資料流開始讀取資料. 具體實現可以推遲到真正從資料流開始讀取資料開始. 標準庫中的所實現的所保證的是在我們第一次對疊代器進行解參考之前, 從資料流讀取資料的操作已經完成了. 實際上一般來講, 什麼時候讀取並沒有太大的差別, 但是如果我們創建一個 istream_iterator, 並沒有使用就需要進行銷毀或者我們正在從兩個不同的物件同步讀取同一個資料流, 那麼什麼時候讀取就非常重要了

 

創建 ostream 疊代器也需要明確對應的型別

ostream_iterator<T> out(OSTREAM, const char *)

其中, 建構子中的第二個參數是一個可選項, 如果傳入引數, 那麼每一次輸出之後都會打印這個字串

ostream_iterator 中盡管存在 "*" 和 "++" 運算子, 但是並不會對其有實際的效果, 只是返回疊代器本身而已

#include <iostream>

#include <vector>


using namespace std;


int main(int argc, char *argv[]) {

    vector<int> vec {1, 2, 3, 4, 5, 6, 7, 8, 9};

    ostream_iterator<int> out(cout, "\t");

    for(const auto &c : vec) {

        out = c;        //這個陳述式相當於 *out++ = c

    }

    cout << endl;

}

/*輸出結果 :

 1  2  3  4  5  6  7  8  9

*/

標準程式庫中存在 copy() 函式, 接受一個疊代器範圍和另外一個疊代器, 也就是將第一個疊代器拷貝到第二個疊代器上, 所以可以對上述的程式碼進行優化

#include <iostream>

#include <vector>


using namespace std;


int main(int argc, char *argv[]) {

    vector<int> vec {1, 2, 3, 4, 5, 6, 7, 8, 9};

    ostream_iterator<int> out(cout, "\t");

    copy(vec.cbegin(), vec.cend(), out);

    cout << endl;

}

 

任何多載了 ">>" 和 "<<" 運算子的型別都可以被創建資料流疊代器

可向標準程式庫演算法中傳遞反向疊代器來進行逆向操作

 

反向疊代器中具有 base() 方法可以將反向疊代器轉換為普通的疊代器

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

  • 輸入疊代器 : 只可以讀取, 單邊掃描; 運算子 : "==", "!=", "++", "*", "->", "(*)"
  • 輸出疊代器 : 只可以寫入, 單邊掃描; 運算子 : "++", "*"
  • 前向疊代器 : 支持讀取和寫入, 可以多次掃描, 支持遞增
  • 雙向疊代器 : 支持讀取與寫入, 可以多次掃描, 支持遞增與遞減
  • 隨機訪問疊代器 : 支持讀取與寫入, 可以多次掃描, 運算子 : "<", "<=", ">", ">=", "+", "+=", "-", "-=", "[]", "*([])"

加入將疊代器支持的操作歸結為疊代器的能力, 那麼向演算法傳遞相對於演算法函式來說能力比較差的疊代器, 或者傳遞一個錯誤類別的疊代器, 那麼將會產生程式錯誤, 但是很多編譯器並不會給出警告

 

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

只能向一個輸出疊代器指派一次

 

forward_list 上的疊代器就是一個典型的前向疊代器

除了 forward_list 之外, 所有順序容器的疊代器都是一個雙向的疊代器

用於訪問內建陣列的指標、arraydequestringvector 都是一個隨機訪問疊代器

 

C++ 標準程式庫演算法假定 : 向目的位置傳入元素, 不管傳入多少都會被視為安全的

 

大多數演算法都符合如下的形式 :

ALGORITHM(iteratorBegin1, iteratorEnd1, [iteratorBegin2], [iteratorEnd2], ARGS)

其中, 被 [] 包圍的疊代器表示可選的, 因為並不是所有演算法都需要另外一個疊代器或者疊代器範圍; ARGS 一般指需要的額外引數

 

一些演算法可能會以多載的形式來傳入一個函子 : 以後綴增加 "_if" 的形式來用漢字代替最後一個元素; 以後綴增加 "_copy". 來向新疊代器進行拷貝; 有的算法可能以 "_copy_if" 的命名出現

 

通用的 sort() 函式要求傳入的是隨機訪問疊代器, 所以通用的 sort() 是不適用於 listforward_list 容器的. 它們內部有獨有的方法 :

  • sort() : 用 < 或者給定的函子來進行排序
  • merge() : 使用 < 或者給定的函子將傳入的容器與本身進行合併, 並且傳入的容器中的元素將被刪除, 兩個容器必須都是有序的
  • remove() : 使用 "==" 或者給定的函子使用 erase() 方法進行擦出與給定的元素相等的每一個元素
  • unique() : 使用 "==" 或者給定的函子, 並且使用 erase() 刪除同一個值的連續拷貝

所有對於 listforward_list 應該優先使用容器內已經給定的方法, 而不是優先使用標準程式庫中的演算法

除此之外, 鏈結串列型別還定義了 splite() 方法 和 splice_after() 方法

如果傳入的為兩個引數, 那麼會將傳入的容器插入到指定的疊代器之前或者之後的位置, 並且將響應的元素從傳入的容器中刪除

如果傳入的為三個引數, 則傳入容器指定位置開始之後插入到指定疊代器之前或者之後的位置

如果傳入的為四個引數, 則將傳入容器指定範圍內的元素插入到指定疊代器之前或者之後

 

鏈結串列有的操作會對底層進行更改

11.關聯容器

標準程式庫中定義了 8 中關聯容器 :

  • map : 關聯陣列, 一個鍵對應一個值. 被定義在 <map> 標頭檔中
  • set : 關鍵字陣列, 只存保鍵的容器. 被定義在 <set> 標頭檔中
  • multimap : 一個鍵可以對應多個值的關聯陣列. 被定義在 <map> 標頭檔中
  • multiset : 關鍵字可以重複的 set. 被定義在 <set> 標頭檔中
  • unordered_map : 以雜湊表的形式組織的 map. 被定義在 <unordered_map> 標頭檔中
  • unordered_set : 以雜湊表的形式組織的 set. 被定義在 <unordered_set> 標頭檔中
  • unordered_multimap : 以雜湊表形式組織的 multimap. 被定義在 <unordered_map> 標頭檔中
  • unordered_multiset : 以雜湊表形式組織的 multiset. 被定義在 <unordered_set> 標頭檔中

 

map 中存在影射的關係, 與 PHP 中的關聯陣列和 Swift 中的字典非常相似. set 是一個鍵的集合, 想要知道一個鍵是否存在與一個集合當中, 應當優先考慮 set

 

使用 map<INDEX, TYPE> 宣告 map 物件. 其中, INDEX 是一種型別, 在使用的時候作為註標; TYPE 也是一種型別

使用 set<TYPE> 宣告 set 物件. set 中包含了 find() 方法, 當能找到對應的值時, 返回對應值; 否則返回尾後疊代器

 

所有關聯容器的疊代器都是一個雙向疊代器

 

在 C++ 11 新標準下, 可以對關聯容器進行值初始化

set<string> s {"a", "b", "c"};

map<int, string> m{{1, "a"}, {2, "b"}, {3, "c"}};

 

如果向 mapset 重複添加同一個元素, 並不會真正被添加. 如果需要重複的元素, 則需要使用 multimap 或者 multiset

 

在宣告 multiset 的時候, 必須提供兩個型別 : 關鍵字型別和比較操作 (函式指標型別)

加入有類別

class A {

    private:

        int a;

    public:

        A(int a) : a(a) {



        }

}

那麼 class A 則不能被用於宣告 multiset 物件, 因為 A 裡並沒有用於比較的操作型別

我們可以在 A 中多載 "<" 運算子或者在外部定義 A 的比較函式, 以函式指標的形式傳入, 那麼就可以宣告 A 的 multimap

 

一個 pair 保存兩個資料成員. 當創建一個 pair 時, 必須提供兩個型別. pair 被定義在 <utility>

pair 的屬性成員並非私用的, 而是 public 權限. 其中, 兩個成員根據位置, 分別被命名為 firstsecond

map 的每一個元素都是一個 pair 型別的元素

除了基本的運算與建構之外, 標準程式庫中還定義了 make_pair() 函式, 將傳入的兩個引數製造成一個 pair 物件然後回傳, 型別由傳入的引數進行推斷

 

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

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

 

節參考關聯容器的疊代器時, 會得到一個 value_type 型別的值的參考. 對此來說, 成員 first 時被 const 限定的, 所以只能更改成員 second 的值

 

set 容器的疊代器是一個被 const 限定的疊代器, 所以無法對具體的值進行更改

 

對於關聯容器來說, 通常不對其使用所有泛型演算法, 因為不管是 set 還是 map 中的 pair, 對應的 key_value 都是被 const 所限定的. 所以關聯容器只適用於只讀的演算法, 但通常也不會對關聯容器使用泛型搜尋的演算法

實際上, 如果對關聯容器使用泛型演算法, 通常都是將其當作源或者一個目的位置

 

mapset 關聯容器使用 insert() 或者 emplace() 方法添加重複的元素對容器沒有任何影響

在對 map 進行插入的時候, 插入的元素必須是 pair 型別的物件, 可以使用 "{}", make_pair(), pair<>(), map<>::value_type() 進行建構

mapinsert() 方法或者 emplace() 方法若存在回傳值, 那麼回傳的值時一個型別為 pair<map<>::iterator, bool> 型別

 

關聯容器提供了一個接受 key_type 引數的 erase() 方法, 刪除所有與給定的 key_type 相匹配的元素, 並且回傳刪除元素的數量

 

mapunordered_map 都提供陣列註標運算子或者 at() 方法, 而 set() 並不支持. 由於一個註標可能對應多個值, 所以 multimapunordered_multimap 都不支援陣列註標運算子

與普通的陣列註標運算子不同的時候, 如果註標中的關鍵字並不在 map 中, 那麼註標運算子會創建一個元素並插入到 map 中. 如果沒有對應的值, 那麼執行值初始化

所以在 map 中搜尋, 如果使用的是陣列註標操作, 將會得到一個 mapped_type 型別的值. 如果只是想要尋找一個元素是否存在於 map 中, 那麼最好是用 find() 方法; 如果想要查找一個元素在 map 中的出現的次數, 那麼可以使用 count() 方法

陣列註標運算元和 at() 方法只適用於沒有被 const 限定的 map 或者 unordered_map

 

lower_bound() 方法用於回傳第一個鍵不小於傳入的引數的疊代器, 這個方法不適用於無需容器

upper_bound() 方法用於回傳第一個鍵大於傳入的引數的疊代器, 這個方法不適用於無需容器

euqal_range() 方法回傳一個 pair 型別的疊代器, 表示鍵等於傳入的引數的範圍. 其中, first 屬性指向第一個與鍵匹配的元素, second 屬性指向最後一個與鍵匹配的元素

如果一個 multimap 或者 multiset 中有多個鍵相同的元素, 那麼這些元素都會按照序列進行儲存. 由此可以知道, 使用 lower_bound() 方法的回傳值是否等於 upper_bound() 的回傳值來判斷容器中是否存在給定的鍵

 

在鍵沒有明顯序列關係的情況之下, 無需容器非常有用. 即如果鍵型別固有就是無序的, 或者性能測試時發現問題可以使用雜湊技術解決, 那麼就可以使用無需容器

 

存儲在有序關聯容器中的元素在輸出時都會按照字典順序進行排序, 無需容器不一定是這樣

 

無需容器在資料結構上時一組 (Bucket : 雜湊表中存儲資料的位置), 每個桶都會保存元素 (0 個或者多個), 如果容器允許重複鍵, 那麼所有鍵相同的元素都會儲存在一個桶中. 當一個桶保存多個元素的時候, 需要按照順序進行搜尋對應元素

無需容器提供了幾個用來管理桶的方法 :

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

 

默認情況下, 無需容器使用鍵型別的 "==" 進行比較, 並且還使用 hash<T> 型別的物件來完成每個元素的 Hash 值. 我們可以直接定義鍵時內建型別、string 和智慧指標型別的無需容器. 如果我們想要為自己定義的型別來使用無需容器, 那麼必須提供我們自己的 Hash 樣板版本

為了讓我們自定義的型別支持無需容器, 我們可以暫時使用函式來代替 "=="

#include <iostream>

#include <unordered_set>


using namespace std;

struct A {

    string str;

};

size_t hasher(const struct A &a) {

    return hash<string>()(a.str);

}

bool equalOperator(const struct A &a, const struct A &b) {

    return a.str == b.str;

}

int main(int argc, char *argv[]) {

    constexpr int size {10};        //無序容器同的 size

    unordered_multiset<A, decltype(hasher) *, decltype(equalOperator) *> uhh(size, hasher, equalOperator);

}
C++ 學習筆記-Jonny'Blog

2018-05-04 16:12:32 by Jonny, 更新內容 :

12. 動態記憶體

智慧指標可以幫助我們自動釋放指標所指的物件, 在 C++ 11 新標準中, 常用的智慧指標有三種 :

  • shared_ptr : 允許多個指標指向同一個物件
  • unique_ptr : 一個物件只允許一個指標指向
  • weak_ptr : 弱勢連結指標, 指向 shared_ptr 所管理的物件

 

shared_ptruniqe_ptr 都有 get() 方法來獲得保存的指標記憶體位址

 

有些操作時專門為 shared_ptr 而準備的 :

  • make_shared<T>() : 回傳一個 shared_ptr 物件, 指向一個動態配置型別為 T 的物件, 並且使用傳入的引數初始化
  • shared_ptr<T> p(q) : 其中, p 是由 shared_ptr<T> q 複製而來. 此操作會遞增 q 中的計數器, 並且 q 中的指標必須可以轉型為 T *
  • p = q : 其中, p 和 q 都是 shared_ptr, 並且 p 與 q 所保存的指標都是可以相互轉型的. 此操作會遞減 p 中的計數器, 遞增 q 中的計數器

 

若一個 shared_ptr 物件中的參考計數器變為 0, 那麼其管理的記憶體就會被自動回收

 

shared_ptr 中有兩個方法來判斷一個共享物件的智慧指標數量 :

  • use_count() : 回傳參考計數器中的數量
  • unique() : 判斷是否獨佔

 

最安全的配置和使用動態記憶體的方法就是呼叫 make_shared<T>() 函式. 其傳入的惡引數必須符合自訂型別某個可見的建構子

 

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

當一個 shared_ptr 被銷毀或者被指派一個新的值的時候, 其參考計數器會遞減

 

有如下程式碼 :

#include <iostream>


using namespace std;

int main(int argc, char *argv[]) {

    auto p {new int {4}};

    auto q {p};

    delete p;

    cout << q << endl << *q << endl;        //未定行為

}

當 q 指向 p 指向的位址之後, p 隨即被釋放. 此時再對 q 進行操作屬於未定行為. 不同的編譯器可能會給出不同的解決方案

為了解決這個未定行為, 我們可以採用 shared_ptr 來替代內建指標

#include <iostream>


using namespace std;

int main(int argc, char *argv[]) {

    shared_ptr<int> p;

    {

        auto q {make_shared<int>(1)};

        p = q;

    }

    cout << *p << endl;

}

即使 q 在作用範圍之外, 已經被銷毀, 但是 shared_ptr 仍然會保留其記憶體不被釋放, 因為此時參考計數器並不為 0

 

在 C++ 11 新標準下, 可以使用列表初始化的方式來初始化一個動態配置的物件 :

auto p {new int {1}};

在 C++ 11 新標準下, 即使使用 "()" 包圍的初始化也可以使用 auto 來進行型別推斷. 但是只能有一個引數來初始化. 這樣的初始化方式不支援使用 "{}" 進行初始化

auto p {new auto(1)};        //p 為 int * 型別

auto pc {new auto("")};        //pc 為 const char * 型別

同樣支援使用 new 來配置 const 物件. 與其它 const 物件相似, 一個動態配置的 const 物件必須進行初始化 :

const auto pc {new const auto(1)};        //pc 為 const int * 型別

 

在記憶體耗盡的情況之後, new 表達式會拋擲 bad_alloc 例外. 但是可以通過增添 std::nothrow 來限定 new 表達式阻止例外情況被擲出 :

auto p {new (std::nothrow) auto(1)};        //如果配置失敗, 那麼 p 自動被指派為 nullptr

bad_allocnothrow 被定義在 <new> 標頭檔中

 

釋放一塊並非由 new 配置的記憶體空間的行為是未定的, 在 Clang 下會產生編譯錯誤

將相同的指標釋放多次的行為是未定的, 在 Clang 下並不會產生編譯錯誤. 但是在運行的時候, 會產生意想不到的情況, 例如程式意外終結

 

一個被 const 限定的物件值雖然不可以被變更, 但是是可以被釋放的

 

對於一個由內建指標管理的動態物件, 直到被明確釋放之前, 都會一直存在於記憶體中, 這導致了 C++ 的動態記憶體管理異常困難 :

  • 忘記 delete 指標歸還記憶體空間
  • 使用已經被 delete 的記憶體空間
  • 同一個指標或者記憶體位址被 delete 多次

 

void func() {

    auto a {new int};

}

盡管到最後 func() 函式已經終結, 但是 a 的記憶體空間並未正確歸還, 還殘留在記憶體當中. 我們需要在 func() 函式中不再用到 a 後, 就將 a 進行釋放   將一個指標 delete 之後設定為 nullptr 只是提供了有限的保護 :

void func() {

    auto a {new int};

    auto p {a};

    delete a;

    a = nullptr;        //此時, a 被設為 nullptr, 但是 p 還是指向那塊已經釋放的地址. 除非保證 p 100% 不再被使用才可以保證這段程式碼足夠安全

}

 

接受指標引數的智慧指標的建構子是 explicit 的, 所以不可以直接像配置動態記憶體那樣 :

shared_ptr<int> p(new int {1024});

shared_ptr<int> p(u);        //u 可以是一個 unique_ptr 型別的智慧指標或內建的指標型別, p 從 u 那裡接管物件, 並且 u 直接會被滯空

shared_ptr 中的 reset() 方法傳入一個新的指標引數來更新 shared_ptr 中的指標, 如果此 shared_ptr 指向的物件唯一, 那麼還會銷毀那個物件. 因此, 如果我們本意上並不想要銷毀的話, 應該使用 unique() 方法檢查

 

我們不應將智慧指標和普通指標混合使用 :

#include <iostream>


using namespace std;


void process(shared_ptr<int> p) {



}

int main(int argc, char *argv[]) {

    auto p {new int {1}};

    process(shared_ptr<int>(p));

    delete p;       //產生錯誤, p 已經被釋放, 相當於一個指標被釋放兩次

}

因為暫時不清楚智慧指標的實作原理, 所以我們可能對於智慧指標中保存的內置指標的釋放並不清楚, 這導致了我們無法知道它們什麼時候會被回收, 所以混合使用兩者是一種危險的行為

 

智慧指標中具有 get() 方法, 回傳一個內建指標型別, 指向這個智慧指標所管理的物件

使用 get() 方法獲得的指標不能使用 delete 進行回收, 否則會造成同一段記憶體被回收兩次; 也不應該使用 get() 方法回傳的指標初始化另一個智慧指標或者指派給另外一個智慧指標

如果使用了 get() 方法回傳的指標, 那麼應當注意在最後一個對應的智慧指標被銷毀之後, 保存內建指標的變數將會失效, 如果再次使用將會產生未定行為

 

如果某段程式碼由 C 和 C++ 共享, 那麼可能不存在解構子, 那麼也就會造成指標無法被正確釋放. 此時, 當我們創建 shared_ptr 物件的時候, 可以向其中傳入函子或者 lambda 來告訴 shared_ptr 需要使用我們自訂的方法來解構 :

#include <iostream>


using namespace std;


struct s {

    void *ptr;

};


int main(int argc, char *argv[]) {

    struct s a;

    shared_ptr<s> p(&a, [](s *a) -> void {

        delete a->ptr;

    });     //當 p 被銷毀的時候, 並不是直接 delete p, 而是使用傳入的函子或者 lambda 進行銷毀

}

我們可以看到上述的智慧指標並非使用 new 進行配置的記憶體, 所以智慧指標指向的物件並非使用 new 進行配置記憶體的時候, 我們很有可能要對其釋放的方式進行自訂, 也就是傳入一個額外的 lambda 或者函子

 

在 C++ 11 中並沒有類似於 make_shared() 這樣的標準程式庫函式回傳 unique_ptr 物件, 所以在宣告 unique_ptr 物件的時候, 我們需要使用 new 進行記憶體配置

unique_ptr 並不支持普通的複製或者指派的操作

可以給一個 uniqe_ptr 型別指標 nullptr, 如果這個 unique_ptr 中原來有指向一個物件的話, 那麼對應的記憶體會被釋放. unqiue_ptr 中的 release() 方法也執行類似的操作, 並且 release() 方法會回傳一個內建指標

雖然 unique_ptr 並不支援複製或者指派, 但是我們可以通過 unique_ptr 中的 reset() 方法傳入非 const 限定的 unique_ptr 來轉移控制權

不能複製 unique_ptr 的規則有一個例外 : 可以複製或者指派一個將要被銷毀的 unique_ptr. 這就說明我們可以通過函式回傳一個 unique_ptr. 此時, 編譯器知道將要回傳的是一個即將被銷毀的 unique_ptr, 會執行一段特殊的複製

 

在 C++ 較早版本的 STL 中還包含了一個名為 auto_ptr 的智慧指標, 因為相容性的原因, auto_ptr 現在仍然可以被使用. 它的行為與 uniqe_ptr 比較類型, 具有 unique_ptr 的部分特性. 但是容器中不可以保存 auto_ptr, 也不可以從函式最後回傳 auto_ptr. 所以我們應該儘量使用 unique_ptr 而非 auto_ptr

 

unique_ptr  和 shared_ptr 的記憶體釋放管理方式是不同的, 我們必須明確地在樣板參數中指定一個記憶體釋放器的型別 :

unique_ptr<object, deleteType> p(new object, deleteFunction);

即當我們傳入函子或者 lambda 的時候, 還要明確指定它的型別. 此時可以直接使用 decltype 進行型別推斷

 

weak_ptr 是一種不控制所指向物件生存週期的智慧指標, 它指向一個由 shared_ptr 管理的物件, 將一個 weak_ptr 繫結到 shared_ptr 上並不會遞增 shared_ptr 中的計數器. 即使一個物件被 weark_ptr 所指向, 但是計數器為 0 時, 也會銷毀

可以將 shared_ptr 或者 weak_ptr 指派一個 weak_ptr, 指派之後, 雙方共享物件

weak_ptr 中由這樣三種方法 :

  • use_count() : 與其共享的 shared_ptr 物件的數量
  • expired() : 其 use_count() 方法回傳 0, 則為 true; 否則, 為 false
  • lock() : 其 expired() 方法回傳 true, 那麼回傳一個空的 shared_ptr; 否則, 回傳一個指向對應物件的 shared_ptr

當創建一個 weark_ptr 的時候, 要使用一個 shared_ptr 進行初始化

當想要訪問一個 weak_ptr 的時候, 所指向的物件可能已經被銷毀, 所以使用之前, 我們有必要進行檢測 :

#include <iostream>


using namespace std;


int main(int argc, char *argv[]) {

    weak_ptr<int> wp(make_shared<int>(1));      //此處建構的 shared_ptr 只是一個臨時物件, 在建構終結傳給 weak_ptr 之後就會被回收

    if(/*shared_ptr<int> tempSP = */wp.lock()) {        //此處不可以使用括號進行初始化, 只可以用 "=" 指派運算子

        cout << "OK" << endl;

    }else {

        cout << "ERROR" << endl;

    }       //輸出結果 : ERROR

}

如果在上述程式碼中將 wp 指派給一個 shared_ptr, 將會擲出 bad_weak_ptr 例外情況

 

默認情況下, new 配置的物件都是進行默認初始化

當使用 new 配置記憶體時, 可以明確初始化, 即傳入值, 在最後跟一對括號

string *p = new string[10]();        //10 個空的 string

int *pInt = new int[5] {1, 2, 3, 4, 5};

如果括號中的元素數量大於實際的陣列, 那麼表達式將會失敗, 並且並不會配置任何記憶體, 同時擲出 bad_array_new_length 的例外情況, 這些型別被定義在 <new> 標頭檔中

動態配置一個空的陣列是被允許的 :

auto p {new int[0]};

相當於 p 是一個末尾之後的一個疊代器. 可以給這個指標加上或者減去字面值為 0 的物件, 例如 nullptr. 但是對於指標本身, 不可以對其進行解參考, 因為指標本身並不指向任何物件

在對指標陣列進行釋放的時候, 記憶體是逆向回收的

 

unique_ptr 中由單獨管理陣列的一個版本 :

unique_ptr<int []> up(new int [N]);

當一個 unique_ptr 指向陣列時, 不可以使用 "." 和 "->" 運算子, 因為這些運算子都是無意義的. 但是可以使用 "[]" 訪問陣列中的資料, 但是這種訪問方式只限於動態的陣列

shared_ptr 並不支援直接管理陣列, 如果要使用 shared_ptr, 我們就需要自己傳入一個記憶體釋放的函子或者 lambda. 如果沒有提供對應的記憶體回收機制, 最終將會導致未定行為

 

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

  • allocator<T> a;        //定義 allocator 物件
  • a.allocate(N);        //配置 N 個記憶體, 但是不進行建構. 方法回傳一個指標, 指向配置的第一個記憶體位址
  • a.deallocate(pa, N);        //釋放記憶體, N 必須時分配時的個數; pa 必須是 allocate() 方法回傳的指標. 在釋放之前, 必須使用 destroy() 方法進行解構
  • a.destroy(pa);        //對 pa 指向的物件進行解構, 但是不回收記憶體
  • a.construct(pa, args...);        //對 pa 指向的記憶體進行建構, args 是建構的引數, 這個引數與 pa 對應的物件型別的建構子相對

在未建構的情況之下, 直接使用這段記憶體會造成未定行為

標準程式庫為 allocator 提供了一組伴隨演算法, 可以在未初始化的記憶體中創建物件, 這些函式將會在給定的記憶體位址中建構物件, 而不是由系統進行配置記憶體 :

  • uninitialized_copy(A, B, M) : 其中, A 和 B 都是疊代器, M 為指定的未建構的記憶體位址, 並且 M 至少不能比疊代器指定的範圍要小
  • uninitialized_copy_n(A, N, M) : 其中 A 為疊代器, N 是元素的數量
  • uninitialized_fill(A, B, T) : 其中 T 為物件, 對 A, B 範圍之內的建構都將由 T 進行複製建構
  • uninitialized_fill_n(A, N, T)
C++ 學習筆記-Jonny'Blog

2018-05-06 00:45:11 by Jonny, 更新內容 :

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

如果一個建構子的第一個參數是類別本身型別的參考 (最好是常數參考, 雖然非常數參考也是可以的), 而且任何額外的參數都帶有預設引數, 那麼可以稱這個建構子為複製建構子

因為複製建構子通常會被隱含地使用, 所以複製建構子通常不應該是 explicit 的, 複製建構子一般會在以下情景被呼叫 :

  • 使用 "=" 宣告物件
  • 將一個物件作為引數傳遞給另一個非參考的參數
  • 從一個回傳型別為非參考的函式回傳一個物件
  • 用 "{}" 列表初始化一個陣列中的元素或者一個聚合類別中的成員
  • 某些類別會對它們所分配的物件進行複製初始化, 例如 STL 中的 vector 中的 insert() 方法

編譯器可以繞過複製建構子或者移動建構子, 直接創建一個物件, 即編譯器允許將

string a = "abc";        //複製初始化

改寫為

string a("abc");        //編譯器略過複製初始化使用普通的建構子進行初始化

即使編譯器略過了拷貝建構子或者移動建構子, 但是對應的拷貝建構子或者移動建構子必須是存在的, 並且可以在類別外被使用

 

在類別中進行運算子多載, 並且過載的運算子為成員函式, 那麼左側運算物件會隱含地被綁定到 this 上. 對於一個二元運算子來說, 右側的運算物件作為明確引數傳遞. 一般來說, 為了與內建的指派運算子保持一致, 指派運算子通常回傳一個指向左側物件的參考

 

在一個解構子中, 首先執行函式內部的陳述式, 最後再按照成員初始化的逆順序進行銷毀成員

 

如果一個類別自訂了解構子, 那麼一般情況下幾乎可以肯定這個類別也需要自訂複製建構子和複製指派運算子

#include <iostream>


using namespace std;

class Foo {

private:

    int *p;

public:

    Foo(int *a) : p(new int(*a)) {

        delete a;

    }

    ~Foo() {

        delete this->p;

    }

};

void func(Foo) {



}

int main(int argc, char *argv[]) {

    Foo a = new int(10);

    func(a);

}        //main 函式終結, 產生錯誤 : 一個指標被釋放兩次

Foo 類別中, 我們自訂了類別的解構子, 讓解構子負責回收記憶體, 但是並沒有自訂複製建構子和複製指派運算子, 這個時候, 編譯器會幫我們合成一個預設的. 但是, 這個預設的並不是我們想要的, 也是導致一個指標被釋放兩次的主要原因

main() 函式中, 將物件 a 傳入 func() 中, 終結的時候, 程式顯示一個記憶體被釋放了兩次

我們並沒有自訂複製建構子, 所以在 a 傳入的時候, 使用的複製建構子是編譯器為我們提供的. 編譯器為我們提供的複製建構子會直接將 a 中的 p 成員的指標直接指派給臨時物件, 並非通過動態分配的方式指派. 因此, 在 func() 函式終結時, 臨時物件需要被釋放, 呼叫了臨時物件中的解構子, 於是第一次釋放了 p 指標指向的記憶體. 此時 main() 函式中的 a 物件中的 p 屬性已經被釋放了, 但是因為 a 物件並沒有被回收, 所以在 main() 函式終結的時候, 需要呼叫 a 中的建構子來回收 a, 因為 p 指向的記憶體已經被回收了, 所以再次釋放這個記憶體位址是一個未定行為. 在 Clang 下, 會直接給出指標會回收兩次的提示

如果要解決這個問題, 我們有兩種解決方案 :

第一種解決方案相對簡單, 但是並不完美, 即將 func() 函式中的參數改為參考的形式 :

void func(Foo &);

這種解決方案其實並不是我們希望的, 我們不能讓我們的類別使用者永遠都用參考的形式, 因為總有一些新人會漏掉

第二種解決方案相對負責, 就是為類別自訂複製建構子和複製指派運算子, 但是這種方法相對於第一種方案更加完美 :

class Foo {

private:

    int *p;

public:

    Foo(int *a) : p(new int(*a)) {

        delete a;

    }

    Foo(const Foo &other) : p(new int {*other.p}) {



    }

    Foo &operator=(const Foo &other) {

        delete this->p;        //被指派之後, 原來的物件就會被解構, 如果不回收記憶體, 會造成泄露

        this->p = new int (*other.p);

        return *this;

    }

    ~Foo() {

        delete this->p;

    }

};

當然還有一種方法就是用指標去替代, 這裏因為要複製的原因, 所以最好是使用 shared_ptr

 

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

#include <iostream>


using namespace std;

class Foo {

private:

    int a;

public:

    constexpr Foo(int a) : a(a) {



    }

    ~Foo() {



    }

};

int main(int argc, char *argv[]) {

    Foo a;

}

上述程式碼是不可以通過編譯的, 因為 Foo 類別中已經存在一個建構子 Foo(int); 所以編譯器不再為我們提供預設合成的版本, 最終導致 main() 函式中 a 物件的宣告產生變異錯誤. 此時, 我們可以通過 = default 來讓編譯器提供預設合成版本, 讓上述程式碼通過編譯

#include <iostream>


using namespace std;

class Foo {

private:

    int a;

public:

    Foo() = default;        //預設合成版本的建構子不可以是 constexpr 的, 並且預設合成版本的建構子無須實作

    constexpr Foo(int a) : a(a) {



    }

    ~Foo() {



    }

};

int main(int argc, char *argv[]) {

    Foo a;

}

 

對於有些類別來說, 複製行為可能沒有任何意義, 甚至有時候還有害, 那麼此時我們需要阻止複製行為的發生. 例如 iostream 類別, 複製會引起多個物件讀取或者寫入相同的 IO 緩衝

C++ 11 新標準中, 可以宣告被刪除的函式. 與 = default 相似, 被刪除的函式可以宣告為 = delete. 但是與 = default 相反的是, 被刪除的函式的宣告會告知編譯器不要再幫我們合成預設的對應函式, 我們不希望定義這些操作

= delete 宣告必須出現在函式第一次被宣告的時候, 並且 = delete 可以指派給任何一個方法, 而 = default 只可以指派給建構子、解構子和複製運算子這一類的集合

在引導函式匹配的過程中, = delete 有時候也可以發揮作用 :

class Foo {

public:

    void func(double) = delete;

    void func(int) {

        

    }

};

int main(int argc, char *argv[]) {

    Foo a;

    a.func(1);      //正確

    a.func(1.2);        //錯誤, 呼叫 Foo::func(double), 這個函式已經被定義為刪除的函式

}

根據 C++ 標準, a.func(1.2); 中的 1.2 在沒有定義 Foo::func(double); 時, 會被轉型為 int 型別與 Foo::func(int); 進行匹配. 而宣告 Foo::func(double); 為被刪除的函式的時候, 是明確告訴編譯器, 在此處指向傳入一個整型的型別, 否則會產生編譯錯誤

 

類別的解構子一般情況下不應該被刪除, 否則類別產生的物件將無法被銷毀. 加入一個類別的解構子被宣告為被刪除的函式, 那麼編譯器將會自動認為該類別內的屬性或者臨時屬性都無法被宣告, 否則將會產生編譯錯誤

對於一個被刪除解構子的類別來說, 可以通過動態配置記憶體的方式來宣告, 但是無法通過 delete 進行釋放記憶體

class Foo {

public:

    ~Foo() = delete;

};

int main(int argc, char *argv[]) {

    Foo a;      //編譯錯誤

    auto b {new Foo};       //正確

    delete b;       //編譯錯誤

}

 

對於某些類別來說, 即使編譯器會幫助我們合成對應的特殊函式, 但是將會將其自動宣告為被刪除的函式 :

  • 類別的解構子是被刪除的或者對外私用的, 那麼類別合成的解構子將會被宣告為刪除的
  • 類別的複製建構子是刪除的或者對外私用的, 那麼類別合成的複製建構子會被宣告為刪除的
  • 類別的複製指派運算子是刪除的或者對外私用的, 或者類別內有被 const 所限定或者參考形式的屬性成員, 那麼合成的複製指派運算子將會被宣告為刪除的
  • 類別內的建構子是刪除的或者對外私用的, 或者類別內有參考形式的成員, 但是它沒有類別內初始化器; 或者內別內有一個被 const 限定的屬性成員, 它沒有類別內初始化器並且其型別並沒有明確地被定義預設建構子, 那麼類別的預設合成建構子將會被宣告為被刪除的

對於上述規則進行一個總結, 實質上, 如果類別內有屬性成員不能被預設建構、複製、指派或者銷毀, 那麼對應的特殊成員函式將會被編譯器自動宣告為被刪除的

在新標準之前, 如果想要將函式設定為被刪除的, 一般通過將其設定為私用的. 對於私用的函式來說, 成員函式或者友誼權限的物件仍然是可以訪問的.  如果連成員函式或者友誼權限的物件都不想讓其訪問, 那麼可以將其宣告在 private 下, 並且不進行實作即可

在新標準下, 我們可以通過將其設定為被刪除的函式而替代將其宣告在 private 下並且不進行實作, 因為宣告在 private 下的函式, 如果試圖呼叫它, 那麼將會產生鏈接錯誤, 而並非編譯錯誤. 鏈接錯誤相對於編譯錯誤來說, 更加難查找

class Foo {

private:

    Foo();

public:

    explicit constexpr Foo(int) {



    }

    void func() {

        Foo a;

    }

};

int main(int argc, char *argv[]) {

    Foo a(1);       //到此, 都可以通過編譯

    a.func();       //產生鏈接錯誤

}

Clang 下是這樣的錯誤 :

Undefined symbols for architecture x86_64:
"Foo::Foo()", referenced from:
Foo::func() in main.cpp.o
        ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
make[3]: *** [CLionDebug] Error 1
make[2]: *** [CMakeFiles/CLionDebug.dir/all] Error 2
make[1]: *** [CMakeFiles/CLionDebug.dir/rule] Error 2
make: *** [CLionDebug] Error 2

 

看起來像值的類別 :

#include <iostream>


using namespace std;


class HasPtr {

private:

    string *ps {nullptr};

    int i;

public:

    HasPtr() = delete;

    HasPtr(const string &s = string()) : ps(new string(s)), i(static_cast<int>(s.length())) {



    }

    HasPtr(const HasPtr &hp) : i(hp.i) {

        auto temp{this->ps};

        this->ps = new string(*hp.ps);

        delete temp;

        /* 錯誤的寫法 */

        /*delete this->ps;

        this->ps = new string(*hp.ps);

        為什麼這一種寫法是錯誤的呢? 如果採用這種方法, 傳入的類別是 this 本身. 那麼, ps 首先會被釋放, 在重新申請的時候會訪問一個已經被回收的記憶體位址, 這是一種未定行為

        以下的複製指派運算子也是同理*/

    }

    HasPtr &operator=(const HasPtr &hp) {

        auto temp {this->ps};

        this->ps = new string(*hp.ps);

        this->i = hp.i;

        delete temp;

        return *this;

    }

    string *getPointer() {

        return this->ps;

    }

    ~HasPtr() {

        delete this->ps;

        this->ps = nullptr;

    }

};

int main(int argc, char *argv[]) {

    HasPtr str {"123"};

    HasPtr strCopy {str};

    cout << str.getPointer() << endl << strCopy.getPointer() << endl;

    /*

     * 輸出結果 :

     *  0x7f9bd5e00060

        0x7f9bd5e00080

     *  即使改變了 strCopy 中的 *ps 的值, 也不會影響 str 中保存的 *ps 的值

     */

}

看起來像指標的類別 :

#include <iostream>


using namespace std;


class HasPtr {

private:

    string *ps {nullptr};

    unsigned *count;

public:

    HasPtr() = delete;

    HasPtr(const string &s = string()) : ps(new string(s)), count(new unsigned {1}) {



    }

    HasPtr(const HasPtr &hp) : ps(hp.ps), count(hp.count) {

        ++*hp.count;

    }

    HasPtr &operator=(const HasPtr &hp) {

        if(!--*this->count) {

            delete this->count;

            delete this->ps;

            this->count = nullptr;

            this->ps = nullptr;

        }

        this->ps = hp.ps;

        ++*hp.count;

        this->count = hp.count;

        return *this;

    }

    ~HasPtr() {

        if(!--*this->count) {

            delete this->count;

            delete this->ps;

            this->count = nullptr;

            this->ps = nullptr;

        }

    }

    string *getPointer() {

        return this->ps;

    }

};

int main(int argc, char *argv[]) {

    HasPtr str {"123"};     //str.count = 1

    cout << str.getPointer() << endl;

    HasPtr strCopy {str};       //str.count = 1, strCopy.count = 1

    cout << str.getPointer() << endl;

    strCopy = HasPtr("aaa");        //str.count = 1, strCopy.count = 1

    /*

     * 輸出結果 :

     *  0x7fad0e402b10
        0x7fad0e402b10

     * 更改了 str 中 *ps 的值也會同時改變 strCopy 中的值

     * 這段程式碼中的 str 與 strCopy 中的 ps 都指向同一個記憶體位置, 但是 main() 函式終結後並未產生多次釋放多一個
記憶體位置的錯誤 : 因為我們在類別內引入了一個計數器 (你可以參考 shared_ptr 的原理)

     * 類別內的屬性指標成員在類別建構的時候會動態配置記憶體, 在類別被複製的時候, 遞增並且將 ps 和 count 的地址複製
給新的物件而不是重新分配記憶體

     * 只有在計數器歸零的時候, 才會釋放對應指標指向的記憶體, 而並不是每一次解構一個類別就立即釋放對應的記憶體

     */

}       //當 main() 函式終結 : str.count = 0, strCopy.count = 0

 

如果類別定義了自己的 swap(), 那麼標準庫中的演算法將優先使用類別內的 swap() 函式; 否則, 將使用標準庫中的 swap() 函式

swap() 函式對於某些類別來說並不是必要, 但是對於分配了資源的類別來說, 定義自己的 swap() 函式可能是一種重要的優化手段 :

#include <iostream>


class Foo {

public:

    friend void swap(Foo &a, Foo &b) {

        using std::swap;

        swap(a.__ATTR, b.__ATTR);       //__ATTR 為類別內的屬性成員

    }

};

注意到上述程式碼中的

using std::swap;

實際上是對標準程式庫中的 swap() 函式的宣告, 表示這個作用範圍之內的某些 swap() 可以使用標準程式庫的 swap() 函式. 對於類別來說, 如果直接使用標準程式庫的 swap() 會產生不必要的臨時物件, 從而導致了資源的浪費, 包括時間的消耗和記憶體的消耗. 因為實際上 swap() 函式並不是交換類別的全部, 而只是交換類別的屬性成員, 所以我們只需要對類別內的屬性成員進行 swap() 即可

而對於 swap() 函式的呼叫, 我們不應在前增加 std::, 因為編譯器會根據 C++ 標準自動尋找最佳匹配的函式. 只要當編譯器無法找到的時候, 此時可以增加 std:: 作用範圍名稱和作用範圍解析運算子

當我們了解 swap() 函式的原理之後, 我們可以創造出一個特別的複製指派運算子 :

class Foo {

    friend void swap(Foo &a, Foo b);

public:

    Foo &operator=(Foo temp) {

        swap(*this, temp);

        return *this;

    }

};

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

 

如果複製指派運算子要執行建構子或者解構子的工作, 那麼最好將公共的部分封裝成一個函式並設定私用權限

 

標準庫 move() 函式用於使用類別的移動建構子. 如果不使用 move() 函式, 一般情況下會使用複製建構子

 

為了支援移動建構的需求, C++ 11 新標準引入了一種新的參考型別 : 右值參考. 即綁定到右值上的參考

 

右值參考宣告子 : &&

 

右值參考有一個重要的特性 : 只能綁定到一個將要銷毀的物件上

 

一個左值參考之可以用左值繫結, 但是一個右值參考可以使用純右值或者一個即將過時的右值 (xvalue) 進行繫結

一個被 const 所限定的左值參考可以使用右值繫結上去, 這樣的行為延長了一個右值的生命週期

一個右值不可以被隱含地被繫結到左值上

 

左值和右值在 C++ 中的區別最明顯的是左值具有持久的狀態, 但是右值的狀態是短暫的. 簡單的區分左值與右值可以用 "&" 運算子. 如果一個值支援取位址運算子, 那麼這個值就是一個左值

一個變數屬於左值, 即使與它繫結的是一個右值

如果要將一個右值直接繫結到左值上會產生編譯錯誤, 但是通過標準程式庫函式 move() 可以將一個右值參考的變數 (這個變數仍然是一個左值) 繫結到一個右值參考的變數上

int &&a {12};        //正確

int &&b {a};        //錯誤, a 是左值, 不可以直接被繫結到右值參考上

int &&c {std::move(a)};        //正確

我們注意到上述代碼中, 使用了 std:: 作用範圍宣告, 因為 move 是一個比較常用的名稱, 所以我們可能經常會用到它, 隨意使用會產生名稱衝突, 所以在使用標準程式庫 move() 函式的時候, 最好在前加上作用範圍宣告

move() 函式在告訴編譯器 : 有一個左值, 但是我們希望這個左值可以像一個右值一樣進行處理. 所以, 呼叫 move() 函式之後, 就意味著除了對 a 指派或者銷毀之外, 我們將可能不會再使用到它. 即我們不會再對 a 的值進行使用 (作出任何假定)

 

與複製建構子相似, 移動建構子的第一個參數也是該類別的一個參考型別, 而且其它參數必須有預設的值. 不過不同於複製建構子的是, 移動建構子的參考是一個右值參考, 而且一般不被 const 所限定, 因為移動建構操作可能會對願物件產生天翻地覆的影響

string 來作為範例 : string 物件在移動之後原物件可能為空. 但是不同的物件對於移動建構的處理有自己不同的方法, 這個主要取決於類別的設計者. 正是因為我們一般不會去仔細查看類別的實作程式碼, 所以一般不對移動後的物件進行使用, 甚至也不會對其值作出任何假定

所有移動建構子必須遵守一個原則 : 願物件被移動之後, 必須保證願物件可以正常被解構或者被指派一個新的值, 即不依賴於目前值的情況之下還可以被安全使用. 儘管我們並不建議程式員使用被移動之後的物件

移動建構子不應該擲出例外情況, 所以我們一般在移動建構子參數列表之後明確指定 noexcept 限定符, 明確告訴編譯器這個過程不可以擲出例外情況. 否則, 如果移動過程中擲出例外情況, 這將導致原物件被改變, 新物件並未完全完成移動, 從而使得兩個物件都不可用

因此, 為了避免因例外情況造成的問題, 除非確保對應的操作肯定不會擲出例外情況, 否則在重新分配的過程中, 一般選擇複製建構子而不是移動建構子

 

移動建構也可以被編譯器合成, 但是有一些情況下, 編譯器並不會自動合成甚至會將其宣告為被刪除的函式 :

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

 

移動操作不會被編譯器隱含地宣告為被刪除的函式

 

移動建構範例 :

class Foo {

private:

    int *a {nullptr};

public:

    constexpr Foo() = default;

    explicit Foo(int num) : a(new int {num}) {

        

    }

    Foo(const Foo &other) : a(new int {*other.a}) {

        

    }

    Foo &operator=(const Foo &other) {

        if(this == &other) {

            return *this;

        }

        this->a = new int {*other.a};

        return *this;

    }

    /* 移動建構子與移動指派運算子 */

    Foo(Foo &&other) noexcept : a(other.a) {

        other.a = nullptr;

    }

    Foo &operator=(Foo &&other) noexcept {

        if(&other == this) {

            return *this;

        }

        delete this->a;

        this->a = other.a;

        other.a = nullptr;

        return *this;

    }

    ~Foo() {

        delete this->a;

        this->a = nullptr;

    }

};

 

一個移動疊代器通過改變給定的疊代器的解參考運算子 "*" 的行為來適配對應的疊代器. 一般來說, 一個疊代器解參考之後將會回傳一個指定資料的左值參考, 但是移動疊代器解參考之後回傳的是一個資料的右值參考. 除此之外, 沒有其他不同的地方

我們可以通過標準程式庫函式 make_move_iterator() 將一個普通的疊代器轉換為一個移動疊代器

將移動疊代器傳遞給一個具有建構操作的演算法意味著演算法中將會使用移動建構的方式來建構新的元素

 

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

 

C++ 中, 允許一個右值物件使用其成員函式, 這造成了一個有趣的現象, 拿 string 來做範例 :

string() + string() = "123";

上述陳述式是正確的, 並且在舊標準中是無法阻止的

而這樣的操作, 並非類別設計者所希望產生的, 所以在 C++ 11 新標準下, 如果希望在自訂設定的類別中阻止這樣的用法, 即希望強制左側的運算物件是一個左值, 那麼可以在參數列表之後放置一個參考限定符 "&" 或者 "&&"

參考限定符限定了一個成員函式只可以被一個左值呼叫還是只可以被一個右值呼叫. 並且參考限定符只可以用於非 static 的成員函式, 而且必須同時出現在宣告與實作中

一個函式可以同時使用 const 限定符和參考限定符進行限定, 但是參考限定符必須跟隨在 const 限定符之後

當一個成員方法要求回傳副件的時候, 對於右值可以直接回傳本身, 但是對於左值只能回傳其拷貝

類似於 const 限定符, 參考限定符也支援函式的多載

#include <iostream>


using namespace std;


class Foo {

public:

    void func() const & {



    }

    void func() & {



    }

    void func() const && {



    }

    void func() && {



    }

};

int main(int argc, char *argv[]) {

    Foo a;

    a.func();       //呼叫 Foo::func() &;

    Foo().func();       //呼叫 Foo::func() &&;

    const Foo b {Foo()};

    b.func();       //呼叫 Foo::func() const &;

    static_cast<const Foo>(Foo()).func();       //呼叫 Foo::func() const &&;

}

const 限定不同的是, 如果我們宣告了兩個或者以上的多載函式, 並且方法具有相同的參數列表, 那麼就必須保持參考限定符的一致, 即要麼全部都在後面增加參考限定符, 要麼全部都不加參考限定符 :

class Foo {

public:

    void func();        //編譯錯誤, 因為另外一個多載並且具有相同參數列表的函式帶有參考限定符, 所以此處也必須要增加參考限定符

    void func() &&;

};

如果要更正上述程式碼的錯誤, 我們需要將其修正為 :

class Foo {

public:

    void func() &;

    void func() &&;

};

即可正確通過編譯

C++ 學習筆記-Jonny'Blog

2018-05-06 16:45:53 by Jonny, 更新內容 :

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

多載運算子函式的參數數量與運算子對應的運算物件一樣多. 例如 : 一元運算子只能有一個參數, 二元運算子只能由兩個參數. 左邊的運算物件會傳遞給第一個參數, 右邊的運算物件會傳遞給第二個參數

除了函式呼叫運算子 "()" 之外, 其它運算子對應的多載函式參數列表中不可以有預設引數

如果一個運算子是成員方法, 那麼第一個運算物件隱含地被綁定到 this 指標上. 因此, 運算子多載成員函式的顯示參數比實際的運算子運算物件少一個

對於一個多載運算子來說, 它或者是類別的成員函式, 或者參數列表中至少有一個本類別型別的參數. 這就意味著當運算子作用於內建型別的時候, 我們無法改變這個運算子本身的含義

我們只能對已經存在的運算子進行多載, 而無法發明新的運算子

對於一個被多載的運算子來說, 其優先級和結合律和對應的內建運算子是保持一致的

 

可以被多載的運算子 :

+

-

*

/

%

^

&

|

~

!

,

=

<

>

<=

>=

++

--

<<

>>

==

!=

&&

||

+=

-=

/=

%=

^=

&=

|=

*=

<<=

>>=

[]

()

->

->*

new

new[]

delete

delete[]

不可以被多載的運算子 :

::

.*

?:

sizeof

typeid

static_cast

dynamic_cast

const_cast

reinterpret_cast

 

可以通過明確呼叫多載的運算子函式 :

string() + string();        //普通表達式, 隱含呼叫

operator+(string(), string());        //等價的函式呼叫, 運算子是友誼函式

string().operator+(string());        //等價的函式呼叫, 運算子是成員函式

stringPointer->operator+(string());        //等價的函式呼叫, 運算子是成員函式, 呼叫物件是指標型別

 

即使有些運算子支援被多載, 但是多載可能會捨棄其某些屬性, 所以以下運算子不建議進行多載

&

這些運算子運算規則在多載之後無法被保留

|

,

&&

這些運算子的短路運算屬性在多載後同樣無法被保留

||

&

C++ 定義了 & 用於類別物件的時候的特殊含義

 

對於多載的運算子是否為需要為成員函式, 以下準則可以幫助我們作出判斷 :

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

當對 "<<" 運算子進行多載的時候, 通常第一個參數都是非常數 ostream 的參考, 第二個參數通常是想要影印的類別的型別的常數參考, 並且回傳的型別一般是 ostream 的參數的參考. ">>" 運算子也是如此, 所以一般都不是成員函式

但是比較特別的是, 輸入資料流可能會產生失敗的情況. 所以, 需要用到條件判斷陳述式對資料流的狀態進行判定並且給出對應的處理方式. 如果發生讀取失敗的情況, 對應的值將會是未定的, 我們應該在處理後設定資料流的條件狀態以標示失敗的訊息

 

一般情況下, 算數與關係運算子是非成員函式, 參數都是常數參考

 

如果類別定義了算數運算子, 那麼應該同時定義複合指派運算子

如果類別定義了 "==" 運算子, 那麼同時也應該定義 "!=" 運算子, 並且其中一個運算子應該由另外一個運算子來實現. 比較運算子亦是如此

通常情況下, 關係運算子應該滿足 :

  • 定義順序關係, 令其與關聯容器中的鍵保持一致
  • 如果類別中同時具有 "==" 運算子, 那麼定義的關係應該與 "==" 保持一致

如果一個類別存在唯一一種邏輯可靠的關係, 那麼就應該考慮為這個類別定義關係運算子. 如果這個類別同時還包括了 "==", 那麼若且唯若關係運算子產生的結果與 "==" 運算子的結果保持一致的時候, 才定義關係運算子

 

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

 

定義遞增或者遞減運算子的時候, 應該同時定義前置版本與後置版本, 為了與內建版本保持一致, 所以前置版本應該回傳物件遞增或者遞減之後的參考

在區分前置版本和後置版本時, 通常會在後置版本的參數中增加一個 int 型別的參數, 這個參數通常不會被使用. 編譯器會自動為其提供值為 0 的引數. 參數在此只是起到區分前置和後置版本的作用, 並不會參與實際的運算. 語法上, 後置的運算子是使用這個參數的, 但是一般不會這麼做

後置的遞增或者遞減運算子應該回傳原值的拷貝而不是參考的形式, 實作的時候可以宣告一個額外的臨時物件作為資料的備份, 使用前置的遞增或者遞減版本, 最終回傳這個臨時的物件

因為不會用到 int 型別的這個參數, 所以我們可以無需對其進行命名

在明確呼叫的時候, 應該對後置的版本傳入一個 int 型別的參數或者可以轉型為 int 型別的參數 :

obj.operator++();        //呼叫前置版本

obj.operator++(0);        //呼叫後置版本

儘管傳入的值不會被使用, 但是想要呼叫後置的版本, 這個值必不可少

 

一般來說, 定義了 "*" 運算子的類別同時也會定義 "->" 運算子, 而 "->" 運算子一般委託給 "*" 來完成任務, 就好像委託建構子一樣. 這兩個運算子一般情況下不改變物件的狀態, 所以可以實作為常數的成員函式

"->" 運算子在定義的時候不可以拋棄其成員訪問這一個基本的含義, 也就是說我們只能通過多載來改變其獲取哪個物件成員, 而不能賦予其它額外的全新意義

就好像 O->F 這樣的惡陳述式來說, C 必須是一個類別的指標或者一個多載了 "->" 運算子的類別, A 必須是這個類別中的其中一個成員. 即多載了 "->" 運算子必須回傳類別的指標或者多載了 "->" 運算子的類別物件

 

如果一個類別定義了 "()" 運算子, 那麼可以稱這個類別為函式物件

函式物件通常被用於泛型演算法中的作為引數傳入

對於 lambda 表達式來說, 編譯器會在編譯的時候將其轉換為一個無名稱的物件, 並且這個物件中多載了 "()" 運算子. 默認情況下, lambda 不會改變捕獲列表中的變數, 那麼多載的運算子就是一個 const 的成員函式

當 lambda 通過參考捕獲變數的時候, 編譯器就直接會直接引用而無需在 lambda 中產生的類別中將其存儲為屬性成員. 而通過捕獲的方式, 編譯器需要為其建立一個對應的屬性成員, 並且同時建立建構子

lambda 表達式產生的類別不含預設的建構子、指派運算子和預設的解構子. 它是否需要含有預設的複製建構中或者移動建構子通常需要以捕獲的資料成員的型別而定

 

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

plus<T>

+

minus<T>

-

multiplies<T>

*

divides<T>

/

modules

%

negate<T>

-

equal_to<T>

==

not_equal_to<T>

!=

greater<T>

>

greater_equal<T>

>=

less<T>

<

less_equal<T>

<=

logical_and<T>

&&

logical_or<T>

||

logical_not<T>

!

範例 :

#include <iostream>


using namespace std;


int main(int argc, char *argv[]) {

    plus<int> p;

    int sum {p(1, 2)};      //sum = 3

}

 

表示運算子的函式物件通常被用在泛型演算法中替換預設的運算子

例如通常情況下, sort() 函式時使用 "<" 運算子來進行排序的, 而我們可以通過傳入第三個參數, 傳入 greater<T> 該讓其使用 ">" 運算子來進行排序

標準程式庫規定函式對於指標同樣適用. 通常情況下, 對於指標的比較是一個未定行為. 但是通過標準程式庫的函式物件來進行比較就不是一個未定行為 :

#include <iostream>

#include <vector>


using namespace std;


int main(int argc, char *argv[]) {

    vector<int *> vec;

    sort(vec.begin(), vec.end(), less<>());

    /*sort(vec.begin(), vec.end(), [](const int *const a, const int *const b) -> bool {

        return a < b;

    });     //直接比較兩個指標是一個未定行為*/

}

 

C++ 中具有集中可呼叫的型別 : 函式、函式指標、lambda 表達式、bind() 創建物件以及多載了 "()" 運算子的類別或者結構體. 每一個可呼叫的物件都有對應的型別, 但是不同型別的可呼叫物件可能都具有相同的呼叫形式 :

#include <iostream>


using namespace std;

using std::placeholders::_1;


int func(int) {

    return 1;

}

auto f = [](int) -> int {

    return 1;

};

struct s {

    int operator()(int) {

        return 1;

    }

};

auto (*fp)(int) {func};

auto b {bind(f, _1)};


int main(int argc, char *argv[]) {

    auto c {s()};

    cout << func(0) << f(0) << c(0) << fp(0) << b(0) << endl;       //輸出結果 : 11111

}

以上所有可呼叫物件都有一個共同的呼叫形式 :

int(int);

其中第一個 int 是可呼叫物件的回傳型別, 第二個 int 是傳入引數的型別

雖然這些可呼叫物件有共同的呼叫型別, 但是它們可能屬於完全不同的型別

我們可以將上述相同型別的可呼叫物件放入一個陣列中 :

int (*fpArray[])(int) {func, *fp};

但是如果向 fpArray 陣列中放入 f 或者 b, 就會產生編譯錯誤. 因為它們屬於不同的型別

當我們有時候想要建立一個函式表的時候, 使用普通的陣列與普通函式指標型別的 map 射影並不能完成我們這個需求. 此時, 我們可以使用標準程式庫提供的型別 function 來解決這樣的需求. function 被定義在 <functional> 標頭檔中

 

function 中擁有以下操作 :

  • function<T> f(args) : args 是一個參數列表, 可以向 args 中傳入任何可呼叫物件, 也可以為空或者傳入 nullptr 來建構一個空的 function 物件
  • if(function<T> NAME_OF_FUNCTION) : function 物件可放置於條件判斷陳述式當中. 當 function 物件中有可呼叫物件的時候, 回傳 true; 否則, 回傳 false
  • f(parameter_list) : 呼叫 f 中的物件, parameter_list 是 f 中可呼叫物件對應的參數列表

function 中擁有一些型別成員 :

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

對於之前全部回傳 int 型別並且參數也只有一個 int 型別的可呼叫物件來說, 我們可以宣告一個 function<T> 的陣列來存儲它們 :

#include <iostream>


using namespace std;

using std::placeholders::_1;


int func(int) {

    return 1;

}

auto f = [](int) -> int {

    return 1;

};

struct s {

    int operator()(int) {

        return 1;

    }

};

auto (*fp)(int) {func};

auto b {bind(f, _1)};

int main(int argc, char *argv[]) {

    auto c {s()};

    function<int(int)> funcArray[] {func, f, c, fp, b};

    for(const auto &func : funcArray) {

        cout << func(0);

    }

    cout << endl;       //輸出結果 : 11111

}

 

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

 

如果你對舊標準不熟悉, 那麼你可以忽略這個提示 : C++ 11 新標準中, function 類別與舊標準中的unary_functionbinary_function 沒有任何關聯. 後面兩個類別在新標準中已經被更加通用的 bind() 函式所替代

 

我們可以通過定義轉型建構子或者轉型運算子來進行類別的型別轉換

轉型運算子具有如下的一般形式 :

operator T() const;

其中 T 代表某種型別, 並且沒有具體的回傳型別和參數, 它必須是一個成員函式

轉型運算子可以回傳任何可回傳的型別, 也就是說, 除了 void 型別、陣列與函式型別之外, 所有的型別都可以被回傳. 陣列與函式可以轉換為對應的指標型別進行回傳. 雖然 void 型別不被支援回傳, 但是可以回傳 void * 型別

如果轉型的時候, 有改變屬性成員的需求的話, 可以不將其宣告為被 const 限定

儘管編譯器只支援一次執行一個由用戶自訂的型別轉換, 但是隱含的轉型運算子或者轉型建構子可以放在一個內建型別轉型之前或者之後使用

雖然轉型運算子不需要指定回傳型別, 但是實際上每個轉型運算子都會回傳一個對應型別的值

class Foo {

public:

    operator int *() const {

        return 1;       //錯誤, 1 並不是 int * 型別

    }

};

我們不應過度使用轉型運算子. 當類別型別與轉換型別存在不明顯的射影關係的時候 (關係不確定或者一對多的射影), 這樣的轉換是存在誤導性的. 此時, 不應該為這個型別定義這樣的轉型運算子, 而是用一些其它的成員函式替代. 例如 Date 類別中具有 year、month 和 day 三個屬性成員, 其轉型運算子 string() 可以將其轉換成 "year_month_day" 也可以將其轉換成 "day_month, year", 這樣的字串. 你甚至可以將其轉型為距離某天有多少天數這樣的字串

 

C++ 舊標準中, IO 行唄定義了向 void * 轉型的規則, 否則在型別轉換的過程中會出現一些意外情況 :

cin << 1;

以上程式碼可以通過編譯順利運行, 並且結果是一個值為 2 的右值

cin 在上述程式碼中, 被轉型為 bool 型別, 即 0 或者 1, 但是 cin 一般情況之下都是無錯誤的, 也就是向類別設計者自訂的 bool 的轉型一般是 true 的結果. 此時 true 在上述程式碼中, 為了適配 "<<" 運算子, 將會被隱含地轉型為 1, 最後向左移動一個位置, 最終產生一個右值

在 C++ 11 新標準中, 為了避免這樣的情況的發生, 引入了明確轉型運算子 :

explicit operator T() const;

在添加 explicit 限定之後, 編譯器不再進行隱含的型別轉換, 而是要進行明確地指出轉型結果然後進行型別轉換. 但是這種情況有一個例外, 在條件判斷的陳述式當中, 即使不進行明確型別轉換, 但是編譯器還是會幫助我們自動添加對應的轉換, 也就是明確型別轉換會被編譯器自動隱含地執行 :

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

如果類別中包含一個或者多個型別轉換, 那麼必須確保在類別型別和目標型別之間只存在唯一一種轉型, 即不要令兩個類別執行互相的型別轉換, 否則將會產生無限遞回 :

class B;

class A {

public:

    A(const B &);

};

class B {

public:

    operator A() const;

};

void f(const A &);

int main(int argc, char *argv[]) {

    B b;

    //f(b);       //錯誤, 呼叫 A::A(const B &) 還是 B::operator A() const;?

    //儘管可以通過明確呼叫的方式進行呼叫, 但是實際上並不建議這樣做 :

    f(A(b));

    f(b.operator A());

}

 

避免轉型目標是內建算數型別的型別轉換. 即如果已經定義了一個轉型為對應算數型別的轉換之後, 應該儘量不在定義接受算數型別的轉型運算子或者轉換到多種算數型別的型別轉換 :

class Foo {

public:

    Foo(int);

    Foo(double);

};

int main(int argc, char *argv[]) {

    long double v;

    Foo(v);       //錯誤, 其與 Foo::Foo(int); 和 Foo::Foo(double) 都不可以精確匹配, 但是都可以轉型之後進行正
確匹配

}

上述程式碼產生 ambiguous calling 的根本原因在於它們所需要的標準型別轉換的級別是一致的. 如果 v 為 short 型別, 那麼編譯器會將 short 型別提拔到 int 型別從而優先於將 short 型別提拔到 double 型別, 此時將直接呼叫 Foo::Foo(int); 即當我們使用兩個自訂的型別轉換的時候, 如果轉換函式中存在標準型別轉換, 最終將由標準型別轉換來決定函式的匹配

 

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

class A {

public:

    A(int);

};

class B {

public:

    B(int);

};

void f(A);

void f(B);

int main(int argc, char *argv[]) {

    f(10);      //錯誤, 呼叫 void f(A) 還是 void f(B)?

}

在上述程式碼中, 即使將 B::B(int); 更改為 B::B(double); 還是會存在 ambiguous calling 的問題

 

如果對同一個類別提供了轉型目標為算數型別的型別轉換, 也提供了多載的運算子, 那麼將會遇到多載運算子與內建運算子的 ambiguous calling 的問題

class Foo {

    friend Foo operator+(const Foo &, const Foo &);

public:

    Foo() = default;

    Foo(int);

    operator int() const;

};

int main(int argc, char *argv[]) {

    Foo a, b;

    Foo c = a + b;      //使用 A::operator+(const A &, const A &);

    int p = a + 3;      //錯誤, 即可以is hi 用內建的 "+" 呼叫 A::operator int() const; 將 a 轉型為 int 型別, 也可以將 3 轉型為 A 型別, 然後使用 A::operator+(const A &, const b &); 最後使用 A::operator int() const; 將結果轉型為 int 型別指派給 p

}

表達式中運算子的候選函式既應該包括成員函式, 也應該包括非成員函式

C++ 學習筆記-Jonny'Blog

2018-06-01 12:12:45 by Jonny, 更新內容 :

15. 物件導向程式設計

C++ 中, 當使用基礎類別的參考或者指標呼叫一個虛擬函式將發生動態繫結

 

即使基礎類別中的解構子什麼都不做, 仍然應該將其宣告為虛擬函式

 

如果基礎類別將一個函式宣告為 virtual 的, 那麼它在衍生類別中也為隱含地 virtual 函式

如果成員函式未被宣告為 virtual 的, 那麼解析將發生在編譯器而非運作期間

 

若要分離編譯, 那麼衍生類別必須要對基礎類別中的虛擬函式進行宣告, 若未宣告, 那麼直接繼承來自基礎類別中的版本

C++ 11 新標準中允許衍生類別明確地在它使用某個成員函式覆蓋其繼承的虛擬函式的時候, 在參數列表之後、const 限定符之後和參考限定符之後添加 override 關鍵字, 將重寫的繼承虛擬函式的檢查交給編譯器

 

因為在衍生類別中含有與其基礎類別對應的成員, 所以我們能夠將衍生類別的物件當作基礎類別的物件來使用. 我們能將基礎類別的指標或者參考繫結到衍生類別的物件的基礎類別部分上. 這種轉型被稱為衍生類別到基礎類別的型別轉換. 這種轉型與之前的轉型類似, 由編譯器進行隱含地轉換

 

儘管衍生類別中有繼承而來的屬性成員, 但是衍生類別並不能直接對這些屬性成員直接進行初始化, 必須委託基礎類別的建構子來完成初始化. 即每一個類別都由自己來控制屬於自己本身的屬性成員的初始化過程. 衍生類別的建構子會首先進行基礎類別部分的初始化, 之後按照本身的屬性成員的宣告順序進行衍生類別新增成員的初始化 :

struct Base {

    Base(parameter_list);

};

struct Derived : public Base {

    Derived(parameter_list_Derived) : Base(parameter_list);

};

儘管從語法上來說, 我們可以在衍生類別中對基礎類別中繼承而來的可訪問的屬性成員進行指派, 但是最好不要這麼做. 盡量遵守每個類別成員實作自己的介面

 

如果基礎類別宣告了一個靜態的成員, 那麼在整個繼承體系中, 只存在該成員的唯一定義. 並且靜態成員在繼承中遵守通用的訪問控制規則

 

衍生類別的宣告和普通類別的宣告相同, 即衍生類別在宣告的時候無需在其後面添加衍生列表, 否則會產生編譯錯誤

 

如果要將以惡搞類別作為基礎類別, 那麼它必須已經被實作而非僅僅被宣告. 這個規則中包含了一個隱含的意思, 即每一個類別不能衍生其本身

 

如果不想一個類別被其它類別所繼承, 在 C++ 11 中可以在類別後添加 final 限定符

class Foo final {};

class Base {};

class NoDerived final : public Base {};

 

一個基礎類別指標或者參考繫結到一個衍生類別的物件上時, 不能使一個基礎類別向衍生類別轉型 :

class Base {};

class Derived : public Base {};

int main(int argc, char *argvp[]) {

    Derived a;

    Base *b = &a;       //衍生類別向基礎類別轉型

    Derived *c = b;     //錯誤, 基礎類別向衍生類別轉型

    Derived *d = dynamic_cast<Derived *>(b);        //正確, 此時要求 Base 中必須存在虛擬函式, 否則將產生編譯錯誤

}

編譯器在編譯時, 並無法確定某個特定的轉換在運作的時候是否安全, 因為編譯器只能通過檢查指標或者參考的靜態型別來推斷轉型是否合法. 如果上述轉型確實需要, 那麼像上述程式碼一樣, 使用 dynamic_cast 運算子進行轉換. 該轉型將在運作的時候執行安全檢查

 

當我們使用一個衍生類別為一個基礎類別的物件初始化或者指派的時候, 只有衍生類別中的基礎類別部分會被複製、移動或者指派, 它的衍生部分將被忽略

不存在基礎類別向衍生類別的隱含轉型

部分轉型可能會因為訪問權限的問題而變得不可行

有時候, 從衍生類別向基礎類別的明確轉型只針對指標或者參考有效 :

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 a {Derived()};     //錯誤

    Base *b {new Derived()};

}

上述程式碼產生編譯錯誤的原因是基礎類別中的複製建構子和複製指派運算子被刪除了. 如果這些函式並沒有被刪除, 它們被類別的設計者所實作或者被編譯器合成, 那麼上述程式碼就可以通過編譯. 但是, 這個陳述式只會執行物件中基礎類別的部分

 

一般情況下, 當我們不再使用某些函式的時候, 可以無需在當前的檔案下為其宣告或者實作. 但是對於類別中的虛擬函式來說, 無論是否被用到, 都要對其宣告並且實作. 因為動態繫結是發生在運作時期的, 而並不是編譯時期, 在編譯時期是無法進行確認的

 

當通過一個普通型別的物件使用虛擬函式的時候, 在編譯期就可以確定對應的呼叫版本

 

一個衍生類別如果覆蓋了繼承而來的虛擬函式, 那麼參數必須和繼承而來的虛擬函式一樣

 

衍生類別中虛擬函式的回傳型別也必須與基礎類別中的對應虛擬函式相同, 但當基礎類別中的虛擬函式回傳型別是類別本身的參考或者指標的時候, 上述規則不生效, 即 :

class A {

public:

    virtual A &func();

    virtual A *func(int);

};

class B : public A {

public:

    B &func();

    A &func();

    B *func(int);

    A *func(int);

};

上述程式碼中, B 類別中的四個函式, 在回傳型別為參考參考的函式中任意選擇一個, 在回傳型別為指標的函式中任意選擇一個都可以通過編譯. 但是這些同時出現無法通過編譯, 因為會產生名稱衝突

 

C++ 11 新標準允許我們顯示地使用 override 關鍵字說明這個成員函式是從基礎類別繼承過來的, 並且被重寫. 如果編譯器無法發現有對應的可以繼承的虛擬函式, 那麼將會產生編譯錯誤. 這有助於查找錯誤

我們也可以像指定 const 限定符一樣, 為類別內的函式增添 final 限定符, 之後的衍生類別中, 任何嘗試重寫該函式的行為都會產生編譯錯誤

 

如果虛擬函式中含有預設的引數, 那麼基礎類別與衍生類別的參數最好保持一致. 如果預設的引數不一致, 最後由呼叫的對應物件型別決定

#include <iostream>


using namespace std;

class A {

public:

    virtual void func(int a = 1) {

        cout << a << endl;

    }

};

class B : public A {

public:

    void func(int a = 2) override {

        cout << a << endl;

    }

};

int main(int agrc, char *argv[]) {

    A *a {new B};

    a->func();      //輸出 : 1

    B *b {dynamic_cast<B *>(a)};

    b->func();      //輸出 : 2

    delete a;

}

 

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

 

我們可以將一個虛擬函式宣告為純虛擬函式, 即在函式之後增加 "=0", 我們可以為純虛擬函式進行實作, 但是實作的時候必須在類別外部. 當一個類別中存在 "=0" 的純虛擬函式宣告的時候, 此類別將屬於抽象類別, 不能使用抽象類別宣告一個物件. 因為抽象類別只負責宣告介面, 可以使用後續的衍生類別來繼承此抽象類別

class A {

public:

    virtual void func() const = 0;

};

class B : public A {

public:

    void func() const override {}

};

int main(int argc, char *argv[]) {

    A a;        //錯誤, A 類別中存在純虛擬函式, 為抽象基礎類別

    B b;        //正確, 雖然 B 繼承自 A, 但是 B 類別中並不存在純虛擬函式

}

 

在繼承的過程中, 衍生類別中的友誼物件對於基礎類別中的私用成員是沒有訪問特權的 :

class Base {

protected:

    int a;

private:

    int b;


};
class Derived : public Base {

private:

    int j;

    friend void func(Base &b) {

        b.a = 10;

        b.b = 20;

    }


    friend void func(Derived &d) {

        d.j = 10;

        d.a = 20;

    }

};

在上述程式碼的衍生類別中, 有兩個友誼函式. 不過上述程式碼並不可以通過編譯, 因為其中一個友誼函式 friend void func(Base &); 越權訪問了. 儘管 a 被繼承到了 Derived 中, 但是對於友誼函式來說, 它只可以訪問 Derived 中的 a, 而並不能訪問 Base 中的 a, 因為它並不是 Base 類別的友誼函式

 

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

private 成員

protected 成員

public 成員

private 繼承

不會被繼承

會被繼承, 且屬性變成 private

會被繼承, 且屬性變成 private

protected 繼承

不會被繼承

會被繼承, 屬性保持為protected

會被繼承, 且屬性變為protected

public 繼承

不會被繼承

會被繼承, 屬性保持protected

會被繼承, 屬性保持protected

 

只有在 public 繼承的時候, 衍生類別才可以向基礎類別發生型別轉換

不論以什麼樣的方式繼承, 衍生類別的成員函式與友誼物件都可以使衍生類別向基礎類別發生型別轉換

如果繼承的方式為 public 繼承或者 protected 繼承, 那麼衍生類別和友誼物件可以使衍生類別向基礎類別發生型別轉換

即 : 對於繼承體系的某個節點來說, 如果基礎類別的 public 成員對內可以訪問, 那麼可以在內部發生衍生類別向基礎類別的型別轉換; 如果基礎類別的 public 成員對外可以訪問, 那麼可以在外部發生衍生類別向基礎類別的型別轉換; 否則, 轉型無法發生

 

友誼關係並無法被繼承, 即基礎類別中的友誼物件無法隨意訪問其衍生類別中的私用成員

 

若一個衍生類別繼承自一個基礎類別, 而另一個類別是基礎類別的友誼類別, 那麼這個類別可以訪問到衍生類別中從基礎類別繼承過來的私用成員 :

#include <iostream>


using namespace std;


class A {

    friend class C;

protected:

    int a {1};

};

class B : public A {};

class C final {

public:

    void func(const B &b) const {

        cout << b.a << endl;

    }

};
int main(int argc, char *argv[]) {

    C().func(B());      //輸出 : 1

}

 

可以通過 using 宣告來改變繼承自基礎類別中的成員在衍生類別中的對外訪問權限 :

class Base {

public:

    int a;

    char b;

};

class Derived : private Base {

protected:

    using Base::a;

public:

    using Base::b;

};

 

classstruct 支援相互繼承

 

class A {};

struct A;

struct B {};

struct B;

struct C;

class C {};

class D;

struct D {};

int main(int argc, char *argv[]) {

    class A a;

    struct A b;

    class B c;

    struct B d;

    class C e;

    struct C f;

    class D g;

    struct D h;

}

上述程式碼在 Clang 下是可以通過編譯的

 

當存在繼承關係的時候, 衍生類別的作用範圍在基礎類別的作用範圍之內, 即存在如下的作用範圍關係 :

基礎類別 {

    衍生類別 {

        

    }

}

根據衍生類別的作用範圍與基礎類別的作用範圍, 我們無法通過動態繫結的物件訪問基礎類別中沒有的方法, 即 :

class A {};

class B : public A {

public:

    void func() {}

};

int main(int argc, char *argv[]) {

    A *a {new B};

    a->func();      //即使 B 中確實存在 func() 函式, 但是也無法訪問, 並且產生編譯錯誤

    delete a;

}

如果確實有這樣的需求, 我們可以將 A 宣告為抽象類別即可 :

class A {

public:

   virtual void func() = 0;

};

class B : public A {

public:

    void func() override {}

};

int main(int argc, char *argv[]) {

    A *a {new B};

    a->func();

    delete a;

}

與其它名稱作用範圍相同, 如果衍生類別存在與基礎類別相同名稱的成員, 那麼將會覆蓋基礎類別中對應的名稱. 如果需要訪問基礎類別中對應的成員, 那麼需要通過作用範圍運算子 "::" 來訪問

當編譯器在搜尋函式名稱的時候, 一旦找到同名函式, 就不會再繼續搜尋了. 當引數傳入與參數不匹配的時候, 即使外部作用範圍中有匹配的函式, 編譯器也不會繼續進行搜尋匹配, 而是直接產生編譯錯誤 :

#include <iostream>


using namespace std;


void func() {}

int main(int argc, char *argv[]) {

    {

        void func(int);

        func();     //錯誤, 如果想要呼叫外層作用範圍中的 void func(); 函式, 那麼應該將程式碼修改為 ::func();

    }

}

void func(int) {

    cout << "OK" << endl;

}

上述搜尋名稱匹配的規則在類別內部同樣適用

 

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

 

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

 

之前提到, 一個類別一般情況下若需要自訂解構子, 同時也需要自訂複製建構子和複製指派運算子. 但在繼承關係中, 是一個例外. 因為對於動態繫結來說, 衍生類別及其基礎類別都需要自訂解構子, 且解構子都是虛擬函式. 但解構子的內部可能什麼也不做, 即為空. 此時, 可以無需為此類別自動複製建構子和複製指派運算子

繼承關係中的任何一個類別, 若需要移動操作, 在解構子被自訂為虛擬函式的情況下, 都需要自訂

若想要編譯器為衍生類別自動合成對應版本的操作, 那麼需要保證基礎類別中的對應的成員是可以訪問的為而且不是一個被刪除的成員函式

一個基礎類別如果沒有移動操作, 那麼其衍生類別在非自訂的情況之下也是沒有移動操作的

如果一個基礎類別中預設的建構子、複製建構子、複製指派運算子和解構子是被刪除的, 那麼衍生類別中對應的成員也將會是被刪除的

如果一個基礎類別中有解構子是不可訪問的或者是被刪除的, 那麼衍生類別中合成的預設建構子和複製建構子將會是被刪除的

即使我們明確地使用 "= default" 宣告要求編譯器為衍生類別合成移動建構子或者移動指派運算子, 但是衍生類別中如果有成員是不可以被移動的或者繼承類別中的對應操作是不可訪問的或者是被刪除的, 那麼即使明確宣告, 但是編譯器還是會講對應的操作設定為被刪除的. 所以一般情況下, 若基礎類別沒有對應的操作, 那麼衍生類別中也不會特別地自訂這個操作

當我們確實需要基礎類別擁有移動操作的時候, 可以自訂或者使用 "=default" 明確宣告讓編譯器合成. 除非基礎類別中有排斥移動的成員, 否則基礎類別將自動獲得移動操作

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

#include <iostream>


class A {

public:

    A() = default;

    A(const A &) = default;

    A(A &&) = default;

    virtual ~A() = default;

};

class B : public A {

public:

    constexpr B() : A() {}

    B(const B &other) : A(other) {}

    B(B &&other) : A(std::move(other)) {}

    ~B() = default;

};

如果不適用基礎類別中的對應操作, 那麼對應操作完成之後的結果是從基礎類別繼承而來的成員被初始化, 而衍生類別自己的成員是複製或者移動之後的結果

對於指派運算子也是如此, 需要首先通過使用作用範圍運算子 "::" 呼叫基礎類別中的對應指派運算子之後再對衍生類別專有的成員進行指派操作

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

如果建構子或者解構子中呼叫了虛擬函式, 難麼將呼叫這個建構子或者解構子對應型別中的對應虛擬函式

 

在 C++ 11 中, 衍生類別能夠重用其直接基礎類別的建構子

衍生類別可以通過 using 宣告的方式重用其直接基礎類別的建構子 :

class Base {

public:

    Base() = default;

};

class Derived : public Base {

public:

    using Base::Base;

};

對於 Derived 類別來說, 編譯器將自動為其合成屬於 Derived 類別的建構子 :

Derived() : Base() {}

當基礎類別中的建構子具有參數的時候, 編譯器會合成這樣的版本 :

Derived(parameter_list) : Base(parameter_list) {}

如果衍生類別中函優自己的成員, 那麼這些成員會被默認初始化

與普通的 using 宣告不同的是, 對於基礎類別中的建構子在衍生類別中的宣告將不會改變建構子的訪問權限, 不管 using 宣告出現在什麼地方

對於一個 using 宣告來說, 不能為其指定 explicit 或者 constexpr. 若基礎類別中有相對應的屬性限定, 那麼通過 using 宣告將會繼承這樣的屬性

當一個基礎類別的建構子擁有預設引數的時候, 使用 using 宣告的時候, 這些預設引數將不會被繼承. 並且衍生類別將獲得多個建構子, 每個建構子分別省略掉一個函優預設引數的參數, 例如現在有一個基礎類別的建構子 :

Base::Base(int a, char b = 'a');

若其衍生類別 Derived 使用 using 宣告重用基礎類別中的建構子, 那麼 Derived 中將會有 :

Derived::Derived(int a, char b);

Derived::Derived(int a);

兩個這樣的建構子

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

 

在容器與繼承中, 我們通常以指標的形式將某一族的類別放入容器. 若直接宣告為普通的類別容器, 那麼衍生類別將會被 cut 掉. 當我們嘗試實作兩個多載函式試圖將一個類別物件以複製或者移動的形式放入容器而不是通過指標的方式直接放入容器的時候, 不可以直接通過動態配置 new 運算子進行配置. 因為此時分配之後的物件將被複製或者移動, 而複製和移動後的物件是基礎類別型別的, 而不是動態繫結的衍生類別型別的. 所以衍生類別的部分將會被 cut 掉. 此時, 我們需要為繼承關係中的每一個類科定義兩個多載的函式來模擬虛擬複製 :

#include <iostream>

#include <vector>



using namespace std;

class A {

public:

    virtual A *clone() const & {

        return new A(*this);

    }

    virtual A *clone() && {

        return new A(std::move(*this));

    }

};

class B : public A {

public:

    A *clone() const & override {

        return new A(*this);

    }

    A *clone() && override {

        return new A(std::move(*this));

    }

};

void add(vector<shared_ptr<A>> *a, const A &b) {

    a->push_back(shared_ptr<A>(b.clone()));

}

void add(vector<shared_ptr<A>> *a, A &&b) {

    a->push_back(shared_ptr<A>(std::move(b).clone()));

}

int main(int argc, char *argv[]) {

    vector<shared_ptr<A>> a;

    add(&a, B());       //呼叫 void add(vector<shared_ptr<A>> *, A &&);

    auto b {A()};

    add(&a, b);     //呼叫 void add(vector<shared_ptr<A>> *, const A &);

}
C++ 學習筆記-Jonny'Blog

2018-07-21 13:46:09 by Jonny, 更新內容 :

16. 樣板與泛型程式設計

在宣告一個新的樣板, 樣板的參數列表不可以為空

 

除了用型別參數宣告樣板之外, 還可以用非型別參數來宣告樣板. 這些參數必須是一個常數表達式, 它可以是整形型別或者一個指向物件或者函式的指標或左值參考. 指標或者左值參考必須具有靜態生存期. 不能使用一個非 static 的普通局域變數或者動態物件作為指標或者左值參考的非型別參數的引數. 指標可以用字面值為 0 的常數表達式

 

與普通函式相似, 樣板函式也可以宣告為 inline 或者 constexpr 的, 但是需要跟在樣板的參數列表之後

 

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

template <typename T>

bool compare(const T &a, const T &b) {

    return a > b;

}

上述函式一眼看上去並沒有什麼問題, 但是當傳入指標的時候, 就會出現未定行為. 因為比較兩個指標就是一個未定行為. 所以, 我們應該實作一個能夠支援指標的版本 :

template <typename T>

bool compare(const T &a, const T &b) {

    return a - b > 0;

}

或者使用標準程式庫的 std::greater

template <typename T>

bool compare(const T &a, const T &b) {

    return [&]() -> bool {

        greater<T> g;

        return g(a, b);

    }();

}

 

樣板程式應該儘量減少對引數型別的要求

 

與樣板相關的程式碼應該儘量放入標頭檔中

 

在樣板被具現化之前, 必須保證樣板的定義, 包括類別樣板成員的定義都是可視的

template <typename T>

struct s {

    s();        //s 的建構子

};

int main(int argc, char *argv[]) {

    s<int> a;       //產生鏈接錯誤

}

 

當對樣板進行具現化的時候, 編碼器會為其產生一個等價的函式或者類別 :

template <typename T>

class A {};

當宣告 A<int> 物件的時候, 編碼器會生成 :

template <>

class A<int> {};

對函式也是類似的

 

對於樣板類別來說, 如果想把成員函式實作在類別之外, 在實作的時候應該同時宣告樣板以及型別參數 :

template <typename T>

struct A {

    void func();

};

template <typename T>

void A<T>::func() {

    

}

 

若一個樣板函式或者樣板類別 (包括樣板類別中的成員樣板函式), 若未被使用, 則其不會被具現化. 這一特性使得在某種型別不能完全符合樣板的操作要求時, 仍然能對其進行具現化類別

 

預設情況之下, 對於一個類別樣板具現體, 其成員只有在被使用的時候才會被具現化

 

當我們使用一個類別樣板時, 必須提供一個樣板引數的型別. 但是例外的是, 當我們在類別自己的作用範圍之內的時候, 可以不需要提供型別 :

template <typename T>

struct A {

    void func(const A<T> &);

};

在類別內部, 函式可以簡化為 :

template <typename T>

struct A {

    void func(const A &);

};

當成員函式被實作在類別之外的時候, 知道遇到類別名稱的時候, 才表示進入類別的作用範圍

 

當一個類別樣板包含一個非樣板的友誼函式時, 這個友誼成員可以訪問所有樣板具現體. 若友誼成員本身是樣板, 那麼類別可以授權友誼樣板具現體訪問, 也可以只授權部分特定的特質化友誼樣板具現體訪問

樣板與友誼成員最常見的關係是一對一的友誼關係, 即當樣板參數為 T 時, 只有 T 型別對應的類別友誼成員可以訪問類別中的私用成員 :

template <typename> class A;        //類似於函式的宣告中可以省略參數的名稱那樣, 樣板類別在宣告的時候也可以省略樣板參數, 但是樣板函式不可以

template <typename T>

bool operator==(const A<T> &a, const A<T> &b) {

    return a.a == b.a;

}

template <typename T>

class A {

    friend bool operator==(const A &, const A &);

private:

    int a {1};

};

一個類別可以將另一個樣板類別的每一個具現體都宣告為自己的友誼成員, 即不管友誼成員中的樣板參數是什麼 :

class A {

    template <typename>

    friend class B;     //此時, B 類別無需提前宣告

};

一個類別可以將用自己具現化的樣板類別宣告為自己的友誼成員, 即友誼的樣板類別中的樣板參數是自己 :

template <typename> class B;

class A {

    friend class B<A>;

};

一個類別可以將相同具現化的樣板類別宣告為自己的友誼成員, 即自己的樣板參數型別與友誼成員中的樣板參數型別相同 :

template <typename> class B;

template <typename T>

class A {

    friend class B<T>;

};

一個類別可以將以任何行唄具現化的樣板類別物件宣告為一任何型別具現化的自己的友誼成員, 即不管自己的樣板參數是什麼, 也不管友誼成員中的樣板參數是什麼, 任何型別具現化的友誼成員都是自己的友誼成員 :

template <typename T>

class A {

    template <typename U>

    friend class B;

};

C++ 11 新標準下, 可以將自己的樣板參數型別宣告為自己的友誼成員 :

template <typename T>

class A {

    friend T;

};

即使 T 是一個內建型別, 這種友誼關係也是被允許的

 

C++ 11 新標準允許我們為樣板類別宣告一個型別別名 :

template <typename A, typename B, typename C, typename D>

class Foo {};

template <typename T, typename U>

using Bar = Foo <T, T, U, U>;



int main(int argc, char *argv[]) {

    Bar<int, long> b;       //等價於 Foo<int, int, long, long> b;

}

當使用 using 宣告時, 也可以使某幾個樣板參數固定 :

#include <iostream>



using namespace std;



template <typename A, typename B, typename C, typename D>

class Foo {};

template <typename T>

using Bar = Foo <unsigned, T, long, T>;



int main(int argc, char *argv[]) {

    Bar<string> b;       //等價於 Foo<unsigned, string, long, string> b;

}

 

對於樣板類別中的 static 成員來說, 它是被相同型別樣板參數的類別所共享的, 而不是不同型別的同時共享

 

樣板參數遵循一般的作用範圍規則, 即內部作用範圍會覆蓋外部的作用範圍

 

樣板物件在宣告的時候必須包含樣板參數. 樣板在宣告的時候, 樣板參數的名稱不必與在實作時的名稱相同. 一個給定的樣板的每一個宣告必須與實作有相同的數量和種類的參數

 

預設情況下, C++ 假定通過作用範圍運算子訪問的名稱不是一個型別. 若我們希望使用一個樣板型別參數的型別成員, 那麼必須通過 typename 來明確告知編碼器我們正在使用的是一個型別, 不是一個物件成員

template <typename T>

class Foo {

public:

    using const_reference = const T &;

};



template <typename T>

typename Foo<T>::const_reference func(const T &) {      //從這裡開始的作用範圍, 任何用到 Foo<T>::const_reference 型別的地方都需要加上 typename 來宣告這是一個型別

    return new T;

}

當我們向編碼器明確某個型別而非一個物件的時候, 必須使用 typename 而不可以使用 class

 

在 C++ 11 新標準中, 允許為函式與類別樣板的樣板參數提供預設引數. 而早起的標準只允許為類別的樣板參數提供預設引數

與函式的參數類似, 只有當其右側所有樣板參數都有預設引數的時候, 此樣板參數所在的位置才可以擁有預設的引數

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

template <typename T = int>

class Foo {};

int main(int argc, char *argv[]) {

    Foo<> a;        //樣板括號不可以省略

}

 

一個類別中, 若包含了一個成員樣板函式, 則這個函式不可以是虛擬函式

當一個樣板類別中的成員樣板函式被實作在類別之外的時候, 必須同時提供樣板類別的樣板參數列表和成員樣板函式的樣板參數列表 :

template <typename T = int>

class Foo {

public:

    template <typename InputIterator>

    void func(InputIterator begin, InputIterator end);

};

template <typename T>

template <typename InputIterator>


void Foo<T>::func(InputIterator begin, InputIterator end) {

    

}

如果你所看到的那樣, 在類別之外實作函式的時候, 在宣告樣板時無須在後面加上預設參數, 也不可以增加預設參數, 否則會產生編碼錯誤

 

當有多份分離檔案使用了一個樣板具現體時, 編碼器會為每一分檔案都生成一個對應的具現體. 此時, 會帶來額外的編碼開銷, 並且這個開銷可能非常嚴重 :

A.cpp :

template <typename T>

bool isExist(const T &a) {

    return static_cast<bool>(a);

}

B.cpp :

#include "A.cpp"

template <typename T> bool isExist(const T &);

void func() {
    if(isExist(1)) {}

}

C.cpp :

#include "A.cpp"

template <tyepanme T> bool isExist(const T &);

void func(int a) {

    if(isExist(a)) {}

}

此時, 有兩份一樣的具現體被生成 :

template <> bool isExist(const int &);

B.cppC.cpp 中都會存在上述具現體. 但實際上可以不需要為每一份檔案都具現化, 因為兩份檔案可以共用一個具現體. 那麼, 可以只為一份檔案生成, 另外一份檔案直接使用即可

此時, 我們需要用到 C++ 11 新標準中的明確具現化. 即使用明確宣告的方式可以將 B.cppC.cpp 改為 :

B.cpp :

#include "A.hpp"

template bool isExist(const int &);     //具現化

void func() {

    if(isExist(1)) {}

}

C.cpp :

#include "A.hpp"

extern template bool isExist(const int &);      //具現體宣告

void func(int a) {

    if(isExist(a)) {}

}

上述程式碼中, 我們明確地在 B.cpp 中讓編碼器生成對應的樣板具現體, 在 C.cppB.cpp 中生成的相同型別的樣板具現體進行宣告, 明確告訴編碼器在同一個 project 下已經有一份同樣的樣板具現體, 無須在此進行具現化

我們可以總結出, 對於樣板具現體的明確具現化的形式為 :

template DECLARATION_FOR_TEMPLATE;

對於樣板具現體的宣告, 形式為 :

extern template DECLARATION_FOR_TEMPLATE;

對於宣告的樣板具現體而言, 宣告之前必須確定其它檔案中已經確實存在一個相同的具現體, 否則在編碼的時候將會產生鏈接錯誤

對於 B.cpp 中的宣告, 因為之前並沒有一份相同的具現體, 所以編碼器所做的工作是先具現化後宣告, 確保存在一份對應的具現體, 並且在 C.cpp 中沿用. 但是在 C.cpp 的宣告中, 如果之前沒有一份相同的具現體, 就會產生鏈接錯誤

 

當編碼器進行具現化樣板類別的時候, 編碼器並不知道多少成員函式會被呼叫, 所以編碼器會對所有成員函式都進行具現化. 因此, 必須保證所有型別都可以被用於樣板類別的所有成員函式

頂層 const 在樣板函式中可以被忽略. 即可以將非 const 物件的參考或者指標傳遞給一個 const 的參考或者指標參數

陣列與函式在樣板函式中會被轉型為陣列指標或者函式指標

算數型別轉型、衍生類別向基礎類別的轉換以及自訂的轉換都無法用於樣板函式

有時, 編碼可能無法完成樣板參數的推斷 :

template <typename T, typename U>

T add(const U &a, const U &b) {

    return a + b;

};

對於這樣的樣板函式, 直接傳入參數讓編碼器進行推斷可能會出現一些問題, 因為編碼器並不知道如何推斷 T 的型別, 因為可能任何內建的整形型別都可以用於 T, 那麼此時, 我們需要在呼叫的時候, 明確 T 的型別 :

add<long long int>(1, 2);

明確樣板中的型別引數按照從左到右的順序與樣板參數列表匹配. 在可以確定可以推斷出的情況下, 可以忽略右邊的樣板參數

我們應設計出儘量少的傳入型別引數的程式碼

 

對於一個已經明確樣板參數型別的樣板具現體, 允許進行正常的轉型 :

template <typename T>

void func(const T &, const T &);

int main(int argc, char *argv[]) {

    func(1, 1.2);       //錯誤, 推斷出的兩個型別不一致

    func<string>("a", "b");     //正確, 樣板參數的型別為 string. 傳入的引數型別為 const char *, 而樣板參數已經明確, 此時編碼器會幫助我們由 const char * 向 string 進行轉換

}

對於一個未知的回傳型別, 除了使用明確樣板參數之後, 我們還可以通過泛型程式碼使用者的程式碼, 讓編碼器幫助我們進行推斷. 此時, 需要使用尾置回傳型別和 decltype 型別推斷 :

template <typename Iterator>

auto &getReference(Iterator it) -> decltype(*it) {

    return *it;

}

上述函式時希望函式使用者可以傳入一個疊代器, 並且回傳疊代器對應的元素的參考. 解參考運算子回傳的是一個左值, 對左值使用 decltype 的結果是左值參考, 上述程式碼可以讓函式的使用者無需明確樣板參數型別

有時, 我們可能希望函式回傳的是一個值而並不是一個左值參考, 但是我們不但不知道傳入的引數型別, 也同樣無法推斷回傳的型別. 此時, 我們可以使用標準程式庫中的型別轉型樣板. 它們被實作在 <type_traits> 的標頭檔中. 這些型別轉換樣板可以將型別轉換為我們想要的型別, 而且無須知道用戶將會傳入什麼樣的型別 :

Mod<T>, 其中 Mod

T

Mod<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

Jonny 備註 : volatile 將會在之後的章節中講解

每個型別轉型樣板類別中都有一個名為 typepublic 型別成員. 它負責保存完成轉型之後的型別

那麼對於之前回傳一個非左值參考的, 可以使用上述樣板類別來完成 :

template <typename Iterator>

auto &getReference(Iterator it) -> typename std::remove_reference<decltype(*it)>::type {

    return *it;

}

此時, 回傳型別從一個左值變成了右值

除此之外, 我們還有一個方案, 但是這個方案在使用的時候會有所限制 :

template <typename Iterator>

auto &getReference(Iterator it) -> decltype(*it + 0) {

    return *it;

}

此時, 回傳的型別也是一個右值. 但是傳入的型別必須支援 "+" 運算子, 否則將會產生編碼錯誤

 

當我們使用函式指標指向樣板函式時, 我們需要明確樣板參數型別 :

template <typename T>

void func(const T &) {}

void (*fp)(const int &) {func};     //樣板函式指標

int func2(void (*)(const int &)) {return 1;}

int func2(void (*)(const string &)) {return 1;}        //與上面的 func2 多載

int main(int argc, char *argv[]) {

    func2(func);        //錯誤. 編碼器並不知道 func 中的樣板參數 T 是什麼型別

    func2(func<char>);      //錯誤. func2 的多載函式中, 並沒有和 func<char> 匹配的函式參數

    func2(func<int>);       //正確

    func2(func<string>);        //正確

}

當函式參數是一個函式樣板具現體的記憶體位址的時候, 程式碼需要滿足對每個樣板參數, 都能唯一確定其型別或值

 

樣板的參數與引數的繫結遵守正常的繫結規則. 即當傳入左值的時候, 可以繫結到 T & 或者 const T & 上; 當傳入右值的時候, 不可以繫結到 T &, 但是可以繫結到 const T & 或者 T && 上. 樣板也可以根據傳入的值推斷出樣板參數是否為 T &&

 

通常情況下, 我們不可以講一個右值參考繫結到一個左值上 :

void func(int &&);

int main(int argc, char *argv[]) {

    int a {0};

    func(a);        //編碼錯誤

}

但是, C++ 中由兩個例外規則, 使樣板中的這種細節可以被編碼通過 :

template <typename T>

void func(T &&) {}

int main(int argc, char *argv[]) {

    int a {0};

    func(a);        //編碼錯誤

}

在上述程式碼中, T 被推斷為 int & 型別, 而不是 int 型別. 這表示我們雖然無法通過直接繫結的方式使一個右值參考繫結到左值上, 但是可以通過樣板參數或者型別別名的方式進行細節. 上述程式碼隱含了一個例外規則 :

當我們講一個左值傳遞到函式的右值參考參數上時, 且這個右值參考時指向型別別名或者樣板參數的, 那麼編碼器推斷 T 的型別將會是傳入型別的參考, 而不僅僅是傳入型別本身. 即最終為 T & & 或者為 T & &&

當型別被推斷為 T & & 或者 T & && 時會產生一個問題 : C++ 中實際上並不存在 T & && 這種型別, 所以這裡要運用到另外一個例外規則, 參考折疊 :

當間接創建了一個參考的參考型別時, 編碼器會自動幫助我們對型別進行折疊. 即 T & &, T & &&, T && & 將會被折疊為 T &; T && && 將會被折疊為 T &&

這就是為什麼上述程式碼可以通過編碼的原因. 即使 T && 為右值參考, 但是最終還是被編碼器折疊成了左值參考

這兩個規則導致了兩個結果 :

  • 如果一個函式參數時指向樣板型別參數的右值參考, 它可以被繫結到一個左值上
  • 如果一個引數的型別是左值, 被細節到了一個右值參考上, 最終具現體的型別將會是左值參考

這兩個規則暗示了 : 如果一個參數是樣板參數或者型別別名, 我們可以將任意型別的引數傳遞到一個右值參考的參數上

由於上述兩個例外規則的影響, 下面程式碼將會產生一些令人意想不到的效果 :

template <typename T>

void func(T &&a) {

    T temp {a};

    ++temp;     //此處假設型別為 T 都支援前置遞增運算子 "++"

    //此時, 外部的 a 有沒有改變呢?

}

對於程式碼註解中的疑問 :

  • 當用右值呼叫函式的時候, T 的型別將會是 T, 即 temp 的遞增不會影響外部的 a
  • 當用左值呼叫函式的時候, T 的型別將會是 T &, 即 temp 的遞增將會影響外部的 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 成員就是一個普通的非參考型別. 然後通過明確型別轉換, 將 type 成員轉換為對應的右值參考的型別, 最終回傳

這其中, 又包含了一個對於右值參考的一個特例規則 : 儘管無法隱含地從一個左值型別轉換為右值參考型別, 但是可以通過 static_cast 進行明確型別轉換, 將一個左值轉換為右值參考

 

對於某些函式需要將其某些傳入的引數連同型別不變地轉遞給其它函式 :

template <typename F, typename T, typename U>

void func(F f, T t, U u) {

    f(u, t);

}

F 是一個接受參考的函式時 :

void f(int a, int &b);

f 若對 b 進行改變, 那麼會影響到引數 t 地值. 但是實際上, 呼叫 func() 的時候, 並不會改變傳入的引數 t 的值. 此時, t 的型別是 T, 而不是 T &

此時, 將樣板參數實作為 T && 型別, 它不會影響實際傳入引數的型別, 並且可以保持對應的型別使其進行轉遞. 即當一個樣板參數被實作為右值參考的型別, 那麼傳入引數的 const 屬性與左值/右值的屬性在函式傳遞的過程中將得到保持. 但此時, 可呼叫物件 f 必須可以接受右值參考的值, 否則將會產生編碼錯誤. 為了解決這個問題, 我們可以使用標準程式庫函式 std::forward() 來保持型別, 它被定義在名稱空間 std 與標頭檔 <utility>

forward() 函式與 move() 函式相對應, 但是 forward() 函式必須通過明確樣板參數型別的方法來呼叫. forward() 回傳明確樣板型別的右值參考, 即 forward<T>() 將回傳 T &&. 最終, 我們重寫 func() 函式

template <typename F, typename T, typename U>

void func(F f, T &&t, U &&u) {

    f(std::forward<U>(u), std::forward<T>(t));

}

此時, 由於 forward<T>() 回傳了 T && 型別, 在 f() 的呼叫中, U 轉型為 U &&. 當我們傳入的 U 為右值的時候, 會以右值參考的形式傳入可呼叫物件 f 中, 並且通過參考折疊為 U &

 

在函式的多載中, 如果有觸及到函式樣板, 那麼函式的匹配規則將會發生改變 :

  • 對於一個函式的呼叫, 將會包含所有樣板參數推斷成功的樣板函式具現體
  • 可匹配的函式按照型別轉換規則來排除
  • 優先匹配最好的函式/ 如果選中的函式匹配同樣好, 那麼 :
    • 如果匹配的函式中只有一個是非樣板函式, 那麼選擇這個函式
    • 如果匹配的函式中沒有非樣板函式, 且其中一個樣板比其它樣板更加特製化, 那麼選擇這個函式
    • 如果上述兩條都無法匹配, 那麼這個呼叫是 ambiguous 的

正確定義一組多載的樣板函式需要對型別間的關係及樣板函式允許的有限的參數型別轉換有著深刻的理解 :

template <typename T>

void func(const T &);

template <typename T>

void func(T *);

int main(int argc, char *argv[]) {

    int *p {nullptr};

    const int *cp {nullptr};

    func(p);

    func(cp);
}

func() 傳入 p 引數的時候, 編碼器具現化出兩個具現體 :

template <>

void func(const int *&);

template <>

void func(int *);

此時, 明顯第二個函式精確匹配, 於是 func(p) 呼叫第二個函式

func() 傳入 cp 引數的時候, 編碼器也同樣具現化出兩個具現體 :

template <>

void func(const int *&);

template <>

void func(const int *);

對於正常的匹配規則來說, 看起來兩個都是可行的. 但是根據多載樣板函式的特殊規則來說, 具現化為 int * 的具現體更加特製化. 因此, 最終 func(cp) 呼叫第二個函式

現在假設 func 函式是用於輸出傳入的引數的內容, 並且採用 IO 庫中的 "<<" 運算子進行輸出

此時, 我們需要特別當函式接收 char * 的情況. 因為 IO 庫中的 "<<" 運算子對 char * 版本有特別的定義, 它輸出的是陣列具體內容, 而不是記憶體位址. 因為第二個 func 是用於輸出指標對應的記憶體位址, 所以當傳入 char * 的時候它並不符合要求. 因此, 要對 char * 特別處理 (這裡我們為了樣板多載的教學, 不採用之後要講的樣板特製化)

我們能想到最好的方法是使用 C++ 的 string 進行處理, 將 char * 轉型為 string 之後再輸出 :

void func(const char *str) {

    func(string(str));

}

void func(char *str) {

    func(string(str));

}

假定對 string, 我們又宣告了一個新的多載函式

void func(const string &);

此時, 對於樣板函式和非樣板函式, 編碼器會有限匹配非樣板函式. 非樣板函式對於樣板函式來說, 它更加特製化

作為另外一個實例, 我們假定向 func 傳入一個字面值常數字串, 即 :

func("123");

此時, 第一個 func 將會具現化出 :

template <>

void func(const char (&)[]);

第二個 func 將會具現化出 :

template <>

void func(const char *);

同樣地, 對於普通的函式匹配規則來說, 這可能是 ambiguous 的. 但是對於樣板函式來說, 第二個 func 具現化出的版本更加特製化, 所以編碼器會選取第二個函式進行匹配

但是需要注意的是, 我們要確認這些為 char * 特別訂製的函式在作用範圍之內, 否則編碼器將會為我們具現化出樣板函式的具現體, 這並不是我們所要的函式

 

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

template <typename ...Args>

void func(const Args &...args);

在樣板中的 typename ...T 被稱為樣板參數包, 在函式參數中的 const T &...args 被稱為函式參數包. 其中, 這些參數包中的參數可以是 0 個, 也可以是多個.

當想要知道參數包中具體有多少參數, 可以使用 sizeof... 運算子 :

#include <iostream>



using namespace std;

template <typename ...Args>

constexpr auto func(const Args &...) -> decltype(sizeof...(Args)) {

    return sizeof...(Args);

}

int main(int argc, char *argv[]) {

    cout << func(1, 2, 3, 4, 5) << endl;        //輸出結果 : 5

}

因為樣板是在編碼期間進行推斷的, 所以 func() 函式具有編碼期計算的能力, 而且回傳的也是常數表達式, 所以宣告 func() 函式為 constexpr 函式

當函式參數個數未知的時候, 如果函式參數的型別都相同, 我們可以考慮使用 std::initializer_list 來接收未知個數的引數. 但是如果函式參數的型別都不知道或者不同, 那麼可以考慮使用可變樣板參數

不定參數的樣板函式通常都是遞迴的. 首先處理包中的第一個參數, 之後以剩餘的參數呼叫自身 :

#include <iostream>



using namespace std;



template <typename T>

double calculator(double sum, const T &t) {

    return sum += static_cast<double>(t);

}

template <typename ...Args, typename T>

double calculator(double sum, const T &t, const Args &...args) {

    return calculator(sum += static_cast<double>(t), args...);

}

int main(int argc, char *argv[]) {

    cout << calculator(0, 1, 's', -'2', 4.3) << endl;       //輸出結果 : 70.3

}

上述程式碼是將傳入的引數全部相加, 最終輸出. 在函式的呼叫中, 0、1、's' 和 -'2' 都是通過遞迴的方式, 由 template <typename ...Args, typename U> double calculator(double, const T &, const Args&...); 來呼叫自己. 到 4.3 傳入的時候, 呼叫的是 template <typename T> double calculator(double, const T &);

在呼叫函式遞迴的過程中, 實際上就是將函式參數包展開的過程 :

calculator(0, 1, 's', -'2', 4.3) -> calculator(1, 's', -'2', 4.3) -> calculator(116, -'2', 4.3) -> calculator(66, 4.3) => return 70.3

參數包的展開從第一個引數開始, 每次展開一個, 並且將剩餘的打包繼續作為引數傳入, 知道參數包的大小為 0 位置, 停止遞迴

對於一個參數包引數, 如同像上述程式碼那樣, 在參數包物件之後增加省略號, 即表示此處展開參數包

以上只是將參數包展開為引數傳入, 實際上 C++ 支援更加複雜的參數包展開, 即對參數包進行操作. 這裡將修改為之前的程式碼, 使參數包中的參數每次作為參數包引數傳入之前, 乘二 :

#include <iostream>



using namespace std;



template <typename T>

T multiply(const T &t) {

    return t * 2;

}

template <typename T>

double add(double sum, const T &t) {

    return sum += static_cast<double>(t);

}

template <typename ...Args, typename T>

double add(double sum, const T &t, const Args &...args) {

    return add(sum += static_cast<double>(t), multiply(args)...);

}

int main(int argc, char *argv[]) {

    cout << add(0, 2, 3, 4, 5, 6, 7, 8) << endl;       //輸出結果 : 896

}

最終輸出這麼大的數的原因是每次都對參數包 t 中的每一個引數都乘以 2. 我們將此遞迴展開 :

第一次 : add(0, 2, 3, 4, 5, 6, 7, 8);

第二次 : add(0 + 2, 3 * 2, 4 * 2, 5 * 2, 6 * 2, 7 * 2, 8 * 2);

第三次 : add(0 + 2 + 3 * 2, 4 * 22, 5 * 22, 6 * 22, 7 * 22, 8 * 22);

第四次 : add(0 + 2 + 3 * 2 + 4 * 22, 5 * 23, 6 * 23, 7 * 23, 8 * 23);

第五次 : add(0 + 2 + 3 * 2 + 4 * 22 +5 * 23, 6 * 24, 7 * 24, 8 * 24);

第六次 : add(0 + 2 + 3 * 2 + 4 * 22 + 5 * 23 + 6 * 24, 7 * 25, 8 * 25);

第七次 : add(0 + 2 + 3 * 2 + 4 * 22 + 5 * 23 + 6 * 24 + 7 * 25, 8 * 25);

最終回傳 : 0 + 2 + 3 * 2 + 4 * 22 + 5 * 23 + 6 * 24 + 7 * 25 + 8 * 25 => 896

假定 const Args &...args 中的參數包展開之後為 : args1, args2, ..., argsn. 那麼, template <typename ...Args, typename T> double add(double, const T &, const Args &...); 中的 return 陳述式展開之後為 :

return add(sum += static_cast<double>(t), multiply(args1), multiply(args2), ..., multiply(argsn);

如若我們將 return 陳述式中的 multiply(args)... 誤寫為 multiply(args...), 將可能會產生編碼錯誤. 除非在上面額外宣告並且實作了

template <typename ...Args> RETURN_TYPE __FUNCTION_NAME__([const] Args [&]);

其中, RETURN_TYPE 為回傳型別, __FUNCTION_NAME__ 為函式名稱, ([const] Args [&]) 為參數列表, 帶有中括號的為可選

 

在 C++ 11 新標準下, 可以使用不定參數樣板與 std::forward 來實作函式, 將引數轉遞給其它函式 :

template <typename T, typename ...Args>

inline void *construct(void *p, Args &&...args) {

    new (p) typename remove_reference<T>::type(std::move(args)...);

    return p;

}

template <typename T, typename ...Args>

void *emplace(Args &&...args) {

    return construct<T>(malloc(sizeof(T)), std::forward<Args>(args)...);

}

上述程式碼模擬了標準程式庫中的 allocator 和 container 其中的放置以及建構函式, 在 emplace() 函式中, 我們將傳入的不定函式引數通過 forward 函式轉遞給了 construct() 函式, 通過 construct() 函式原地建構

 

當我們特製化一個樣板函式的時候, 只需要將樣板參數置空, 之後將原來的樣板參數用已知的行唄填充即可. 樣板函式特製化的實質是人為地對樣板函式進行具現化, 而並非多載樣板函式. 因此, 特製化並不影響函式的匹配

使用任何樣板具現體之前, 最好將各種特製化的具現體宣告在作用範圍中. 對於普通的類別與函式來說, 缺少宣告而無法匹配的情況下, 編碼器可以通過編碼錯誤的方式來提示我們. 但是特製化的樣板則不同, 如果編碼器無法找到它們, 那麼編碼器會通過樣板具現化的方式為我們生成一個, 這可能會導致編碼器生成的並不是我們想要的具現體, 而這種錯誤比一般的編碼錯誤更難查找

當我們對樣板類別進行特製化的時候, 對於實作在類別之外的成員函式, 我們無須以 template <> 開頭. 但是, 當我們對樣板類別中的某個成員函式進行特製化的時候, 必須要以 template <> 開頭 :

template <typename T>

class A {

public:

    A() {

        cout << "template <typename T> class A" << endl;

    }

};

template <>

class A<int> {

public:

    A();

};

A<int>::A() {

    cout << "A<int>" << endl;

}

template <>

A<int>::A() {}        //編碼錯誤
template <typename T>

class A {

public:

    void func();

};

template <>

void A<int>::func() {

    cout << "A<int>" << endl;

}

template <typename T>

void A<T>::func() {

    cout << "A<T>" << endl;

}

void A<int>::func() {       //編碼錯誤

    cout << "A<int>" << endl;

}

與函式樣板不同的是, 類別樣板支援部分特製化. 我們可以為其指定一部分樣板參數, 而並非全部參數, 甚至是樣板參數的一部分, 而並非全部特性. 一個樣板類別的部分特製化本身還是一個樣板, 在使用它們的時候, 還需要為其為特製化的樣板參數部分提供型別參數 :

template <typename T>

void func<int>(const T &) {}    //編碼錯誤

template <typename T>

void func<T &&>(const T &) {}      //編碼錯誤

我們可以運用這一為類別設定的特性來模擬標準程式庫中的 std::remove_referece

template <typename T>

struct remove_reference {

    using type = T;

};      /* 未特製化的普通版本 */

template <typename T>

struct remove_reference<T &> {

    using type = T;

};      /* 為 T & 左值參考特製化的版本 */

template <typename T>

struct remove_reference<T &&> {

    using type = T;

};      /* 為 T && 右值參考特製化的版本 */

接下來的一個實例是對部分樣板參數進行特製化, 而並非全部參數. 部分樣板特製化的樣板參數是原樣板參數的子集或一個特製化的具現體 :

#include <iostream>



using namespace std;



template <typename A, typename B, typename C, typename D>

struct Foo {

    Foo() {

        cout << "default" << endl;

    }

};

struct type_assembly {

    using T = int;

    using U = char;

    using V = double;

};

template <typename T>

struct Foo<type_assembly::T, type_assembly::U, type_assembly::V, T> {

    Foo() {

        cout << "special" << endl;

    }

};

int main(int argc, char *argv[]) {

    Foo<int, int, int, int> f;      //輸出結果 : default

    Foo<int, char, double, long> b;     //輸出結果 : special

}

 

到此為止, C++ 基礎部分已經全部講解完畢了

C++ 學習筆記-Jonny'Blog

2019-01-12 15:33:30 by Jonny, 更新內容 :

17. 用於大型程式的工具

  • 例外處理

C++ 的例外處理機制具有堆疊回溯的性質. 如果一個例外情況於 try 內被擲出, 那麼檢查與此 try 範圍內繫結的 catch. 如果沒有辦法匹配, 那麼就繼續尋找巢狀 try 外層的 catch. 若此時還不匹配, 那麼就要去呼叫這個函式的外層函式中去嘗試尋找. 最終, 如若還是沒有一個 catch 匹配這個例外情況, 那麼此時程式將呼叫 std::terminate(), 由它負責終結程式

在堆疊回溯的過程中, 局域變數也會像函式終結那樣被銷毀

 

在例外情況發生的時候, 我們應該保證所有自訂的物件和配置的記憶體都被正常回收

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

 

throw 表達式中的物件必須事一個完全型別. 如果這個物件是一個類別的話, 其必須存在非私用的複製建構子或者移動建構子還有解構子. 如果這個物件事一個陣列或者函式, 將被自動地轉型為對應的陣列指標或者函式指標

因為例外處理中堆疊回溯的性質, 擲出一個局域物件的指標的 throw 表達式幾乎可以肯定是錯誤的. 這就如同函式回傳一個局域物件的指標. 因為指標所指向的物件位於某個可視範圍之內, 而在 catch 之前, 這個範圍已經終結, 並且局域物件已經被銷毀了. 所以, 當我們擲出一個指標的時候, 應保證任何對應的處理例外情況的程式碼範圍內, 指標所指的記憶體位址中存在對應的物件, 而不是一個已經被釋放或者未配置的記憶體位址

 

當我們擲出一條表達式的時候, 該表達式的靜態編碼型別決定了例外物件的型別. 若一個表達式擲出的是一個解參考的基礎類別的指標, 而實際指標指向的事衍生類別, 那麼擲出的物件將會被切去衍生類別的部分, 只保留基礎類別的部分, 然後被擲出

 

如果 catch 無需訪問 throw 擲出的表達式的話, 我們可以直接省略 catch 中的參數列表的參數名稱, 這一點與函式的參數性質有點類似

#include <iostream>



using namespace std;



int main(int argc, char *argv[]) {

    try {

        throw 0;

    }catch(int) {

        cout << "An int exception!" << endl;

    }

    try {

        throw 1;

    }catch(int &i) {

        cout << i << endl;

    }

}

catch 中宣告的型別也同樣需要是一個完全型別, 它可以是左值參考, 但是它不可以是右值參考

傳入 catch 的引數與函式的引數相類似, 若其參數型別為非參考型別, 那麼 catch 內部的任何更改都不會影響外部對應的物件

catch 的參數還有一個與函式參數相類似的性質是如果 catch 的參數型別為基礎類別, 那麼可以使用派生類別對其進行初始化. 若型別非參考型別的話, 其衍生部分會被切割掉

一般情況下, 如果 catch 接受的引數型別來自某個繼承體系, 最好將這個 catch 的參數型別宣告為參考型別

由於例外情況的匹配是由 catch 出現的順序進行的, 所以我們最終匹配到的 catch 可能未必是最佳的. 因此, 越是專門的 catch 越應該放置於整個 catch 列表的前端

當程式中使用了一個具有繼承關係的多個例外物件時, 我們應該對 catch 陳述式的順序進行組織, 將衍生類別例外處理放置在基礎類別例外處理的前面

與函式引述和參數的匹配規則相比, 例外物件型別和 catch 宣告中的物件型別匹配的規則受到了更多的限制, 絕大多數的型別轉型都不被允許 :

  • 允許非常數向常數的型別轉型. 即一個非常數物件的 throw 表達式可以和一個接受常數參考的 catch 匹配
  • 允許衍生類別向基礎類別進行轉型
  • 陣列被轉型為指向陣列的指標; 函式被轉型為指向函式的指標

除上述之外的轉型都不可以在 catch 匹配的過程中發生

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

一個重新擲出的 throw 不包含任何表達式的原因是因為它可以將當前 catch 到的例外物件沿呼叫串列向上進行轉遞 :

#include <iostream>



using namespace std;



int main(int argc, char *argv[]) {

    try {

        try {

            throw runtime_error("Inner catch");

        }catch(exception &e) {

            cout << e.what() << endl;

            throw;

        }

    }catch(runtime_error &r) {

        r = runtime_error("Outer catch");

        cout << r.what() << endl;

    }

}



/* 輸出結果 :

 * Inner catch

 * outer catch

 */

 

很多時候, catch 會改變其傳入引數的內容. 如果在改變了引數內容後, catch 進行重新擲出, 那麼只有當 catch 例外宣告中的型別是參考型別的時候, 我們對引數的更改才會被保留並且繼續被轉遞 :

#include <iostream>



using namespace std;



int main(int argc, char *argv[]) {

    try {

        try {

            try {

                throw runtime_error("1");       //設定錯誤提示為 1

            }catch(runtime_error e) {       //非參考型別

                e = runtime_error("2");     //設定錯誤提示為 2

                throw;

            }

        }catch(exception &e) {

            cout << e.what() << endl;       //輸出結果 : 1, 也就是上一條 catch 對引數的更改未被轉遞出來, 轉遞出來的還是上一條 catch 捕捉到的引數

            dynamic_cast<runtime_error &>(e) = runtime_error("3");      //不進行轉型的改變無效

            throw;

        }

    }catch(exception &e) {

        cout << e.what() << endl;       //輸出結果 : 3

    }

}

 

有時, 我們希望不管例外物件事什麼型別, 程式都可以進行捕獲. 此時, 我們可以使用省略號運算子 "..." 來替代 catch 中的參數列表 :

#include <iostream>



using namespace std;



int main(int argc, char *argv[]) {

    try {

        //...

    }catch(...) {

        //...

        throw;      //catch(...) 通常與重新擲出例外情況的空 throw 表達式一起使用. 其中, 當前 catch 處理當前部分可以完成的統一工作

    }

}

catch(...) {} 即可以單獨出現, 也可以和其它幾個 catch 一起出現. 如果它和其它幾個 catch 一起出現, 那麼
catch(...) 必須在最後的位置. 否則, 在它之後的其它幾個 catch 將永遠不會被匹配

 

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

#include <iostream>



using namespace std;



class Foo {

private:

    int *p;

public:

    explicit Foo(int num) try : p {new auto(num)} {}catch(exception &e) {

        cout << e.what() << endl;

    }

};

在初始化建構子參數的時候, 也有可能發生例外情況, 但是這樣的例外情況並不屬於函式 try 塊的一部分, 它屬於物件建構所屬的那個位置的一個例外情況

與函式建構子相對應的是, 函式的解構子也可以寫為一個函式 try

 

當我們明確某個函式不會擲出例外情況的時候, 使用之前所提到的 noexcept 標識符有助於編碼器簡化程式碼和執行某些特殊的優化. 而這些優化並不適用於可能出錯的程式碼

noexcept 應同時出現在函式的宣告及實作, 並且應位於尾置回傳型別之前

typedef 或型別別名中不可以出現 noexcept

也可以為函式指標的宣告與定義中為其指定 noexcept

在類別成員函式中, noexcept 應跟在 const 及參考限定符之後, finaloverride 及純虛擬函式的 = 0 宣告之前

編碼器並不會在編碼器時檢查 noexcept, 即即使對函式作了 noexcept 說明, 但是仍然可以在函式中擲出例外情況這樣的行為會被編碼通過. 這種用法違反了例外情況說明. 一旦一個 noexcept 函式擲出了例外情況, 程式會呼叫標準程式庫函式 std::terminate() 以確保運作時不會擲出任何例外情況的承諾. 上述情況對於堆疊回溯並未做出任何約定

在我們確認一個函式不會擲出例外情況或者根本不知道一個函式是否應該擲出例外情況的時候, 我們就應該將其宣告為 noexcept 函式

對於函式呼叫者而言, 一個 noexcept 函式的呼叫, 無論這個函式時確定不會擲出例外情況還是程式被 std::terminate() 所終結, 函式的呼叫者無需對此進行負責

通常情況下, 編碼器不能也不必在編碼時驗證例外情況說明

 

在 C++ 11 之前, C++ 中有一套更加詳細的例外情況說明方案, 該方案使我們可以指定某個函式可能會擲出的例外情況的物件的型別. 函式可以指定一個關鍵字 throw, 在其後跟緊一個括號, 其中包括了例外物件型別列表, 其出現的位置和 noexcept 出現的位置相同. 這樣的方案在 C++ 11 中已經被遺棄. 但儘管如此, 如果函式被宣告為 throw() 的, 那麼意味著函式保證不會擲出任何例外情況, 即 :

void func() throw();void func() noexcept; 兩者相互等價

但是, 在 C++ 11 之後, 我們應該使用 noexcept 來替代 throw()

noexcept 可以接受一個可選的引數, 引數必須可以轉型為 bool 型別 : 如果引數最終結果為 true, 則函式保證不會擲出例外情況; 如果引數最終的結果為 false, 那麼函式可能會擲出例外情況 :

#include <iostream>



using namespace std;



/* 當 T 為整形型別的時候, 函式承諾不會擲出任何例外情況; 否則, 函式可能擲出例外情況 */

template <typename T>

void func(T value) noexcept(is_integral<T>::value) {

    cout << value << endl;

}

noexcept 說明符經常與 noexcept 運算子一起混合使用. noexcept 運算子是一個一元運算子, 並且回傳值是一個 bool 型別的右值常數表達式. 其用於表示給定的表達式是否會擲出例外情況, 和 sizeof 運算子相似. noexcept 運算子也不會求其表達式對應物件的值

noexcept 的運算物件很多時候都是函式, 若函式本身作出了 noexcept 說明, 那麼回傳結果為 true; 否則, 回傳結果為 false :

void g();

void f() noexcept(noexcept(g())) {}     //若 g() 具有 noexcept 標識, 那麼 f() 是一個保證不會擲出例外情況的函式

如果原函式被 noexcept 標識, 那麼指向這個函式的任何函式指標都要使用對應的 noexcept 標識; 但是一個被 noexcept 標識的函式指標既可以指向被 noexcept 標識的函式也可以指向不被 noexcept 標識的函式

 

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

 

當編碼器合成複製控制成員的時候, 同時也為其生成了一個例外情況說明. 如果對所有成員和基礎類別的所有操作都作出了 noexcept 說明, 那麼合成的成員也是 noexcept 的. 如果合成成員呼叫的任意一個函式可能擲出例外情況, 那麼合成的成員將是 noexcept(false) 的. 而且, 如果我們定義了一個解構子, 但並未對其提供例外情況說明, 那麼編碼器將為其合成一個. 合成的例外情況說明將於假設由編碼器合成的解構子對應的例外情況說明相同

 

標準程式庫例外情況類別 std::exception 僅僅提供了複製建構子、複製指派運算子和一個虛擬的解構子, 除此之外還有一個名為 what() 的虛擬成員函式. 其中, what() 函式回傳一個 const char *, 該指標指向一個以空字元結尾的字元陣列, 並且作出了不會擲出例外情況的說明

運作時的錯誤通常只有在程式運行的時候才能檢測到; 而邏輯錯誤可以在我們通讀程式碼的時候發現

 

  • 名稱空間

當使用多個程式庫的時候, 特別是每個程式庫都由不同的作者提供, 並且將所有名稱都放置於全域名稱空間內, 將可能會引發名稱空間污染

名稱空間可以是不連續的, 當我們定義一個名稱空間的時候, 若這個名稱空間並不存在, 將創建一個新的名稱空間; 當這個名稱空間已經存在的時候, 將打開這個名稱空間為其增添新的名稱空間成員

 

一般情況下, 我們不把 #include 放入名稱空間中. 如果將 #include 放入名稱空間中, 這將意味著將標頭檔中的所有名稱宣告為該名稱空間下的成員. 特別是標準程式庫名稱空間 std

 

樣板的特製化必須宣告與原始樣板所屬的名稱空間之內

 

名稱空間支援巢狀, 內層的名稱空間中的名稱將會隱藏外層名稱空間中宣告的同名成員

 

C++ 11 新標準引入了內嵌名稱空間, 內嵌名稱空間中的名稱可以被外層名稱空間直接使用. 即使用的時候, 無需在前加上名稱空間以及可視範圍運算子

宣告內嵌名稱空間的方式是在名稱空間宣告之前加上 inline. 在名稱空間第一次被定義的時候, 若它是一個內嵌的名稱空間, 那麼一定要加上 inline. 在後續打開名稱空間的時候, 可以加上 inline, 也可以不加 inline

#include <iostream>



using namespace std;



inline namespace Jonny {

    auto a {10};

}



namespace A {

    inline namespace B {

        auto a {100};

    }

}



int main(int argc, char *argv[]) {

    cout << ::a << endl;        //輸出結果 : 10

    cout << A::a << endl;       //輸出結果 : 100

}

內嵌名稱空間具有轉遞的性質, 即假設 B 為 A 的內嵌名稱空間, C 為 B 的內嵌名稱空間, 那麼可以直接通過 A:: 來使用 B 名稱空間和 C 名稱空間中的名稱

內嵌的名稱空間通常用於版本控制. 若 "重寫" 某個函式與一個內嵌名稱空間中, 通過外層名稱空間與可視範圍運算子呼叫這個函式, 得到的將是重寫函式的運作結果而非舊版本的運作結果. 當我們想要呼叫舊版本的函式, 則需要完整的名稱空間

namespace game {

    /* 1.1 版本 */

    inline namespace game_1_1 {

        int move(void *role, int direction, int distance);

    }

    /* 1.0 版本 */

    namespace game_1_0 {

        int move(void *role, int direction, int distance);

    }

}

通過上述程式碼, 我們可以發現 1.1 版本的 move() 函式 "重寫" 了 1.0 版本的 move() 函式. 所有對於 move() 函式未指名名稱空間的呼叫, 都將使用 game_1_1 中的 move() 函式

當 1.2 版本又有新的 move() 函式誕生, 此時只要新增一個 inline namespace game_1_2, 並且將原來內嵌的 game_1_1 改為非內嵌的名稱空間即可 :

namespace game {

    /* 1.2 版本 */

    inline namespace game_1_2 {

        int move(void *role, int direction, int distance, void * = nullptr);

    }

    /* 1.1 版本 */

    namespace game_1_1 {

        int move(void *role, int direction, int distance);

    }

    /* 1.0 版本 */

    namespace game_1_0 {

        int move(void *role, int direction, int distance);

    }

}

 

不具名的名稱空間是指 namespace 後沒有名稱空間所對應的名稱 :
namespace {}
在不具名的名稱空間中宣告的變數具有靜態的生命週期, 直到程式終結之前, 它們才開始被銷毀

一個不具名名稱空間可以在一個檔案內不連續, 單不可以跨越多個檔案. 若兩個檔案內部存在不具名的名稱空間, 那麼這兩個名稱空間互不相關. 兩個不同檔案的不具名的名稱空間中可以宣告同名變數, 並且該變數的可視範圍僅在該檔案之內

若兩個檔案都具有一個在全域名稱空間下的不具名的名稱空間, 並且兩個不具名的名稱空間中都存在一個同名的變數, 那麼它們不能被同時包含在另外一個檔案內, 否則將會產生一個變數多次定義的編碼錯誤

若一個不具名的名稱空間在全域名稱空間中, 那麼在不具名的名稱空間中已經宣告的變數名稱在全域可視範圍之內不可以在此對此進行宣告, 否則也將產生一個變數多次定義的編碼錯誤

不具名的名稱空間也支援被巢狀於其它名稱空間中

在標準 C++ 引入名稱空間之前, 程式通常需要將變數宣告為 static 使其對整個檔案內都有效. 這種作法由 C 繼承而來, 並且最終產生的效果於不具名的名稱空間下的名稱產生的效果類似. 但這種作法已經被 C++ 標準遺棄並且由不具名的名稱空間替代

 

當一個名稱空間具有很長的名稱的時候, 可以對其宣告一個名稱空間別名 :

namespace A = B;        //A 為 B 的別名, B 必須是一個已經存在的名稱空間; 否則, 將產生編碼錯誤

名稱空間的別名也可以指向巢狀的名稱空間 :

namespace A = B::C::D;

一個名稱空間可以有無限個別名, 它們於原來的名稱相互等價

 

在一個類別可視範圍內, 一條 using 宣告陳述式智能指向基礎類別的成員

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

對於名稱空間中的名稱搜尋, 有一個重要的例外 : 如果一個函式接受的引數為一個類別型別, 首先會在當前可視範圍內搜尋, 之後在外層可視範圍內搜尋, 接著還會去查找引數類別對應型別的可視範圍的名稱. 這一例外對於參考與指標同樣適用. 這條例外帶來的好處, 就是我們可以對某個型別直接使用在其名稱空間之下的運算子, 例如對 string 型別使用 IO 運算子 :

#include <iostream>



int main(int argc, char *argv[]) {

    std::string str {"123"};

    std::cout << str << std::endl;

    std::cin >> str;

    std::cout << str << std::endl;

}

對於上述程式碼來說, 編碼器會在定義了 stringistream 的名稱空間 std 中查找, 並且找到對應 string 的 IO 輸出運算子

如果不存在這個例外, 那麼我們將必須提供一條 using 宣告 :

using std::operator>>;

或者在使用運算子的時候, 變成 :

std::operator>>(std::cin, str);

根據例外的名稱搜尋規則, 如果與友誼物件宣告相結合, 可能會產生一些意想不到的情況 :

namespace A {

    class Foo {

        friend void f();

        friend void f2(const Foo &);

    };

}



int main(int argc, char *argv[]) {

    A::Foo x;

    f();        //編碼錯誤, 找不到不接受引數並且名稱為 f 的函式

    f2(x);      //正確, 在 A::Foo 中找到接受 x 引數並且名稱為 f2 的函式. 但是因為該函式未被實作, 所以會產生連結錯誤

}

友誼函式 f2() 接受一個 A::Foo 型別的引述, 那麼當呼叫 f2() 並且傳入一個 A::Foo 或者可以隱含地轉型為 A::Foo 的引數的時候, 編碼器會到 A::Foo 類別中查找, 並且在名稱空間 A 中的 Foo 類別之內找到這個函式. 這就相當於 f2() 函式在名稱空間 A 中進行了隱含地宣告. 但對於 f() 函式來說, 它並不會接受任何引數, 所以編碼器在查找的時候, 只會在 main 函式之內或者全域名稱空間下去查找

也就是說, 當一個在另外一個名稱空間內且沒有在目前名稱空間內被宣告的函式, 它具有類別型別的參數, 那麼無需對這個函式在當前名稱空間下宣告, 即可呼叫到它, 只要傳入的參數是對應的型別或者其衍生類別型別 :

namespace A {

    class Base {};

    void func(const Base &) {}

}



class Derived : public A::Base {};



int main(int argc, char *argv[]) {

    func(Derived());        //正確

}

所以, 不管一個函式是否被宣告, 不管它在哪一個名稱空間中, 只要它接受的引數中含有類別型別, 在呼叫它的時候, 傳入對應的類別物件之後, 都可以在無需宣告的情況下直接呼叫它

 

當為一個函式進行 using 宣告的時候, 其所有版本都會被引入當前的名稱空間中 :

using namespace_name::function_name;        //正確

using namespace_name::function_name(int);        //錯誤, 不需要為其指定參數列表

使用 using 進行宣告的時候, 若外層可視範圍已經存在同名的物件, 那麼內層的將會對外層的同名物件進行覆蓋. 若 using 宣告的可視範圍內已經有同名物件, 那麼將會產生編碼錯誤 :

#include <iostream>



using namespace std;



auto a {20};



namespace A {

    auto a {0};

    //using ::a;      //未被註解的情況下將會產生編碼錯誤

    namespace B {

        using ::a;

    }

}



int main(int argc, char *argv[]) {

    cout << A::B::a << endl;        //輸出結果 : 20. 等價於 cout << a << 
endl; 或者 cout << ::a << endl;

    cout << A::a << endl;       //輸出結果 : 0

}

使用 using 指示引入一個參數列表不同但是函式名稱相同的函式是被允許的, 這就相當於函式的多載

 

  • 多繼承

多繼承指的是一個衍生類別從多個基礎類別中繼承所有基礎類別的屬性

對於衍生類別能夠繼承的基礎類別個數, C++ 並沒有作特殊的規定. 但是, 在衍生類別的繼承列表中, 同一個基礎類別只能出現一次

在衍生類別中, 基礎類別的初始化順序與衍生列表中基礎類別出現的順序保持對應, 與衍生類別建構子中初始化列表中的基礎類別順序無關

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

#include <iostream>



using namespace std;



class A {

public:

    A() {

        cout << 1;

    }

    explicit A(const string &) {}

    virtual ~A() = default;

};

class B {

public:

    B() {

        cout << 2;

    }

    explicit B(const string &) {}

    virtual ~B() = default;

};

class C : public A, protected B {

protected:

    using B::B;

public:

    using A::A;

};



int main(int argc, char *argv[]) {

    C c("123");     //儘管 C 是以 protected 的方式繼承 B, 但是還是會產生編碼錯誤

    C c;        //輸出結果 : 12. 預設建構子 (無參數的建構子) 不會產生影響

}

要想上述程式碼通過編碼, 需要對其進行一定的修改 :

#include <iostream>



using namespace std;



class A {

public:

    A() {

        cout << 1 << endl;

    }

    explicit A(const string &) {}

    virtual ~A() = default;

};

class B {

public:

    B() {

        cout << 2 << endl;

    }

    explicit B(const string &) {}

    virtual ~B() = default;

};

class C : public A, protected B {

protected:

    using B::B;

public:

    using A::A;

    C(const string &str) : A(str), B(str) {}

    C() = default;      //當 C 自訂了其它建構子, 如果想要預設建構子生效, 那麼需要對其明確宣告

};



int main(int argc, char *argv[]) {

    C c("123");     //正確

    C p;        //正確

}

對於解構子來說, 其解構順序與建構順序相反, 即首先解構衍生類別, 然後根據衍生列表的相反順序進行解構

 

對於多個基礎類別衍生而來的衍生來別來說, 任何一個基礎類別型別的參考指標都可以繫結到衍生類別上

因為編碼器會將一個衍生類別向任何一個基礎類別上的轉型都視為一樣好, 那麼在函式呼叫的過程中, 很可能因為這樣而產生呼叫問題 :

#include <iostream>



using namespace std;



class A {

public:

    A() {

        cout << 1 << endl;

    }

    explicit A(const string &) {}

    virtual ~A() = default;

};

class B {

public:

    B() {

        cout << 2 << endl;

    }

    explicit B(const string &) {}

    virtual ~B() = default;

};

class C : public A, protected B {

protected:

    using B::B;

public:

    using A::A;

    C(const string &str) : A(str), B(str) {}

    C() = default;      //當 C 自訂了其它建構子, 如果想要預設建構子生效, 那麼需要對其明確宣告

};



void func(const A &) {}

void func(const B &) {}



int main(int argc, char *argv[]) {

    func(C());      //ambiguous calling

}

 

在多繼承下, 對於名稱的查找將在所有基礎類別中同時進行. 但即使對於一個衍生類別來說, 從多個基礎類別中繼承了相同名稱的屬性也是被允許的, 但是在訪問的時候, 必須在其之前加上類別名稱和可視範圍運算子

比上述情況更加複雜的是, 即使衍生類別從兩個基礎類別中繼承了兩個同名但是具有不同參數列表的函式, 也可能發生含糊呼叫的問題 :

class A {

public:

    void func(int) {}

};

class B {

public:

    void func(double) {}

};

class C : public A, protected B {};



int main(int argc, char *argv[]) {

    C().func(0);        //ambiguous calling

}

與此類似的是, 儘管某個同名函式在其中一個基礎類別中是私用的, 在另外一個基礎類別中是公用的, 但是從衍生類別呼叫這個函式, 也會產生類似的問題 :

class A {

private:

    void func(int) {}

};

class B {

public:

    void func(double) {}

};

class C : public A, protected B {};



int main(int argc, char *argv[]) {

    C().func(0);        //ambiguous calling

}

一些比較好的 IDE 可能對於上述程式碼的靜態分析提示是 func 不可達, 但是實際上編碼器並不會產生這樣的編碼錯誤. 編碼器只是告訴你, 在名稱查找的過程中發現這種函式呼叫是含糊的

 

  • 虛擬繼承

在繼承的過程中, 兩個繼承體系中間類別都繼承自同一個基礎類別, 然後一個衍生類別從這個中間類別繼承, 這種情況時常會發生. 一般來說, 這並不存在什麼太大的問題. 但是一些統一且唯一的工作由基礎類別進行管理, 然後讓中間類別負責稍微高級一些的功能, 最後的衍生類別負責合併高級功能, 這樣的類別如果使用這樣的繼承體系可能就會出現一些問題. 因為在這種繼承體系中, 最低層的基礎類別被中間類別繼承了兩次, 相當於最後的衍生類別中有兩個最低層的基礎類別. 這可能會造成最後的衍生類別的功能紊亂. 以標準程式庫中的 iostream 作為範例 :

iostream 是一個衍生類別, 它繼承自 istreamostream. 而 istreamostream 並不是最低層的基底類別, istreamostream 都繼承自 basic_ios

basic_ios 是一個用於保存緩衝區資料並且管理資料流狀態的類別

ostream 是一個負責資料輸出的類別

istream 是一個負責資料輸入的類別

所以 iostream 是一個既可以負責資料輸入也可以負責資料輸出的類別

如果 iostream 中真的包含了分別來自 ostream 繼承和 istream 繼承的 basic_ios, 那麼將會出現兩個緩衝區, 兩個資料流狀態. 這樣對於 iostream 來說, 顯然會產生很大的麻煩

為了避免這樣的麻煩, 在 C++ 中可以通過虛擬繼承的機制, 可以解決上述的問題. 虛擬繼承的目的是令某個類別作出宣告, 承諾願意共享它的基礎類別. 其中, 共享的基礎類別被稱為虛擬基礎類別. 在這樣的機制下, 不管虛擬基礎類別在繼承體系中出現多少次, 在衍生類別中將會永遠只包含一個

虛擬繼承的方式是在衍生列表的基礎類別之前增加 virtual 關鍵字, 它與訪問說明符的位置可以相互調換, 即一下程式碼是被允許且等價的 :

struct A {};

struct B : virtual private A {};

struct B : private virtual A {};

以虛擬繼承實作 iostream, 此時 basic_iosiostream 中只會存在一個, 那麼也就是說, iostream 中, 不管輸入還是輸出, 只會存在一個緩衝區, 一個資料流狀態

通過虛擬繼承, iostream 大致可以設計為 :

class basic_ios;

class istream : virtual protected basic_ios {};

class ostream : virtual protected basic_ios {};

class iostream : public istream, public ostream {};

從這個繼承體系中, 我們可以發現, 只有當 iostream 這個需求出現的時候, 才會需要虛擬繼承這樣的機制. 並且, 虛擬繼承並不會對 istreamostream 產生任何影響. 它只影響繼承自 istreamostream 之後衍生出的心的衍生類別

下面有一段使用虛擬繼承和不使用虛擬繼承的演示程式碼

不使用虛擬繼承 :

struct A {

    int a;

};

struct B : A {};

struct C : A {};

struct D : B, C {};



int main(int argc, char *argv[]) {

    D d;

    d.a = 10;       //編碼錯誤, a 是含糊的

    d.B::a = 10;        //正確

    d.C::a = 10;        //正確

}

使用虛擬繼承 :

struct A {

    int a;

};

struct B : virtual A {};

struct C : virtual A {};

struct D : B, C {};



int main(int argc, char *argv[]) {

    D d;

    d.a = 10;       //正確

}

若虛擬繼承基礎類別只被一條衍生路徑所覆蓋, 那麼可以直接訪問這個成員; 但是如果虛擬繼承的衍生類別也擁有同名成員, 那麼可能會導致含糊的訪問 :

#include <iostream>



using namespace std;



struct A {

    int a {0};

};

struct B : virtual A {

    int a {1};

};

struct C : virtual A {};

struct D : B, C {};



int main(int argc, char *argv[]) {

    D d;

    cout << d.a << endl;        //輸出結果 : 1

}

上述程式碼並沒有產生含糊的訪問. 因為虛擬繼承中, 衍生類別中同名成員的優先級要比虛擬基礎類別中的同名成員要高

#include <iostream>



using namespace std;



struct A {

    int a {0};

};

struct B : virtual A {

    int a {1};

};

struct C : virtual A {

    int a {2};

};

struct D : B, C {};



int main(int argc, char *argv[]) {

    D d;

    cout << d.a << endl;        //編碼錯誤

}

上述程式碼會產生含糊的訪問, 因為 B, C 中的 a 成員雖然比 A 中的優先級高, 但是在 B, C 中是同級別的

解決這種含糊的訪問的最好方法是在 D 中新定義一個 a 成員 :

#include <iostream>



using namespace std;



struct A {

    int a {0};

};

struct B : virtual A {

    int a {1};

};

struct C : virtual A {

    int a {2};

};

struct D : B, C {

    int a {3};

};



int main(int argc, char *argv[]) {

    D d;

    cout << d.a << endl;        //輸出結果 : 3

}

 

在虛擬繼承中, 虛擬基礎類別是由最後衍生的類別進行初始化的. 否則, 在初始化的過程中, 虛擬基礎類別將多次被初始化 :

struct A {

    int a {0};

};

struct B : virtual A {};

struct C : virtual A {};

struct D : B, C {};

即在上述繼承體系中, 由 D 負責 A 的初始化

在實際的繼承中, 任何一個類別都可能成為 "最後一個類別". 所以, 一旦我們從一個基礎類別中虛擬衍生出一個新的衍生類別, 我們就應該讓這個類別對其虛擬基礎類別進行初始化

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

含有虛擬基礎類別的物件的建構順序與一般順序稍有不同 : 首先, 使用提供給最後衍生的類別建構子的初始化值初始化該物件的虛擬基礎類別部分, 然後按照基礎類別在衍生列表中出現的順序一次進行初始化

如果最後衍生的類別並未對虛擬基礎類別進行明確初始化, 那麼將使用虛擬基礎類別的預設建構子. 若虛擬基礎.欸別沒有預設將鉤子, 將會產生編碼錯誤

當一個類別擁有多個虛擬基礎類別, 那麼其初始化順序將按照在衍生列表中出現的順序從左到右進行建構. 合成的複製、移動建構子瑜複製指派運算子也是按照相同的順序執行

對於解構子來說, 與往常一樣, 將按照建構順序的相反順序對類別進行解構

C++ 學習筆記-Jonny'Blog

2019-01-21 15:20:01 by Jonny, 更新內容 :

18. 特殊工具與技術

  • 記憶體分配控制

當對記憶體的配置有特殊需求的時候, 例如使用 new 將物件放置在特定的記憶體位址的時候, 通常需要對 new 運算子和 delete 運算子進行多載. 但是 newdelete 運算子的多載和其它運算子的多載有些不同

當我們使用 new 表達式的時候 :

auto p {new auto(0)};

auto p {new int[5] {1, 2, 3, 4, 5}};

實際上執行了三個步驟 :

  1. 首先使用標準程式庫函式 ::operator new 或者 ::operator new[] 進行記憶體配置, 此時還沒開始建構
  2. 接下來使用對應的建構子進行建構
  3. 建構完成之後回傳對應物件的指標

當我們使用 delete 表達式的時候 :

delete p;

delete[] p;

實際上執行了兩個步驟 :

  1. 對指標對應的記憶體中的物件進行解構
  2. 使用標準程式庫函式 ::operator delete 或者 ::operator delete[] 對物件指向的記憶體進行回收

請留意, 從此處開始, operator newoperator delete 將同時代表 operator new, operator new[]operator delete, operator delete[]

當我們對全域的 operator newoperator delete 進行多載之後, 這些多載的函式就負擔起了控制記憶體配置的作用, 因此我們必須要完全保證這些多載函式一定正確

我們既可以將 operator newoperator delete 多載為全域函式, 也可以將其放置於成員函式中. 當編碼器發現一條 new 表達式或者 delete 表達式後, 首先會在可視範圍之內查找自訂的版本, 如果找不到才會到外層的可視範圍內查找, 直到全域下的自訂版本. 此時, 如果還是找不到自訂版本的情況下, 才會呼叫標準程式庫的版本

我們也可以使用可視範圍運算子直接呼叫對應的可視範圍中的多載函式

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

void *operator new (size_t);

void *operator new[] (size_t);

void operator delete (void *) noexcept;

void operator delete[] (void *) noexcept;

void *operator new (size_t, const std::nothrow_t &) noexcept;

void *operator new[] (size_t, const std::nothrow_t &) noexcept;

void operator delete (void *, const std::nothrow_t &) noexcept;

void operator delete[] (void *, const std::nothrow_t &) noexcept;

nothrow_t 是被定義在 <new> 標頭檔中的一個 struct, 這個結構體沒有任何成員. 除此之外, <new> 標頭檔中還宣告了一個名為 nothrow 的物件, 用於請求不會擲出例外情況的版本

若這些標準程式庫版本的 operator newoperator delete 可能會擲出例外情況, 那麼他們可能會擲出 std::bad_alloc 例外情況

與解構子類似, delete 也不允許擲出任何例外情況. 在你沒有明確對 operator delete 的多載版本標識為 nothrow 函式的時候, 編碼器會自動為其標識. 與此同時, 編碼器還會檢測這個多載版本裡面是否擲出了例外情況, 並且給出相應的警告. 如果執意擲出例外情況, 那麼會呼叫標準程式庫函式 std::termination()

當將 operator newoperator delete 定義為類別成員的時候, 它們是隱含的 static 函式. 因為 new 用於物件建構之前, delete 用於物件解構之後

對於 operator new 來說, 它的回傳型別必須為 void *, 第一個參數型別必須為 size_t, 而且不可以有預設引數. 當使用 operator new 的時候, 需要把物件對應型別所需的具體大小傳給第一個參數; 當使用 operator new[] 的時候, 需要把具體陣列中的所有元素所需的總空間大小傳給第一個參數

對於 operator new 的自訂, 可以為其提供額外的參數, 但是下面函式是標準程式庫所專有, 不可以被自訂 :

void *operator new(size_t, void *);

對於 operator delete 來說, 第一個參數必須為 void * 型別

當將 operator delete 定義為類別成員的時候, 函式可以包含另外一個型別為 size_t 的參數. 此時, 該參數是為了指定第一個參數指向的物件大小, 並且可以用於刪除繼承體系中的物件. 若基礎類別中有一個虛擬解構子, 那麼傳遞給 operator delete 的大小將由實際指標所指向的物件的對應型別所確定

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

C++ 從 C 中繼承了指標, 也繼承了 malloc()free() 函式, 它們在 C++ 中被定義在 <cstdlib> 標頭檔中. 我們可以使用這兩個函式來編寫我們自己的 operator new 或者 operator delete :

#include <cstdlib>

#include <new>



using namespace std;



void *operator new (size_t size) {

    if(void *memory {malloc(size)}) {

        return memory;

    }

    throw std::bad_alloc();

}

void operator delete (void *p) noexcept {

    free(p);

}

請留意, 我們自訂的 operator newoperator delete 的形式和標準程式庫是一樣的, 而且他們在全域名稱空間內. 但是這並不會造成含糊地呼叫, 因為編碼器會優先使用我們自訂的版本

上述的 operator newoperator delete 只是用於配置記憶體, 並沒有指標所指向的記憶體中進行任何建構. 如果需要進行建構, 我們應該使用放置 new, 即 placement new. 放置 new 的基本形式是 :

new (pointer) type;

new (pointer) type(initializers);

new (pointer) type[size];

new (pointer) type[size] {initializer_list};

這些 new 表達式並不負責配置記憶體, 而是負責建構物件. 我們可以在 pointer 地址上使用對應的初始化值或者初始化列表建構 type 型別的物件或者陣列

放置 new 回傳一個 type * 型別的指標. 如果不是通過放置 new 回傳的指標來使用放置 new 建構的物件, 將會是一個未定行為

放置 newallocatorconstruct() 成員函式有些類似, 但是細節上是由區別的. 我們傳給 construct() 成員函式的指標必須是由 allocator 配置的. 但是放置 new 並沒有這個要求, 甚至可以傳給它一個飛動態記憶體的指標

對於一個類別物件來說, 如果想要清除給定記憶體中的物件, 但是並不想讓記憶體被回收的話, 可以通過直接呼叫類別的解構子的方式 :

T value;

value.~T();
  • 運行時型態識別

相當於 decltypeauto 來說, 它們都是被應用於編碼期的, 我們也可以通過運作期型態識別 typeid 運算子來回傳一個表達式的型別 :

typeid(expression)

其中 expression 可以是任何表達式或者已知的型別

typeid 運算中, 頂層的 const 將被忽略

typeid 的操作結果將會是一個常數物件的參考

對於陣列或者函式進行 typeid 運算的時候, 陣列或者函式並不會被轉型為對應的指標型別. 即放入陣列型別, 回傳的將會是 T [], 而並不是 T *

只有當運算物件是定義了至少一個虛擬函式的型別的左值的時候, typeid 的結果才會在運作期得到結果. 即運算物件不屬於類別型別或者是一個不包含任何虛擬函式的類別的時候, typeid 的結果將會直接是物件的靜態型別

typeid 直接作用於指標而不是作用於指標指向的物件的時候, 回傳的結果將會是指標靜態編碼時期的型別而非動態型別

對於正在運作時運用 typeid 得到指標所指向物件的值的時候, 指標不可以為空指標, 否則將會擲出 bad_typeid 的例外情況

 

相對於 static_cast 轉型運算子, 同樣有在運作期才負責型別轉換的運算子 dynamic_cast, 如果有仔細閱讀的話, 之前的我們已經提到並且使用過這個運算子了. 這個運算子的使用方法如下 :

dynamic_cast<T *>(object);

dynamic_cast<T &>(object);

dynamic_cast<T &&>(object);

其中, T 可以被 const 或者 volatile 限定, 而且 T 必須時一個類別型別. 通常情況下, T 應該具有虛擬的成員函式

如果要對一個指標使用 dynamic_cast 運算子, 那麼這個指標必須是一個有效的指標

如果要對一個物件使用 dynamic_cast 運算子轉型為 T &, 那麼這個物件必須是一個左值

如果要將一個物件使用 dynamic_cast 運算子轉型為 T &&, 那麼這個物件不能是一個左值

在上述三種轉型中, object 的型別必須符合以下三個條件中的任何一個 :

  • object 的型別是目標型別的 public 衍生類別
  • object 的型別是目標類別的基礎類別
  • object 就是目標型別

如果符合任意一個條件, 那麼使用 dynamic_cast 的轉型就可以成功

dynamic_cast 轉型的目標為指標型別, 轉型失敗回傳的結果是 nullptr; 若轉型的目標為參考型別, 那麼將會擲出 bac_cast 例外情況. bad_cast 被定義在 <typeinfo> 標頭檔中

對於一個空指標執行 dynamic_cast 轉型是被允許的, 不過結果還是空指標

在可能的情況下, 我們應儘量將指標的 dynamic_cast 放在條件陳述式內, 從而確保型別轉型和結果檢查在同一條表達式中完成, 並且對於外部來說, 其並不可見

由於不存在空的參考, 所以我們應該將向參考型別轉型的 dynamic_cast 放在例外情況檢查機制中

 

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

我們將通過繼承體系中基礎類別的 "==" 運算子友誼函式多載來為 RTTI 演示範例 :

首先, 我們需要考慮傳入的兩個物件是否為同一個型別, 因為不管它是衍生類別還是基礎類別, 它都將被繫結到基礎類別的常數參考上. 此時, 我們將運用到 typeid 運算子

接下來, 我們應該在基礎類別中定義一個用於比較的私用虛擬函式, 並且接下來的每一個衍生類別都對其進行重寫. 因為實際的比較工作由這個成員函式來完成. 對於衍生類別來說, 我們還應該做多一步, 因為傳入的是繫結到基礎類別的參考上的. 所以在比較之前, 應用到 dynamic_cast 進行型別轉型後再進行比較. 而衍生類別中, 用於比較的成員函式中的轉型操作, 我們並不需要將其放入例外檢測的機制中, 因為它絕對不會失效. 這是由於我們會將 typeid 運算子放在比較函式的呼叫之前, 而當通過比較到呼叫成員函式的時候, 已經可以確定兩個物件是相同的型別, 並且都滿足左值和其型別是轉型目標型別的 pubic 基礎類別這樣的條件

範例 :

#include <iostream>


using namespace std;


class Base {

    friend bool operator==(const Base &lhs, const Base &rhs) {

        return typeid(lhs) == typeid(rhs) and lhs.equal_to(rhs);

    }

private:

    int a;

protected:

    virtual bool equal_to(const Base &rhs) const {

        return this->a == rhs.a;

    }

public:

    constexpr explicit Base(int a = 10) noexcept : a {a} {}

    virtual ~Base() noexcept = default;

};

class Derived : public Base {

private:

    int b;

protected:

    bool equal_to(const Base &rhs) const override {

        return Base::equal_to(rhs) and this->b == dynamic_cast<const Derived &>(rhs).b;

    }

public:

    constexpr explicit Derived(int a = 10, int b = 20) noexcept : Base(a), b {b} {}

};


int main(int argc, char *argv[]) {

    Base a, b, c(20);

    Derived d, e, f(20, 30);

    cout << boolalpha << unitbuf;

    auto A {a == b};

    auto B {a == c};

    auto C {a == d};

    auto D {a == f};

    auto E {d == e};

    auto F {e == f};

    cout << A << endl << B << endl << C << endl << D << endl << E << endl << F << endl;

}


/*

 * 輸出結果 :

 * true

 * false

 * false

 * false

 * true

 * false

 * */

 

之前提到的 <typeinfo> 標頭檔中, 還定義了另外一個名稱為 type_info 的類別

type_info 類別的實作是一個 IMDB (實作定義行為), 不同的編碼器之間對這個類別的實作可能會有些差異. 不過, C++ 標準強制規定了 type_info 必須被宣告於 <typeinfo> 標頭檔中, 並且至少存在一下操作 :

  • typeid(T1) == typeid(T2)
  • typeid(T1) != typeid(T2)
  • typeid(T).name() : 回傳一個 C-Style 字串, 表示型別的名稱. 不同型別回傳的字串一定會存在差異
  • typeid(T1).before(typeid(T2)) : 若 T1 位於 T2 之前, 那麼回傳 true; 否則, 回傳 false

type_info 類別沒有預設的建構子, 而且複製建構子、移動建構子、複製指派運算子和移動指派運算子都被宣告為被刪除的函式, 而創建 type_info 物件的唯一方法就是使用 typeid 運算子

對於 type_info 中的 name() 成員函式, 其回傳的字串可能並不是程式碼中所使用的名稱, 這取決於作業系統

type_info 中的 before() 成員函式所採用的順序關係可能取決於編碼器

 

  • 列舉 enum

列舉型別 enum 可以讓我們將一組整型常數表達式組織在一起, 所以列舉型別也是屬於字面值場數表達式. 它通常被用於提高程式碼的可讀性

C++ 中具有不限定作用範圍的列舉. 在 C++ 11 之後, 引入了限定作用範圍的列舉型別

限定作用範圍的列舉由 enum struct 或者 enum class 宣告, 隨後是列舉型別的名稱, 並且在其中宣告列舉成員 :

enum struct e {e1, e2, e2};

不限定作用範圍的列舉由 enum 直接進行宣告 :

enum e {e1, e2, e3};

列舉可以不具名 :

enum {e1, e2 = 9, e3};

對於限定作用範圍的列舉來說, 列舉成員的名稱遵循作用範圍的一般準則. 對於不限定作用範圍的列舉來說, 列舉成員的可視範圍和其所在的可視範圍相同

我們可以為列舉成員提供指定的值, 如果沒有提供指定的值, 那麼其值預設是前面的值 + 1. 如果第一個值沒有被指派指定的值, 那麼其預設值為 0

#include <iostream>


using namespace std;


int main(int argc, char *argv[]) {

    enum A {

        e1, e2, e3 = 10, e4

    };

    cout << e1 << " " << e2 << " " << e3 << " " << e4 << endl;      //輸出結果 : 0 1 10 11

}

列舉成員本身就是一個整型的場數表達式, 所以任何需要用到常數表達式的地方都可以嘗試使用列舉成員. 在 switch 內的 case 條件中, 也可以使用列舉成員; 將列舉型別作為樣板的型別然後傳入一個列舉成員和在類別的定義中初始化列舉型別的靜態成員都是被允許的

只要列舉是具名的, 那麼這個名稱就可以作為一個型別. 想要初始化列舉物件或者為列舉物件進行指派, 必須使用該列舉型別裡面的列舉成員進行初始化. 對於不限定作用範圍的列舉來說, 它們可以被自動地被轉型為內建整型; 對於限定作用範圍的列舉來說, 則需要通過明確型別轉換才可以被轉型為內建整型 :

#include <iostream>


using namespace std;


int main(int argc, char *argv[]) {

    enum A {

        e1, e2, e3 = 10, e4

    };

    A a = 10;       //編碼錯誤

    A a2 = e3;      //OK

    A a3 = static_cast<A>(10);      //OK, 相當於 A a3 = e3;

    enum struct B {

        b1, b2, b3, b4

    };

    B b = 1;        //編碼錯誤

    B b2 = b1;       //編碼錯誤, 找不到 b1, 因為 B 是限定作用範圍的列舉

    B b3 = B::b1;       //OK

    B b4 = static_cast<B>(0);       //OK, 相當於 B b4 = B::b1;

    int i = e1;     //OK, 相當於 int i = 0

    int i2 = B::b1;     //編碼錯誤

    int i3 = static_cast<int>(B::b1);       //OK, 相當於 int i3 = 0;

}

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

enum struct E : unsigned long long {e = 0xffffffffffffffffULL};

若我們沒有為上述 E 指定 unsigned long long 型別的時候, 將會產生編碼錯誤. 因為 E 限定了作用範圍的 enum, 成員的預設型別為 int, 而 E::e 成員顯然超過了 int 型別可容納的最大值

不限定作用範圍的列舉儘管沒有預設型別, 但是成員的潛在型別足夠大, 至少可以容納最大列舉成員的值 :

enum A {e1 = 0xffffffffffffffff};        //預設為 long 型別

不限定作用範圍的列舉的潛在型別因硬體而異

當不限定作用範圍的列舉的某個成員的值任何一個型別都沒有辦法容納的時候, 也將產生編碼錯誤 :

enum A {e = 0xffffffffffffffffffff};        //e 過大, 編碼錯誤

不過我們仍然可以通過為不限定作用範圍的列舉明確宣告其整型型別 :

enum A : long {e, f, g};

明確指定 enum 的成員型別使得我們可以控制不同實現環境中使用的型別. 我們將可以確保在一種實現環境下編碼通過的程式碼在其它產生環境中聲稱的程式碼一致

在 C++ 11 新標準中, 允許我們對列舉型別進行宣告. 但是我們必須明確宣告其成員的型別, 不論是明確地還是隱含地 :

enum A : long long int;

enum struct B : unsigned long long;

enum struct C;        //限定作用範圍的列舉型別, 其成員隱含地被宣告為 int 型別, 其等價於 enum struct C : int;

宣告的列舉型別必須和實作時的型別相匹配

在函式參數與引數匹配的過程總, 列舉型別也遵守其初始化規則 :

enum Tokens {

    S = 80, C = 42675

};


void func(Tokens) {}

void func(int) {}


int main(int argc, char *argv[]) {

    Tokens t = S;

    func(80);       //匹配 void func(int);

    func(t);        //匹配 void func(Tokens);

    func(S);        //匹配 void func(Tokens);

}

儘管不可以將整型的字面值直接傳遞給 enum 的參數, 但是可以將一個不限定作用範圍的列舉型別的物件或其成員傳遞給參數. 此時, enum 的具體型別將被提升到至少為 int 型別. 實際的提升結果由列舉型別的潛在型別所決定 :

enum {

    e1 = 254, e2

};


void func(unsigned char) {}

void func(int) {}


int main(int argc, char *argv[]) {

    unsigned char c {e2};

    func(e1);       //匹配 void func(int);

    func(e2);       //匹配 void func(int);

    func(c);        //匹配 void func(unsigned char);

}

 

  • 類別成員指標

類別成員指標是指向類別中的非靜態成員的指標, 而不是指向類別的物件

指向類別中靜態成員的指標與普通的指標並沒有什麼區別

類別成員指標在初始化的時候, 我們無需指定成員所屬的物件, 只有當使用成員指標的, 才提供成員所屬的物件

類別的屬性成員指標的宣告和初始化的基本形式是 :

T CLASS::*NAME {&CLASS::ATTR_MEM};

其中, T 為指標的型別, 也就是屬性成員的對應型別, CLASS 為指向其成員的類別名稱, NAME 為指標的名稱, ATTR_MEM 為類別中屬性成員的名稱. 值得留意的是, 這裡的 "&" 不可以像函式指標那樣直接省略

在 C++ 11 中, 我們可以使用 auto 或者 delctype 進行簡化

當我們初始化一個成員指標或者為成員指標指派之後, 這個指標並沒有指向任何資料. 成員指標指定了類別成員而非類別物件的成員. 只有在接參考成員指標的時候, 我們才提供物件的信息. 當我們需要接參考指標並且獲取一個物件的成員或者需要物件成員在記憶體中的位址的時候, 我們可以使用指標成員訪問運算子 ".*" 或者 "->*" :

#include <iostream>


using namespace std;


struct A {

    int a;

    explicit constexpr A(int a) : a {a} {}

};

int main(int argc, char *argv[]) {

    int A::*mp = &A::a;

    A a(10);

    cout << a.*mp << endl;      //輸出結果 : 10

    cout << &a->*mp << endl;        //輸出結果 : 10

}

通常情況下, 屬性成員都是私用不對外的. 也就是說, 我們無法通過上述方式直接獲得對應屬性成員指標. 此時, 在設計類別的時候, 我們應該考慮實作一個獲取類別中屬性成員指標的函式 :

#include <iostream>


using namespace std;


class Foo {

private:

    int a;

public:

    explicit constexpr Foo(int a) : a {a} {}

    static constexpr auto get_a_pointer() -> decltype(&Foo::a) {

        return &Foo::a;

    }

};

int main(int argc, char *argv[]) {

    auto mp {Foo::get_a_pointer()};

    Foo f(10);

    cout << f.*mp << endl;      //輸出結果 : 10

}

與屬性成員類似, 成員函式也可以使用一個類別成員指標來指向它. 最簡便的方式也是使用 auto 或者 decltype 進行創建. 不過如果進行明確地宣告而不使用自動推導, 那麼應該將 const 限定符與參考限定符也考慮進來 :

#include <iostream>



using namespace std;



class Foo {

private:

    mutable int a;

public:

    explicit constexpr Foo(int a) : a {a} {}

    int get() const & {

        return this->a;

    }

    int get(int i) const && {

        return ++this->a + i;

    }

};



int main(int argc, char *argv[]) {

    int (Foo::*fp)() const & {&Foo::get};

    int (Foo::*fp_i)(int) const && {&Foo::get};

    Foo a(10);

    cout << (a.*fp)() << endl;      //輸出結果 : 10. (a.*fp) 的括號不可以省略. 因為函式呼叫運算子的優先級要高於成員指標訪問運算子. 去掉之後就相當於 a.*(fp()), 先呼叫 fp() 函式, 然後對其回傳值進行解參考並訪問

    cout << (Foo(10).*fp_i)(0) << endl;     //輸出結果 : 11

    cout << (Foo(10).*fp)() << endl;        //輸出結果 : 10. 這條陳述式並沒有引發編碼錯誤的主要原因是任何物件都可以被繫結到被 const 限定的參考上

}

在不使用 auto 或者 delctype 的情況下, 成員函式的指標實在是太長了, 此時我們也可以使用 typedef 或者 using 去簡化它們

下面, 我們來看一個範例, 用成員指標函式表對類別中的函式進行二次封裝 :

假如我們現在要做一個名稱為 2048 的遊戲, 對於遊戲邏輯部分, 我們需要用類別進行封裝. 遊戲邏輯主要分為新遊戲, 重新開始, 向上移動, 下左移動, 向下移動, 向右移動和撤回操作. 對於遊戲邏輯的執行, 我們並不希望呼叫不同的成員函式, 而是由統一的成員函式指標陣列去管理. 此時, 可以使用列舉型別對遊戲邏輯操作進行編號

範例 :

class Game {

private:

    using fp = void (Game::*)();        //遊戲邏輯函式型別

    static fp funcList[];       //函式陣列表, 所有類別共享一個函式陣列表

protected:

    virtual void up();      //向上移動

    virtual void down();        //向下移動

    virtual void left();        //向左移動

    virtual void right();       //向右移動

    virtual void newGame();     //新遊戲

    virtual void restart();     //重新開始

    virtual void recall();      //撤回

public:

    enum Operator {

        NEW_GAME, RESTART, RECALL, UP, DOWN, LEFT, RIGHT

    };

    void operate(Operator op) {

        (this->*funcList[static_cast<decltype(sizeof(int[0]))>(op)])();

    }

};

Game::fp Game::funcList[] {

    &Game::newGame,

    &Game::restart,

    &Game::recall,

    &Game::up,

    &Game::down,

    &Game::left,

    &Game::right

};      //留意順序要與 Game::Operator 內列舉中宣告的順序一致; 否則, 在呼叫函式的時候會產生錯誤


int main(int argc, char *argv[]) {

    Game game;

    /* 一下三種形式的呼叫都可以 */

    game.operate(game.UP);

    game.operate(Game::DOWN);

    game.operate(Game::Operator::RECALL);

}

有一些標準程式庫的演算法支援傳入一個可呼叫物件, 但是類別成員指標並不是一個合格的可呼叫物件, 因為它要被繫結到一個物件上才可以使用. 因此, 我們無法將成員函式直接傳入標準程式庫的演算法. 我們以在 vector<string> 中搜尋空的 string 為範例 :

#include <vector>

#include <string>


using namespace std;


int main(int argc, char *argv[]) {

    vector<string> vec {"123", "fafd", "", "ppp", "", "::::"};

    auto iterator {find_if(vec.cbegin(), vec.cend(), &string::empty)};      //編碼錯誤

}

如果想要解決這樣的編碼錯誤, 我們可以使用標準程式庫函式物件 function, 我們明確告知 function : empty 是接受 string 型別引數, 並且回傳 bool 的函式. 一般情況下, 執行成員函式的物件將被傳給隱含的 this 引數 :

#include <iostream>
#include <vector>


using namespace std;


template class std::basic_string<char>;     //macOS with Clang++ need this declaration


int main(int argc, char *argv[]) {

    vector<string> vec {"123", "fafd", "", "ppp", "", "::::"};

    function<bool(const string &)> f {&string::empty};

    auto iterator {find_if(vec.cbegin(), vec.cend(), f)};

    cout << iterator - vec.cbegin() << endl;        //輸出結果 : 2

}

當使用成員函式宣告 function 物件的時候, 除了指定其回傳型別和呼叫形式之後, 第一個參數應該表示成員在哪個型別的物件上執行, 這個參數的繫結一般是隱含的. 除此之外, 還需要指明物件是以指標的方式繫結還是參考的形式繫結

還有一種方法就是使用標準程式庫函式 mem_fn(), 它被定義在 <functional> 標頭檔中, 從成員指標聲稱一個客戶叫的物件 :

#include <iostream>
#include <vector>

using namespace std;

template class std::basic_string<char>;     //macOS with Clang++ need this declaration

int main(int argc, char *argv[]) {
    vector<string> vec {"123", "fafd", "", "ppp", "", "::::"};
    auto iterator {find_if(vec.cbegin(), vec.cend(), mem_fn(&string::empty))};
    cout << iterator - vec.cbegin() << endl;        //輸出結果 : 2
}

mem_fn() 函式本質上是通過回傳一個可呼叫物件來實現對成員指標向可呼叫物件的轉型

對於 mem_fn() 函式聲稱的可呼叫物件來說, 它既可以通過物件呼叫, 也可以通過指標呼叫

第三種方法就是使用之前所說過的標準程式庫函式 bind() 生成可以呼叫物件 :

#include <iostream>

#include <vector>


using namespace std;

using namespace std::placeholders;


template class std::basic_string<char>;     //macOS with Clang++ need this declaration


int main(int argc, char *argv[]) {

    vector<string> vec {"123", "fafd", "", "ppp", "", "::::"};

    auto iterator {find_if(vec.cbegin(), vec.cend(), bind(&string::empty, _1))};

    cout << iterator - vec.cbegin() << endl;        //輸出結果 : 2

}

其中, 佔位符 _1 會被 this 隱含地綁定

mem_fn() 函式相似, bind() 生成的可呼叫物件既可以通過物件呼叫, 也可以通過指標呼叫

 

  • 巢狀類別

巢狀類別和外層類別相互獨立, 對於類別內部的訪問都相互遵循訪問說明符

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

 

  • 區域類別

實作在函式內部的類別被稱為區域類別

區域類別的所有成員都必須完整地實作在類別內部

區域類別不允許擁有靜態成員變數

區域類別對外層可視範圍中的名稱的訪問是受到限制的, 區域類別只能訪問外層可視範圍中宣告的型別別名、靜態變數、列舉成員以及具有 constexpr 標識的變數. 也就是說, 如果區域類別被宣告於函式內部, 那麼函式中的區域變數不能被區域類別所訪問

區域類別同樣支援在內部宣告巢狀類別. 此時, 巢狀類別可以在區域類別之外進行實作, 但是必須與區域類別在相同的可視範圍之內

區域類別中的巢狀類別也是一個區域類別, 也要遵循區域類別需要遵守的各種規定. 所以在對區域類別實作的時候, 其成員也必須被實作在類別內部

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

 

  • 等位 union

union 等位是一種特殊的類別, 一個 union 也像一個類別或一個列舉一樣, 可以宣告一種全新的型別

一個 union 可以擁有多個成員變數, 但是不可以擁有參考型別的成員

在 C++ 11 新標準中, 擁有建構子和解構子的型別也可以稱為 union 的成員

union 可以為其成員指定訪問權限, 即將指定的成員放在指定的成員訪問說明符之下

預設情況之下, union 的成員和 struct 一樣是 public

當給 union 每一個成員指派具體值的時候, 其它成員的狀態可能會和這個值有關, 但是也可能是未定的狀態

一個 unino 中不管擁有多少成員, 都全部使用剛好的記憶體來存儲. 這個記憶體的大小由 union 中最大的型別決定

union 不能繼承自其它類別, 也不可以作為其它類別的基礎類別. 所以, 在 union 不可以存在虛擬函式

union 也支援自訂建構子和解構子

當想要方便地表示一組不同型別相互排斥的值的時候, 就可以使用 union :

union Data {
    char c;

    int i;

    long l;

    double d;

};

上述定義的 Data 中, 值的型別可能是 charintlongdouble 中的任意一種

預設情況下, union 的值是未初始化的

當使用 union 宣告新物件的時候, 也可以使用花括號進行明確初始化

#include <iostream>


using namespace std;


int main(int argc, char *argv[]) {

    union Data {

        char c;

        int i;

        long l;

        double d;

    };

    Data token {'a'};

    cout << token.c << endl;

    cout << token.i << endl;

    cout << token.l << endl;

    cout << token.d << endl;

}


/*

 * 輸出結果 :

 * a

 * 97

 * 97

 * 4.79244e - 322

 * */

union 也可以是不具名的. 當實作一個不具名的 union 的時候, 編碼器會自動幫我們創建一個物件, 在不具名 union 內部的所有成員在其所在的作用範圍內都可以直接訪問 :

#include <iostream>


using namespace std;


int main(int argc, char *argv[]) {

    union {

        int i;

        char c;

        double d;

    };

    i = 108;

    cout << i << endl << c << endl << d << endl;

}


/*

 * 輸出結果 :

 * 108

 * l

 * 2.122e - 314

 * */

不具名 union 不可以包含私用的成員, 也不能為其宣告任何的成員函式

union 內部的成員都是內建型別的時候, 編碼器會按照成員的順序合成預設的建構子或者複製建構子. 但是如果 union 內部含有類別型別的成員, 並且該成員對應的型別自訂了預設的建構子或者複製建構子, 那麼 union 對應的合成版本將被編碼器宣告為被刪除的函式

對於擁有類別型別成員的 union 來說, 想要將 union 改為類別型別的成員或者為類別型別的成員指派其它值的時候, 必須分別建構或者解構該類別型別的成員, 即當我們想將 union 的值改為類別型別的成員的對應值的時候, 必須使用該類別的建構子

例如, 若一個 union 含有 string 型別的成員, 但是 union 並沒有自訂建構子或者自訂複製建構子, 那麼這個 union 的預設建構子和複製建構子都將被編碼器宣告為被刪除的函式. 若任何一個類別中包含由這樣一個 union 型別的成員, 那麼這個類別的對應的合成版本的操作也將被宣告為被刪除的

對於 union 來說, 想要建構或者解構類別型別的成員非常複雜, 因此我們通常將含有類別型別成員的 union 巢狀在一個類別中, 這個類別負責對 union 與類別型別有關的成員進行狀態轉換, 並且在這個類別中, 巢狀 union 將會被設定為不具名 union. 在此之後, 我們需要在類別中增加一個列舉型別的成員, 將其的狀態設定為類別中 union 目前存儲的值. 這樣, 這個列舉成員就成了 union 的判別式 :

class Union {

private:

    enum {

        UNSIGNED_LONG_LONG, CHAR, DOUBLE, STD_STRING

    } token;        //判別式, 不具名的列舉

    union {

        unsigned long long i;

        char c;

        double d;

        string s;

    };

    void copy(const Union &rhs) {

        switch(rhs.token) {

            case UNSIGNED_LONG_LONG:

                this->i = rhs.i;

                break;

            case CHAR:

                this->c = rhs.c;

                break;

            case DOUBLE:

                this->d = rhs.d;

                break;

            case STD_STRING:

                new (&this->s) string(rhs.s);       //使用放置 new 表達式在自己專屬的記憶體中配置新的物件

                break;

            default:

                break;

        }

    }

public:

    constexpr Union() : token {UNSIGNED_LONG_LONG}, i {0} {}

    Union(const Union &rhs) : token {rhs.token} {

        this->copy(rhs);

    }

    Union &operator=(const Union &rhs) {

        if(this->token == STD_STRING and rhs.token not_eq STD_STRING) {

            this->s.~string();

        }else if(this->token == STD_STRING and rhs.token == STD_STRING) {

            this->s = rhs.s;

            return *this;

        }

        this->copy(rhs);

        this->token = rhs.token;

        return *this;

    }

    Union &operator=(const string &str) {

        if(this->token == STD_STRING) {

            this->s = str;

            return *this;

        }

        new (&this->s) string(s);

        this->token = STD_STRING;

        return *this;

    }

    Union &operator=(char c) {

        if(this->token == STD_STRING) {

            this->s.~string();

        }

        this->c = c;

        this->token = CHAR;

        return *this;

    }

    Union &operator=(unsigned long long i) {

        if(this->token == STD_STRING) {

            this->s.~string();

        }

        this->i = i;

        this->token = UNSIGNED_LONG_LONG;

        return *this;

    }

    Union &operator=(double d) {

        if(this->token == STD_STRING) {

            this->s.~string();

        }

        this->d = d;

        this->token = DOUBLE;

        return *this;

    }

};

 

  • 不可攜特性

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

通常算數型別的大小在每一台硬體中都可能不同

1. 位元欄位

當一個程式向其它程式或者硬體傳送資料的時候, 通常會使用二進制, 此時會用到其中一個不可攜的特性, 即位元欄位

類別可以將其成員變數宣告為位元欄位. 位元欄位在記憶體中的分佈是和硬體相關的

位元欄位只能是整型型別或者列舉型別, 由於帶號數的位元欄位是由具體的實作而且定的, 所以通常情況下使用的是無號數的型別來保存一個位元欄位 :

T NAME : CONSTANT_EXPRESSION;

其中, T 為型別, NAME 為位元欄位名稱, 冒號後面必須是一個常數表達式, 用於指定所占的二進制位元數

取位址運算子不可以作用於位元欄位. 即位元欄位被宣告在類別內, 任何指標都無法指向它們

#include <iostream>


using namespace std;

struct Bit {

    unsigned b1 : 1;

    unsigned b2 : 2;

    unsigned b3 : 1;

    unsigned long b4 : 1;

};

int main(int argc, char *argv[]) {

    cout << sizeof(Bit) << endl;        //輸出結果 : 8

}

2. volatile 限定符

與硬體相關的程式可能會包含幾個變數, 這些變數的具體值由程式之外的過程控制. 流入一個程式可能包含一個由作業系統時鐘來控制更新的變數. 當變數的值可能被程式之外的控制或者檢測之外被改變的時候, 應該將變數宣告為 volatile 變數. 它告訴編碼器不應該對這樣的變數進行任何優化

volatile 限定符的使用和 const 限定符一樣 :

volatile T a;

volatile T *p;

volatile T *volatile p;

volatile T array[];

當其限定一個物件的時候, 那麼這個物件的每一個成員都將被 volatile 限定符所限定

一個變數, 它可能同時被 constvolatile 所限定, 而且 C++ 標準對於 constvolatile 的宣告順序並沒有特殊的要求

一個類別的成員函式也可以被 volatile 所限定. 當一個類別的成員函式被 volatile 所限定的時候, 它只可以被 voaltile 物件所呼叫

const 限定不相同的是 :

  • 我們只能將一個 volatile 物件的記憶體位址指派給一個 volatile 的指標; 只有某個參考被限定 voaltile 的時候, 我們才能用一個 volatile 物件對其進行初始化
  • 類別中合成的複製建構子、複製指派運算子、移動建構子、移動指派運算子, 我們不可以用它們初始化一個帶有 volatile 限定的物件或為一個帶有 volatile 限定的物件指派. 因為由編碼器合成的對應操作, 其參數型別都是不帶有 volatile 限定符的. 所以當希望一個類別擁有給帶有 volatile 限定的物件指派或者初始化的能力的時候, 我們需要自訂對應的操作
class Foo {

public:

    Foo(const volatile Foo &);

    Foo(volatile Foo &&);

    Foo &operator=(const volatile Foo &);

    Foo &operator=(volatile &&);

};

volatile 的確切含義是和硬體相連的, 因此需要詳細閱讀編碼器文檔來理解

對帶有 volatile 的程式進行遷移 (遷移到其它硬體上或者編碼器上) 的時候, 通常需要對程式碼作出相應的改變

3. 連結指示 extern

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

C++ 使用連結指示 extern + 字面值字串來表示對應的程式設計語言的名稱. 它具有兩種方式, 一種是單個地連結, 另外一種是複合的連結. 連結指示不可以出現在類別與函式的內部 :

extern "C" size_t strlen(const char *);

extern "C" char *strcat(char *, const char *);

上述程式碼為單個連結, 與其等家的複合形式為 :

extern "C" {
 
   size_t strlen(const char *);

   char *strcat(char *, const char *);

}

對於指示連結的語言, 必須由編碼器進行支援. 我們必須有權訪問該語言的編碼器並且這個編碼器與 C++ 的編碼器應當是相互相容的

儘管複合宣告中由花括號, 但是這並不代表可視範圍, 宣告在花括號之外仍然可見 :

#include <iostream>


using namespace std;


extern "C" {

#include <math.h>       //math.h 中所有的函式都會被認為由 C 進行編碼

}


int main(int argc, char *argv[]) {

    cout << pow(10, 20) << endl;        //輸出結果 : 1e + 20

}

指示連結支援巢狀, 因此如果標頭檔中自帶了指示連結, 那麼該指示連結內宣告的不會受影響

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

編寫函式所用的語言也是函式型別的一部分, 因此若想要宣告其它語言編寫的函式的指標, 也應該明確函式本身的指示連結 :

extern "C" void (*fp)(void);        //一個指向 C 語言編碼的回傳型別為 void 並且不接受任何引數的函式指標

void (*fcpp)();     //一個指向 C++ 語言編碼的回傳型別為 void 並且不接受任何引數的函式指標

對於上述的指標, 如果存在如下的指派 :

fcpp = f;

嚴格來說, 這樣的指派是不被允許的. 因為兩個函式時由不同語言所編碼的. 但是由於 C 和 C++ 的相容性, 有些編碼器可能會通過上述程式碼的編碼. 但是上述程式碼嚴格來說是非法的

當使用指示連結的時候, 不但對函式宣告有效, 而且對於回傳型別和參數列表型別的函式指標也有效 :

extern "C" void f(int (*)(int *));        //f 為一個 C 語言編碼的函式, 其參數也是指向由 C 語言編碼的函式指標

在使用 f 指標的時候, 我們應該為其指派一個由 C 語言編碼的函式指標或者 C 語言編碼的函式

因為指示連結對宣告中的所有函式都有效, 因此如果我們希望給一個 C++ 函式傳入一個 C 語言編碼的函式的時候, 需要使用型別別名 :

extern "C" typedef void F(int *);

void f(F *);        //C++ 函式

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

extern "C" int f(int) {

    return 42;

}

上述程式碼中的 f() 函式可以被 C 語言程式所呼叫, 編碼器將其生成指定語言的程式碼

單被多種語言共享的函式的回傳型別和參數列表受到很多約束. 例如, C 並不太可能接受一個來自 C++ 的型別. 因為有些在 C++ 中獨有的語言特性無法被 C 語言所理解

有時候, 在 C 和 C++ 編碼同一個檔案的時候, 需要對 C++ 程式碼作出一些處理, 即使用 C++ 巨集標識符 : __cplusplus :

#ifdef __cplusplus

extern "C"

#endif

int strcmp(const char *, const char *);

指示連結與多載函式的相互作用依賴於目標語言. 如果目標語言支援函式的多載, 那麼該語言實現指示連結的編碼器也很可能支援多載這些來自於 C++ 的函式

C 語言的指示連結只支援宣告多載函式的其中一個, 因為 C 語言並不支援函式的多載. 如果由一組同名的函式, 其中一個來自 C 語言, 那麼其它必定要宣告為來自 C++ 語言

C++ 學習筆記-Jonny'Blog