C++是一門古老的語言,但仍然在不間斷更新中,不斷引用新特性。但與此同時(shí) C++又甩不掉巨大的歷史包袱,并且 C++的設(shè)計(jì)初衷和理念造成了 C++異常復(fù)雜,還出現(xiàn)了很多不合理的“缺陷”。
本文主要有 3 個(gè)目的:
C++有一個(gè)很大的歷史包袱,就是 C 語言。C 語言誕生時(shí)間很早,并且它是為了編寫 OS 而誕生的,語法更加底層。有人說,C 并不是針對(duì)程序員友好的語言,而是針對(duì)編譯期友好的語言。有些場景在 C 語言本身可能并沒有什么不合理,但放到 C++當(dāng)中會(huì)“爆炸”,或者說,會(huì)迅速變成一種“缺陷”,讓人異常費(fèi)解。
C++在演變過程中一直在吸收其他語言的優(yōu)勢,不斷提供新的語法、工具來進(jìn)行優(yōu)化。但為了兼容性(不僅僅是語法的兼容,還有一些設(shè)計(jì)理念的兼容),還是會(huì)留下很多坑。
數(shù)組本身其實(shí)沒有什么問題,這種語法也非常常用,主要是表示連續(xù)一組相同的數(shù)據(jù)構(gòu)成的集合。但數(shù)組類型在待遇上卻和其他類型(比如說結(jié)構(gòu)體)非常不一樣。
我們知道,結(jié)構(gòu)體類型是可以很輕松的復(fù)制的,比如說:
struct St {
int m1;
double m2;
};
void demo() {
St st1;
St st2 = st1; // OK
St st3;
st1 = st3; // OK
}
但數(shù)組卻并不可以,比如:
int arr1[5];
int arr2[5] = arr1; // ERR
明明這里 arr2 和 arr1 同為int[5]
類型,但是并不支持復(fù)制。照理說,數(shù)組應(yīng)當(dāng)比結(jié)構(gòu)體更加適合復(fù)制場景,因?yàn)樾枨笫呛苊鞔_的,就是元素按位復(fù)制。
由于數(shù)組不可以復(fù)制,導(dǎo)致了數(shù)組同樣不支持傳參,因此我們只能采用“首地址+長度”的方式來傳遞數(shù)組:
void f1(int *arr, size_t size) {}
void demo() {
int arr[5];
f1(arr, 5);
}
而為了方便程序員進(jìn)行這種方式的傳參,C 又做了額外的 2 件事:
int[5]
類型,傳參時(shí)自動(dòng)轉(zhuǎn)換為int *
類型)void f(int *arr);
void f(int arr[]);
void f(int arr[5]);
void f(int arr[100]);
所以這里非常容易誤導(dǎo)人的就在這個(gè)語法糖中,無論中括號(hào)里寫多少,或者不寫,這個(gè)值都是會(huì)被忽略的,要想知道數(shù)組的邊界,你就必須要通過額外的參數(shù)來傳遞。
但通過參數(shù)傳遞這是一種軟約束,你無法保證調(diào)用者傳的就是數(shù)組元素個(gè)數(shù),這里的危害詳見后面“指針偏移”的章節(jié)。
之所以 C 的數(shù)組會(huì)出現(xiàn)這種奇怪現(xiàn)象,我猜測,作者考慮的是數(shù)組的實(shí)際使用場景,是經(jīng)常會(huì)進(jìn)行切段截取的,也就是說,一個(gè)數(shù)組類型并不總是完全整體使用,我們可能更多時(shí)候用的是其中的一段。舉個(gè)簡單的例子,如果數(shù)組是整體復(fù)制、傳遞的話,做數(shù)組排序遞歸的時(shí)候會(huì)不會(huì)很尷尬?首先,排序函數(shù)的參數(shù)難以書寫,因?yàn)橐付〝?shù)組個(gè)數(shù),我們總不能針對(duì)于 1,2,3,4,5,6,...元素個(gè)數(shù)的數(shù)組都分別寫一個(gè)排序函數(shù)吧?其次,如果取子數(shù)組就會(huì)復(fù)制出一個(gè)新數(shù)組的話,也就不能對(duì)原數(shù)組進(jìn)行排序了。
所以綜合考慮,干脆這里就不支持復(fù)制,強(qiáng)迫程序員使用指針+長度這種方式來操作數(shù)組,反而更加符合數(shù)組的實(shí)際使用場景。
當(dāng)然了,在 C++中有了引用語法,我們還是可以把數(shù)組類型進(jìn)行傳遞的,比如:
void f1(int (&arr)[5]); // 必須傳int[5]類型
void demo() {
int arr1[5];
int arr2[8];
f1(arr1); // OK
f1(arr2); // ERR
}
但絕大多數(shù)的場景似乎都不會(huì)這樣去用。一些新興語言(比如說 Go)就注意到了這一點(diǎn),因此將其進(jìn)行了區(qū)分。在 Go 語言中,區(qū)分了“數(shù)組”和“切片”的概念,數(shù)組就是長度固定的,整體來傳遞;而切片則類似于首地址+長度的方式傳遞(只不過沒有單獨(dú)用參數(shù),而是用 len 函數(shù)來獲?。?/p>
func f1(arr [5]int) {
}
func f2(arr []int) {
}
上面例子里,f1 就必須傳遞長度是 5 的數(shù)組類型,而 f2 則可以傳遞任意長度的切片類型。
而 C++其實(shí)也注意到了這一點(diǎn),但由于兼容問題,它只能通過 STL 提供容器的方式來解決,std::array
就是定長數(shù)組,而std::vector
就是變長數(shù)組,跟上述 Go 語言中的數(shù)組和切片的概念是基本類似的。這也是 C++中更加推薦使用 vector 而不是 C 風(fēng)格數(shù)組的原因。
C/C++中的類型說明符其實(shí)設(shè)計(jì)得很不合理,除了最簡單的變量定義:
int a; // 定義一個(gè)int類型的變量a
上面這個(gè)還是很清晰明了的,但稍微復(fù)雜一點(diǎn)的,就比較奇怪了:
int arr[5]; // 定義一個(gè)int[5]類型的變量arr
arr 明明是int[5]
類型,但是這里的 int 和[5]卻并沒有寫到一起,如果這個(gè)還不算很容易造成迷惑的話,那來看看下面的:
int *a1[5]; // 定義了一個(gè)數(shù)組
int (*a2)[5]; // 定義了一個(gè)指針
a1 是int *[5]
類型,表示 a1 是個(gè)數(shù)組,有 5 個(gè)元素,每個(gè)元素都是指針類型的。
a2 是int (*)[5]
類型,是一個(gè)指針,指針指向了一個(gè)int[5]
類型的數(shù)組。
這里離譜的就在這個(gè)int (*)[5]
類型上,也就是說,“指向int[5]
類型的指針”并不是int[5]*
,而是int (*)[5]
,類型說明符是從里往外描述的,而不是從左往右。
這里的另一個(gè)問題就是,C/C++并沒有把“定義變量”和“變量的類型”這兩件事分開,而是用類型說明符來同時(shí)承擔(dān)了。也就是說,“定義一個(gè) int 類型變量”這件事中,int 這一個(gè)關(guān)鍵字不僅表示“int 類型”,還表示了“定義變量”這個(gè)意義。這件事放在定義變量這件事上可能還不算明顯,但放到定義函數(shù)上就不一樣了:
int f1();
上面這個(gè)例子中,int 和()共同表示了“定義函數(shù)”這個(gè)意義。也就是說,看到 int 這個(gè)關(guān)鍵字,并不一定是表示定義變量,還有可能是定義函數(shù),定義函數(shù)時(shí) int 表示了函數(shù)的返回值的類型。
正是由于 C/C++中,類型說明符具有多重含義,才造成一些復(fù)雜語法簡直讓人崩潰,比如說定義高階函數(shù):
// 輸入一個(gè)函數(shù),輸出這個(gè)函數(shù)的導(dǎo)函數(shù)
double (*DC(double (*)(double)))(double);
DC 是一個(gè)函數(shù),它有一個(gè)參數(shù),是double (*)(double)
類型的函數(shù)指針,它的返回值是一個(gè)double (*)(double)
類型的函數(shù)指針。但從直觀性上來說,上面的寫法完全毫無可讀性,如果沒有那一行注釋,相信大家很難看得出這個(gè)語法到底是在做什么。
C++引入了返回值右置的語法,從一定程度上可以解決這個(gè)問題:
auto f1() -> int;
auto DC(auto (*)(double) -> double) -> auto (*)(double) -> double;
但用 auto 作為占位符仍然還是有些突兀和晦澀的。
我們來看一看其他語言是如何彌補(bǔ)這個(gè)缺陷的,最簡單的做法就是把“類型”和“動(dòng)作”這兩件事分開,用不同的關(guān)鍵字來表示。 Go 語言:
// 定義變量
var a1 int
var a2 []int
var a3 *int
var a4 []*int // 元素為指針的數(shù)組
var a5 *[]int // 數(shù)組的指針
// 定義函數(shù)
func f1() {
}
func f2() int {
return 0
}
// 高階函數(shù)
func DC(f func(float64)float64) func(float64)float64 {
}
Swift 語言:
// 定義變量
var a1: Int
var a2: [Int]
// 定義函數(shù)
func f1() {
}
func f2() -> Int {
return 0
}
// 高階函數(shù)
func DC(f: (Double, Double)->Double) -> (Double, Double)->Double {
}
JavaScript 語言:
// 定義變量
var a1 = 0
var a2 = [1, 2, 3]
// 定義函數(shù)
function f1() {}
function f2() {
return 0
}
// 高階函數(shù)
function DC(f) {
return function(x) {
//...
}
}
指針的偏移運(yùn)算讓指針操作有了較大的自由度,但同時(shí)也會(huì)引入越界問題:
int arr[5];
int *p1 = arr + 5;
*p1 = 10// 越界
int a = 0;
int *p2 = &a;
a[1] = 10; // 越界
換句話說,指針的偏移是完全隨意的,靜態(tài)檢測永遠(yuǎn)不會(huì)去判斷當(dāng)前指針的位置是否合法。這個(gè)與之前章節(jié)提到的數(shù)組傳參的問題結(jié)合起來,會(huì)更加容易發(fā)生并且更加不容易發(fā)現(xiàn):
void f(int *arr, size_t size) {}
void demo() {
int arr[5];
f(arr, 6); // 可能導(dǎo)致越界
}
因?yàn)閰?shù)中的值和數(shù)組的實(shí)際長度并沒有要求強(qiáng)一致。
在其他語言中,有的語言(例如 java、C#)直接取消了指針的相關(guān)語法,但由此就必須引入“值類型”和“引用類型”的概念。 例如在 java 中,存在“實(shí)”和“名”的概念:
public static void Demo() {
int[] arr = new int[10];
int[] arr2 = arr; // “名”的復(fù)制,淺復(fù)制
int[] arr3 = Arrays.copyOf(arr, arr.length); // 用庫方法進(jìn)行深復(fù)制
}
本質(zhì)上來說,這個(gè)“名”就是??臻g上的一個(gè)指針,而“實(shí)”則是堆空間中的實(shí)際數(shù)據(jù)。如果取消指針概念的話,就要強(qiáng)行區(qū)分哪些類型是“值類型”,會(huì)完全復(fù)制,哪些是“引用類型”,只會(huì)淺復(fù)制。
C#中的結(jié)構(gòu)體和類的概念恰好如此,結(jié)構(gòu)體是值類型,整體復(fù)制,而類是引用類型,要用庫函數(shù)來復(fù)制。
而還有一些語言保留了指針的概念(例如 Go、Swift),但僅僅用于明確指向和引用的含義,并不提供指針偏移運(yùn)算,來防止出現(xiàn)越界問題。例如 go 中:
func Demo() {
var a int
var p *int
p = &a // OK
r1 := *p // 直接解指針是OK的
r2 := *(p + 1) // ERR,指針不可以偏移
}
swift 中雖然仍然支持指針,但非常弱化了它的概念,從語法本身就能看出,不到迫不得已并不推薦使用:
func f1(_ ptr: UnsafeMutablePointer<Int>) {
ptr.pointee += 1 // 給指針?biāo)赶虻闹导?
}
func demo() {
var a: Int = 5
f1(&a)
}
OC 中的指針更加特殊和“奇葩”,首先,OC 完全保留了 C 中的指針用法,而額外擴(kuò)展的“類”類型則不允許出現(xiàn)在棧中,也就是說,所有對(duì)象都強(qiáng)制放在堆中,棧上只保留指針對(duì)其引用。雖然 OC 中的指針仍然是 C 指針,但由于操作對(duì)象的“奇葩”語法,倒是并不需要太擔(dān)心指針偏移的問題。
void demo() {
NSObject *obj = [[NSObject alloc] init];
// 例如調(diào)用obj的description方法
NSString *desc = [obj description];
// 指針仍可偏移,但幾乎不會(huì)有人這樣來寫:
[(obj+1) description]; // 也會(huì)越界
}
隱式類型轉(zhuǎn)換在一些場景下會(huì)讓程序更加簡潔,降低代碼編寫難度。比如說下面這些場景:
double a = 5; // int->double
int b = a * a; // double->int
int c = '5' - '0'; // char->int
但是有的時(shí)候隱式類型轉(zhuǎn)化卻會(huì)引發(fā)很奇怪的問題,比如說:
#define ARR_SIZE(arr) (sizeof(arr) / sizeof(arr[0]))
void f1() {
int arr[5];
size_t size = ARR_SIZE(arr); // OK
}
void f2(int arr[]) {
size_t size = ARR_SIZE(arr); // WRONG
}
結(jié)合之前所說,函數(shù)參數(shù)中的數(shù)組其實(shí)是數(shù)組首元素指針的語法糖,所以f2
中的arr
其實(shí)是int *
類型,這時(shí)候再對(duì)其進(jìn)行sizeof
運(yùn)算,結(jié)果是指針的大小,而并非數(shù)組的大小。如果程序員不能意識(shí)到這里發(fā)生了int [N]
->int *
的隱式類型轉(zhuǎn)換,那么就可能出錯(cuò)。
還有一些隱式類型轉(zhuǎn)換也很離譜,比如說:
int a = 5;
int b = a > 2; // 可能原本想寫a / 2,把/寫成了>
這里發(fā)生的隱式轉(zhuǎn)換是 bool->int,同樣可能不符合預(yù)期。關(guān)于布爾類型詳見后面章節(jié)。 C 中的這些隱式轉(zhuǎn)換可能影響并不算大,但拓展到 C++中就可能有爆炸性的影響,詳見后面“隱式構(gòu)造”和“多態(tài)轉(zhuǎn)換”的相關(guān)章節(jié)。
C/C++的賦值語句自帶返回值,這一定算得上一大缺陷,在 C 中賦值語句返回值,在 C++中賦值語句返回左值引用。
這件事造成的最大影響就在=
和==
這兩個(gè)符號(hào)上,比如:
int a1, a2;
bool b = a1 = a2;
這里原本想寫b = a1 == a2
,但是錯(cuò)把==
寫成了=
,但編譯是可以完全通過的,因?yàn)?code>a1 = a2本身返回了 a1 的引用,再觸發(fā)一次隱式類型轉(zhuǎn)換,把 bool 轉(zhuǎn)化為 int(這里詳見后面非布爾類型的布爾意義章節(jié))。
更有可能的是寫在 if 表達(dá)式中:
if (a = 1) {
}
可以看到,a = 1
執(zhí)行后 a 的值變?yōu)?1,返回的 a 的值就是 1,所以這里的if
變成了恒為真。
C++為了兼容這一特性,又不得不要求自定義類型要定義賦值函數(shù)
class Test {
public:
Test &operator =(const Test &); // 拷貝賦值函數(shù)
Test &operator =(Test &&); // 移動(dòng)賦值函數(shù)
Test &operator =(int a); // 其他的賦值函數(shù)
};
這里賦值函數(shù)的返回值強(qiáng)制要求定義為當(dāng)前類型的左值引用,一來會(huì)讓人覺得有些無厘頭,記不住這里的寫法,二來在發(fā)生繼承關(guān)系的時(shí)候非常容易忘記處理父類的賦值。
class Base {
public:
Base &operator =(const Base &);
};
class Ch : public Base {
public:
Ch &opeartor =(const Ch &ch) {
this->Base::operator =(ch);
// 或者寫成 *static_cast<Base *>(this) = ch;
// ...
return *this;
}
};
古老一些的 C 系擴(kuò)展語言基本還是保留了賦值語句的返回值(例如 java、OC),但一些新興語言(例如 Go、Swift)則是直接取消了賦值語句的返回值,比如說在 swift 中:
let a = 5
var b: Int
var c: Int
c = (b = a) // ERR
b = a
會(huì)返回Void
,所以再賦值給 c 時(shí)會(huì)報(bào)錯(cuò)
在原始 C 當(dāng)中,其實(shí)并沒有“布爾”類型,所有表示是非都是用 int 來做的。所以,int 類型就賦予了布爾意義,0 表示 false,非 0 都表示 true,由此也誕生了很多“野路子”的編程技巧:
int *p;
if (!p) {} // 指針→bool
while (1) {} // int→bool
int n;
while (~scanf('%d', &n)) {} // int→bool
所有表示判斷邏輯的語法,都可以用非布爾類型的值傳入,這樣的寫法其實(shí)是很反人類直覺的,更嚴(yán)重的問題就是與 true 常量比較的問題。
int judge = 2; // 用了int表示了判斷邏輯
if (judge == true) {} // 但這里的條件其實(shí)是false,因?yàn)閠rue會(huì)轉(zhuǎn)為1,2 == 1是false
正是由于非布爾類型具有了布爾意義,才會(huì)造成一些非常反直覺的事情,比如說:
true + true != true
!!2 == 1
(2 == true) == false
基本上除了 C++和一些弱類型腳本語言(比如 js)以外,其他語言都取消了非布爾類型的布爾意義,要想轉(zhuǎn)換為布爾值,一定要通過布爾運(yùn)算才可以,例如在 Go 中:
func Demo() {
a := 1 // int類型
if (a) { // ERR,if表達(dá)式要求布爾類型
}
if (a != 0) { // OK,通過邏輯運(yùn)算得到布爾類型
}
}
這樣其實(shí)更符合直覺,也可以一定程度上避免出現(xiàn)寫成類似于if (a = 1)
出現(xiàn)的問題。C++中正是由于“賦值語句有返回值”和“非布爾類型有布爾意義”同時(shí)生效,才會(huì)在這里出現(xiàn)問題。
關(guān)于 C/C++到底是強(qiáng)類型語言還是弱類型語言,業(yè)界一直爭論不休。有人認(rèn)為,變量的類型從定義后就不能改變,并且每個(gè)變量都有固定的類型,所以 C/C++應(yīng)該是強(qiáng)類型語言。
但有人持相反意見,是因?yàn)檫@個(gè)類型,僅僅是“表面上”不可變,但其實(shí)是可變的,比如說看下面例程:
int a = 300;
uint8_t *p = reinterpret_cast<uint8_t *>(&a);
*p = 1; // 這里其實(shí)就是把a(bǔ)變成了uint8_t類型
根源就在于,指針的解類型是可以改變的,原本int
類型的變量,我們只要把它的首地址保存下來,然后按照另一種類型來解,那么就可以做到“改變 a 的類型”的目的。
這也就意味著,指針類型是不安全的,因?yàn)槟悴灰欢鼙WC現(xiàn)在解指針的類型和指針指向數(shù)據(jù)的真實(shí)類型是匹配的。
還有更野一點(diǎn)的操作,比如:
struct S1 {
short a, b;
};
struct S2 {
int a;
};
void demo() {
S2 s2;
S1 *p = reinterpret_cast<S1 *>(&s2);
p->a = 2;
p->b = 1;
std::cout << s2.a; // 猜猜這里會(huì)輸出多少?
}
這里的指針類型問題和前面章節(jié)提到的指針偏移問題,綜合起來就是說 C/C++的指針操作的自由度過高,提升了語言的靈活度,同時(shí)也增加了其復(fù)雜度。
如果僅僅在 C 的角度上,后置自增/自減語法并沒有帶來太多的副作用,有時(shí)候在程序中作為一些小技巧反而可以讓程序更加精簡,比如說:
void AttrCnt() {
static int count = 0;
std::cout << count++ << std::endl;
}
但這個(gè)特性繼承到 C++后問題就會(huì)被放大,比如說下面的例子:
for (auto iter = ve.begin(); iter != ve.end(); iter++) {
}
這段代碼看似特別正常,但仔細(xì)想想,iter 作為一個(gè)對(duì)象類型,如果后置++
,一定會(huì)發(fā)生復(fù)制。后置++
原本的目的就是在表達(dá)式的位置先返回原值,表達(dá)式執(zhí)行完后再進(jìn)行自增。但如果放在類類型來說,就必須要臨時(shí)保存一份原本的值。例如:
class Element {
public:
// 前置++
Element &operator ++() {
ele++;
return *this;
}
// 后置++
Element operator ++(int) {
// 為了最終返回原值,所以必需保存一份快照用于返回
Element tmp = *this;
ele++;
return tmp;
}
private:
int ele;
};
這也從側(cè)面解釋了,為什么前置++
要求返回引用,而后置++
則是返回非引用,因?yàn)檫@里需要復(fù)制一份快照用于返回。
那么,寫在 for 循環(huán)中的后置++
就會(huì)平白無故發(fā)生一次復(fù)制,又因?yàn)榉祷刂禌]有接收,再被析構(gòu)。
C++保留的++
和--
的語義,也是因?yàn)樗?code>+=1或-=1
語義并不完全等價(jià)。我們可以用順序迭代器來解釋。對(duì)于順序迭代器(比如說鏈表的迭代器),++
表示取下一個(gè)節(jié)點(diǎn),--
表示取上一個(gè)節(jié)點(diǎn)。而+n
或者-n
則表示偏移了,這種語義更適合隨機(jī)訪問(所以說隨機(jī)迭代器支持+=
和-=
,但順序迭代器只支持++
和--
)。
其他語言的做法基本分兩種,一種就是保留自增/自減語法,但不再提供返回值,也就不用區(qū)分前置和后置,例如 Go:
a := 3
a++ // OK
b := a++ // ERR,自增語句沒有返回值
另一種就是干脆刪除自增/自減語法,只提供普通的操作賦值語句,例如 Swift:
var a = 3
a++ // ERR,沒有這種語法
a += 1 // OK,只能用這種方式自增
這里說的類型長度指的是相同類型在不同環(huán)境下長度不一致的情況,下面總結(jié)表格
類型 | 32 位環(huán)境長度 | 64 位環(huán)境長度 |
---|---|---|
int/unsigned | 4B | 4B |
long/unsigned long | 4B | 8B |
long long/ unsigned long long | 8B | 8B |
由于這里出現(xiàn)了 32 位和 64 位環(huán)境下長度不一致的情況,C 語言特意提供了stdint.h
頭文件(C++中在 cstddef 中引用),定義了定長類型,例如int64_t
在 32 位環(huán)境下其實(shí)是long long
,而在 64 位環(huán)境下其實(shí)是long
。
但這里的問題點(diǎn)在于:
例如uint64_t
在 32 位環(huán)境下對(duì)應(yīng)的格式符是%llu
,但是在 64 位環(huán)境下對(duì)應(yīng)的格式符是%lu
。有一種折中的解決辦法是自定義一個(gè)宏:
#if(sizeof(void*) == 8)
#define u64 '%lu'
#else
#define u64 '%llu'
#endif
void demo() {
uint64_t a;
printf('a='u64, a);
}
但這樣會(huì)讓字符串字面量從中間斷開,非常不直觀。
例如在 64 位環(huán)境下,long
和long long
都是 64 位長,但編譯器會(huì)識(shí)別為不同類型,在一些類型推導(dǎo)的場景會(huì)出現(xiàn)和預(yù)期不一致的情況,例如:
template <typename T>
void func(T t) {}
template <>
void func<int64_t>(int64_t t) {}
void demo() {
long long a;
func(a); // 會(huì)匹配通用模板,而匹配不到特例
}
上述例子表明,func<int64_t>
和func<long long>
是不同實(shí)例,盡管在 64 位環(huán)境下long
和long long
真的看不出什么區(qū)別,但是編譯器就是會(huì)識(shí)別成不同類型。
格式化字符串算是非常經(jīng)典的 C 的產(chǎn)物,不僅是 C++,非常多的語言都是支持這種格式符的,例如 java、Go、python 等等。
但 C++中的格式化字符串可以說完全就是 C 的那一套,根本沒有任何擴(kuò)展。換句話說,除了基本數(shù)據(jù)類型和 0 結(jié)尾的字符串以外,其他任何類型都沒有用于匹配的格式符。
例如,對(duì)于結(jié)構(gòu)體類型、數(shù)組、元組類型等等,都沒法匹配到格式符:
struct Point {
double x, y;
};
void Demo() {
// 打印Point
Point p {1, 2.5};
printf('(%lf,%lf)', p.x, p.y); // 無法直接打印p
// 打印數(shù)組
int arr[] = {1, 2, 3};
for (int i = 0; i < 3; i++) {
printf('%d, ', arr[i]); // 無法直接打印整個(gè)數(shù)組
}
// 打印元組
std::tuple tu(1, 2.5, 'abc');
printf('(%d,%lf,%s)', std::get<0>(tu), std::get<1>(tu), std::get<2>(tu)); // 無法直接打印整個(gè)元組
}
對(duì)于這些組合類型,我們就不得不手動(dòng)去訪問內(nèi)部成員,或者用循環(huán)訪問,非常不方便。
針對(duì)于字符串,還會(huì)有一個(gè)嚴(yán)重的潛在問題,比如:
std::string str = 'abc';
str.push_back('\0');
str.append('abc');
char buf[32];
sprintf(buf, 'str=%s', str.c_str());
由于 str 中出現(xiàn)了'\0'
,如果用%s
格式符來匹配的話,會(huì)在 0 的位置截?cái)啵簿褪钦fbuf
其實(shí)只獲取到了str
中的第一個(gè) abc,第二個(gè) abc 就被丟失了。
而一些其他語言則是擴(kuò)展了格式符功能用于解決上述問題,例如 OC 引入了%@
格式符,用于調(diào)用對(duì)象的description
方法來拼接字符串:
@interface Point2D : NSObject
@property double x;
@property double y;
- (NSString *)description;
@end
@implementation Point2D
- (NSString *)description {
return [[NSString alloc] initWithFormat:@'(%lf, %lf)', self.x, self.y];
}
@end
void Demo() {
Point2D *p = [[Point2D alloc] init];
[p setX:1];
[p setY:2.5];
NSLog(@'p=%@', p); // 會(huì)調(diào)用p的description方法來獲取字符串,用于匹配%@
}
而 Go 語言引入了更加方便的%v
格式符,可以用來匹配任意類型,用它的默認(rèn)方式打印。
type Test struct {
m1 int
m2 float32
}
func Demo() {
a1 := 5
a2 := 2.6
a3 := []int{1, 2, 3}
a4 := '123abc'
a5 := Test{2, 1.5}
fmt.Printf('a1=%v, a2=%v, a3=%v, a4=%v, a5=%v\n', a1, a2, a3, a4, a5)
}
Python 則是用%s
作為萬能格式符來使用:
def Demo():
a1 = 5
a2 = 2.5
a3 = 'abc123'
a4 = [1, 2, 3]
print('%s, %s, %s, %s'%(a1, a2, a3, a4)) #這里沒有特殊格式要求時(shí)都可以用%s來匹配
枚舉類型原本是用于解決固定范圍取值的類型表示,但由于在 C 語言中被定義為了整型類型的一種語法糖,導(dǎo)致枚舉類型的使用上出現(xiàn)了一些問題。
枚舉類型無法先聲明后定義,例如下面這段代碼會(huì)編譯報(bào)錯(cuò):
enum Season;
struct Data {
Season se; // ERR
};
enum Season {
Spring,
Summer,
Autumn,
Winter
};
主要是因?yàn)?code>enum類型是動(dòng)態(tài)選擇基礎(chǔ)類型的,比如這里只有 4 個(gè)取值,那么可能會(huì)選取int16_t
,而如果定義的取值范圍比較大,或者中間出現(xiàn)大枚舉值的成員,那么可能會(huì)選取int32_t
或者int64_t
。也就是說,枚舉類型如果沒定義完,編譯期是不知道它的長度的,因此就沒法前置聲明。
C++中允許指定枚舉的基礎(chǔ)類型,制定后可以前置聲明:
enum Season : int;
struct Data {
Season se; // OK
};
enum Season : int {
Spring,
Summer,
Autumn,
Winter
};
但如果你是在調(diào)別人寫的庫的時(shí)候,人家的枚舉沒有指定基礎(chǔ)類型的話,那你也沒轍了,就是不能前置聲明。
也就是說,我沒有辦法判斷某個(gè)值是不是合法的枚舉值:
enum Season {
Spring,
Summer,
Autumn,
Winter
};
void Demo() {
Season s = static_cast<Season>(5); // 不會(huì)報(bào)錯(cuò)
}
enum Test {
Ele1 = 10,
Ele2,
Ele3 = 10
};
void Demo() {
bool judge = (Ele1 == Ele3); // true
}
但因?yàn)?C++提供了enum class
風(fēng)格的枚舉類型,解決了這兩個(gè)問題,因此這里不再額外討論。
宏這個(gè)東西,完全就是針對(duì)編譯器友好的,編譯器非常方便地在宏的指導(dǎo)下,替換源代碼中的內(nèi)容。但這個(gè)玩意對(duì)程序員(尤其是閱讀代碼的人)來說是極其不友好的,由于是預(yù)處理指令,因此任何的靜態(tài)檢測均無法生效。一個(gè)經(jīng)典的例子就是:
#define MUL(x, y) x * y
void Demo() {
int a = MUL(1 + 2, 3 + 4); // 11
}
因?yàn)楹昃褪呛唵未直┑靥鎿Q而已,并沒有任何邏輯判斷在里面。
宏因?yàn)樗堋昂糜谩保苑浅H菀妆粸E用,下面列舉了一些宏濫用的情況供參考:
#define DEFAULT_MEM \
public: \
int GetX() {return x_;} \
private: \
int x_;
class Test {
DEFAULT_MEM;
public:
void method();
};
這種用法相當(dāng)于屏蔽了內(nèi)部實(shí)現(xiàn),對(duì)閱讀者非常不友好,與此同時(shí)加不加 DEFAULT_MEM 是一種軟約束,實(shí)際開發(fā)時(shí)極容易出錯(cuò)。
再比如這種的:
#define SINGLE_INST(class_name) \
public: \
static class_name &GetInstance() { \
static class_name instance; \
return instance; \
} \
class_name(const class_name&) = delete; \
class_name &operator =(const class_name &) = delete; \
private: \
class_name();
class Test {
SINGLE_INST(Test)
};
這位同學(xué),我理解你是想封裝一下單例的實(shí)現(xiàn),但咱是不是可以考慮一下更好的方式?(比如用模板)
#define strcpy_s(dst, dst_buf_size, src) strcpy(dst, src)
這位同學(xué),咱要是真想寫一個(gè)安全版本的函數(shù),咱就好好去判斷 dst_buf_size 如何?
#define COPY_IF_EXSITS(dst, src, field) \
do { \
if (src.has_##field()) { \
dst.set_##field(dst.field()); \
} \
} while (false)
void Demo() {
Pb1 pb1;
Pb2 pb2;
COPY_IF_EXSITS(pb2, pb1, f1);
COPY_IF_EXSITS(pb2, pb1, f2);
}
這種用宏來做函數(shù)名的拼接看似方便,但最容易出的問題就是類型不一致,加入pb1
和pb2
中雖然都有f1
這個(gè)字段,但類型不一樣,那么這樣用就可能造成類型轉(zhuǎn)換。試想pb1.f1
是uint64_t
類型,而pb2.f1
是uint32_t
類型,這樣做是不是有可能造成數(shù)據(jù)的截?cái)嗄兀?/p>
#define IF(con) if (con) {
#define END_IF }
#define ELIF(con) } else if (con) {
#define ELSE } else {
void Demo() {
int a;
IF(a > 0)
Process1();
ELIF(a < -3)
Process2();
ELSE
Process3();
}
這位同學(xué)你到底是寫 python 寫慣了不適應(yīng) C 語法呢,還是說你為了讓代碼掃描工具掃不出來你的圈復(fù)雜度才出此下策的呢~~
共合體的所有成員共用內(nèi)存空間,也就是說它們的首地址相同。在很多人眼中,共合體僅僅在“多選一”的場景下才會(huì)使用,例如:
union QueryKey {
int id;
char name[16];
};
int Query(const QueryKey &key);
上例中用于查找某個(gè)數(shù)據(jù)的 key,可以通過 id 查找,也可以通過 name,但只能二選一。
這種場景確實(shí)可以使用共合體來節(jié)省空間,但缺點(diǎn)在于,共合體的本質(zhì)就是同一個(gè)數(shù)據(jù)的不同解類型,換句話說,程序是不知道當(dāng)前的數(shù)據(jù)是什么類型的,共合體的成員訪問完全可以用更換解指針類型的方式來處理,例如:
union Un {
int m1;
unsigned char m2;
};
void Demo() {
Un un;
un.m1 = 888;
std::cout << un.m2 << std::endl;
// 等價(jià)于
int n1 = 888;
std::cout << *reinterpret_cast<unsigned char *>(&n1) << std::endl;
}
共合體只不過把有可能需要的解類型提前寫出來罷了。所以說,共合體并不是用來“多選一”的,筆者認(rèn)為這是大家曲解的用法。畢竟真正要做到“多選一”,你就得知道當(dāng)前選的是哪一個(gè),例如:
struct QueryKey {
union {
int id;
char name[16];
} key;
enum {
kCaseId,
kCaseName
} key_case;
};
用過 google protobuf 的讀者一定很熟悉上面的寫法,這個(gè)就是 proto 中oneof
語法的實(shí)現(xiàn)方式。
在 C++17 中提供了std::variant
,正是為了解決“多選一”問題存在的,它其實(shí)并不是為了代替共合體,因?yàn)楣埠象w原本就不是為了這種需求的,把共合體用做“多選一”實(shí)在是有點(diǎn)“屈才”了。
更加貼合共合體本意的用法,是我最早是在閱讀處理網(wǎng)絡(luò)報(bào)文的代碼中看到的,例如某種協(xié)議的報(bào)文有如下規(guī)定(例子是我隨便寫的):
二進(jìn)制位 | 意義 |
---|---|
0~3 | 協(xié)議版本號(hào) |
4~5 | 超時(shí)時(shí)間 |
6 | 協(xié)商次數(shù) |
7 | 保留位,固定 為 0 |
8~15 | 業(yè)務(wù)數(shù)據(jù) |
這里能看出來,整個(gè)報(bào)文有 2 字節(jié),一般的處理時(shí),我們可能只需要關(guān)注這個(gè)報(bào)文的這 2 個(gè)字節(jié)值是多少(比如說用十六進(jìn)制表示),而在排錯(cuò)的時(shí)候,才會(huì)關(guān)注報(bào)文中每一位的含義,因此,“整體數(shù)據(jù)”和“內(nèi)部數(shù)據(jù)”就成為了這段報(bào)文的兩種獲取方式,這種場景下非常適合用共合體:
union Pack {
uint16_t data; // 直接操作報(bào)文數(shù)據(jù)
struct {
unsigned version : 4;
unsigned timeout : 2;
unsigned retry_times : 1;
unsigned block : 1;
uint8_t bus_data;
} part; // 操作報(bào)文內(nèi)部數(shù)據(jù)
};
void Demo() {
// 例如有一個(gè)從網(wǎng)絡(luò)獲取到的報(bào)文
Pack pack;
GetPackFromNetwork(pack);
// 打印一下報(bào)文的值
std::printf('%X', pack.data);
// 更改一下業(yè)務(wù)數(shù)據(jù)
pack.part.bus_data = 0xFF;
// 把報(bào)文內(nèi)容扔到處理流中
DataFlow() << pack.data;
}
因此,這里的需求就是“用兩種方式來訪問同一份數(shù)據(jù)”,才是完全符合共合體定義的用法。
共合體應(yīng)該是 C 語言的特色了,其他任何高級(jí)語言都沒有類似的語法,主要還是因?yàn)?C 語言更加面相底層,C++僅僅是繼承了 C 的語法而已。
先來吐槽一件事,就是 C/C++中const
這個(gè)關(guān)鍵字,這個(gè)名字起的非常非常不好!為什么這樣說呢?const 是 constant 的縮寫,翻譯成中文就是“常量”,但其實(shí)在 C/C++中,const
并不是表示“常量”的意思。
我們先來明確一件事,什么是“常量”,什么是“變量”?常量其實(shí)就是衡量,比如說1
就是常量,它永遠(yuǎn)都是這個(gè)值。再比如'A'
就是個(gè)常量,同樣,它永遠(yuǎn)都是和它 ASCII 碼對(duì)應(yīng)的值。
“變量”其實(shí)是指存儲(chǔ)在內(nèi)存當(dāng)中的數(shù)據(jù),起了一個(gè)名字罷了。如果我們用匯編,則不存在“變量”的概念,而是直接編寫內(nèi)存地址:
mov ax, 05FAh
mov ds, ax
mov al, ds:[3Fh]
但是這個(gè)05FA:3F
地址太突兀了,也很難記,另一個(gè)缺點(diǎn)就是,內(nèi)存地址如果固定了,進(jìn)程加載時(shí)動(dòng)態(tài)分配內(nèi)存的操作空間會(huì)下降(盡管可以通過相對(duì)內(nèi)存的方式,但程序員仍需要管理偏移地址),所以在略高級(jí)一點(diǎn)的語言中,都會(huì)讓程序員有個(gè)更方便的工具來管理內(nèi)存,最簡單的方法就是給內(nèi)存地址起個(gè)名字,然后編譯器來負(fù)責(zé)翻譯成相對(duì)地址。
int a; // 其實(shí)就是讓編譯器幫忙找4字節(jié)的連續(xù)內(nèi)存,并且起了個(gè)名字叫a
所以“變量”其實(shí)指“內(nèi)存變量”,它一定擁有一個(gè)內(nèi)存地址,和可變不可變沒啥關(guān)系。
因此,C 語言中const
用于修飾的一定是“變量”,來控制這個(gè)變量不可變而已。用const
修飾的變量,其實(shí)應(yīng)當(dāng)說是一種“只讀變量”,這跟“常量”根本挨不上。
這就是筆者吐槽這個(gè)const
關(guān)鍵字的原因,你叫個(gè)read_only
之類的不是就沒有歧義了么?
C#就引入了readonly
關(guān)鍵字來表示“只讀變量”,而const
則更像是給常量取了個(gè)別名(可以類比為 C++中的宏定義,或者constexpr
,后面章節(jié)會(huì)詳細(xì)介紹constexpr
):
const int pi = 3.14159; // 常量的別名
readonly int[] arr = new int[]{1, 2, 3}; // 只讀變量
C++由于保留了 C 當(dāng)中的const
關(guān)鍵字,但更希望表達(dá)其“不可變”的含義,因此著重在“左右值”的方向上進(jìn)行了區(qū)分。左右值的概念來源于賦值表達(dá)式:
var = val; // 賦值表達(dá)式
賦值表達(dá)式的左邊表示即將改變的變量,右邊表示從什么地方獲取這個(gè)值。因此,很自然地,右值不會(huì)改變,而左值會(huì)改變。那么在這個(gè)定義下,“常量”自然是只能做右值,因?yàn)槌A績H僅有“值”,并沒有“存儲(chǔ)”或者“地址”的概念。而對(duì)于變量而言,“只讀變量”也只能做右值,原因很簡單,因?yàn)樗恰爸蛔x”的。
雖然常量和只讀變量是不同的含義,但它們都是用來“讀取值”的,也就是用來做右值的,所以,C++引入了“const 引用”的概念來統(tǒng)一這兩點(diǎn)。所謂 const 引用包含了 2 個(gè)方面的含義:
換言之,const 引用可能是引用,也可能只是個(gè)普通變量,如何理解呢?請(qǐng)看例程:
void Demo() {
const int a = 5; // a是一個(gè)只讀變量
const int &r1 = a; // r1是a的引用,所以r1是引用
const int &r2 = 8; // 8是一個(gè)常量,因此r2并不是引用,而是一個(gè)只讀變量
}
也就是說,當(dāng)用一個(gè) const 引用來接收一個(gè)變量的時(shí)候,這時(shí)的引用是真正的引用,其實(shí)在r1
內(nèi)部保存了a
的地址,當(dāng)我們操作r
的時(shí)候,會(huì)通過解指針的語法來訪問到a
const int a = 5;
const int &r1 = a;
std::cout << r1;
// 等價(jià)于
const int *p1 = &a; // 引用初始化其實(shí)是指針的語法糖
std::cout << *p1; // 使用引用其實(shí)是解指針的語法糖
但與此同時(shí),const 引用還可以接收常量,這時(shí),由于常量根本不是變量,自然也不會(huì)有內(nèi)存地址,也就不可能轉(zhuǎn)換成上面那種指針的語法糖。那怎么辦?這時(shí),就只能去重新定義一個(gè)變量來保存這個(gè)常量的值了,所以這時(shí)的 const 引用,其實(shí)根本不是引用,就是一個(gè)普通的只讀變量。
const int &r1 = 8;
// 等價(jià)于
const int c1 = 8; // r1其實(shí)就是個(gè)獨(dú)立的變量,而并不是誰的引用
const 引用的這種設(shè)計(jì),更多考慮的是語義上的,而不是實(shí)現(xiàn)上的。如果我們理解了 const 引用,那么也就不難理解為什么會(huì)有“將亡值”和“隱式構(gòu)造”的問題了,因?yàn)榇钆?const 引用,可以實(shí)現(xiàn)語義上的統(tǒng)一,但代價(jià)就是同一語法可能會(huì)做不同的事,會(huì)令人有疑惑甚至對(duì)人有誤導(dǎo)。
在后面“右值引用”和“因式構(gòu)造”的章節(jié)會(huì)繼續(xù)詳細(xì)介紹它們和 const 引用的聯(lián)動(dòng),以及可能出現(xiàn)的問題。
C++11 的右值引用語法的引入,其實(shí)也完全是針對(duì)于底層實(shí)現(xiàn)的,而不是針對(duì)于上層的語義友好。換句話說,右值引用是為了優(yōu)化性能的,而并不是讓程序變得更易讀的。
右值引用跟 const 引用類似,仍然是同一語法不同意義,并且右值引用的定義強(qiáng)依賴“右值”的定義。根據(jù)上一節(jié)對(duì)“左右值”的定義,我們知道,左右值來源于賦值語句,常量只能做右值,而變量做右值時(shí)僅會(huì)讀取,不會(huì)修改。按照這個(gè)定義來理解,“右值引用”就是對(duì)“右值”的引用了,而右值可能是常量,也可能是變量,那么右值引用自然也是分兩種情況來不同處理:
我們先來看右值引用綁定常量的情況:
int &&r1 = 5; // 右值引用綁定常量
和 const 引用一樣,常量沒有地址,沒有存儲(chǔ)位置,只有值,因此,要把這個(gè)值保存下來的話,同樣得按照“新定義變量”的形式,因此,當(dāng)右值引用綁定常量時(shí),相當(dāng)于定義了一個(gè)普通變量:
int &&r1 = 5;
// 等價(jià)于
int v1 = 5; // r1就是個(gè)普通的int變量而已,并不是引用
所以這時(shí)的右值引用并不是誰的引用,而是一個(gè)普普通通的變量。
我們?cè)賮砜纯从抑狄媒壎ㄗ兞康那闆r: 這里的關(guān)鍵問題在于,什么樣的變量適合用右值引用綁定? 如果對(duì)于普通的變量,C++不允許用右值引用來綁定,但這是為什么呢?
int a = 3;
int &&r = a; // ERR,為什么不允許右值引用綁定普通變量?
我們按照上面對(duì)左右值的分析,當(dāng)一個(gè)變量做右值時(shí),該變量只讀,不會(huì)被修改,那么,“引用”這個(gè)變量自然是想讓引用成為這個(gè)變量的替身,而如果我們希望這里做的事情是“當(dāng)通過這個(gè)引用來操作實(shí)體的時(shí)候,實(shí)體不能被改變”的話,使用 const 引用就已經(jīng)可以達(dá)成目的了,沒必要引入一個(gè)新的語法。
所以,右值引用并不是為了讓引用的對(duì)象只能做右值(這其實(shí)是 const 引用做的事情),相反,右值引用本身是可以做左值的。這就是右值引用最迷惑人的地方,也是筆者認(rèn)為“右值引用”這個(gè)名字取得迷惑人的地方。
右值引用到底是想解決什么問題呢?請(qǐng)看下面示例:
struct Test { // 隨便寫一個(gè)結(jié)構(gòu)體,大家可以腦補(bǔ)這個(gè)里面有很多復(fù)雜的成員
int a, b;
};
Test GetAnObj() { // 一個(gè)函數(shù),返回一個(gè)結(jié)構(gòu)體類型
Test t {1, 2}; // 大家可以腦補(bǔ)這里面做了一些復(fù)雜的操作
return t; // 最終返回了這個(gè)對(duì)象
}
void Demo() {
Test t1 = GetAnObj();
}
我們忽略編譯器的優(yōu)化問題,只分析 C++語言本身。在GetAnObj
函數(shù)內(nèi)部,t
是一個(gè)局部變量,局部變量的生命周期是從創(chuàng)建到當(dāng)前代碼塊結(jié)束,也就是說,當(dāng)GetAnObj
函數(shù)結(jié)束時(shí),這個(gè)t
一定會(huì)被釋放掉。
既然這個(gè)局部變量會(huì)被釋放掉,那么函數(shù)如何返回呢?這就涉及了“值賦值”的問題,假如t
是一個(gè)整數(shù),那么函數(shù)返回的時(shí)候容易理解,就是返回它的值。具體來說,就是把這個(gè)值推到寄存器中,在跳轉(zhuǎn)會(huì)調(diào)用方代碼的時(shí)候,再把寄存器中的值讀出來:
int f1() {
int t = 5;
return t;
}
翻譯成匯編就是:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 5 ; 這里[rbp-4]就是局部變量t
mov eax, DWORD PTR [rbp-4] ; 把t的值放到eax里,作為返回值
pop rbp
ret
之所以能這樣返回,主要就是 eax 放得下 t 的值。但如果 t 是結(jié)構(gòu)體的話,一個(gè) eax 寄存器自然是放不下了,那怎么返回?(這里匯編代碼比較長,而且跟編譯器的優(yōu)化參數(shù)強(qiáng)相關(guān),就不放代碼了,有興趣的讀者可以自己匯編看結(jié)果。)簡單來說,因?yàn)榧拇嫫鞣挪幌抡麄€(gè)數(shù)據(jù),這個(gè)數(shù)據(jù)就只能放到內(nèi)存中,作為一個(gè)臨時(shí)區(qū)域,然后在寄存器里放一個(gè)臨時(shí)區(qū)域的內(nèi)存地址。等函數(shù)返回結(jié)束以后,再把這個(gè)臨時(shí)區(qū)域釋放掉。
那么我們?cè)倩貋砜催@段代碼:
struct Test {
int a, b;
};
Test GetAnObj() {
Test t {1, 2};
return t; // 首先開辟一片臨時(shí)空間,把t復(fù)制過去,再把臨時(shí)空間的地址寫入寄存器
} // 代碼塊結(jié)束,局部變量t被釋放
void Demo() {
Test t1 = GetAnObj(); // 讀取寄存器中的地址,找到臨時(shí)空間,再把臨時(shí)空間的數(shù)據(jù)復(fù)制給t1
// 函數(shù)調(diào)用結(jié)束,臨時(shí)空間釋放
}
那么整個(gè)過程發(fā)生了 2 次復(fù)制和 2 次釋放,如果我們按照程序的實(shí)際行為來改寫一下代碼,那么其實(shí)應(yīng)該是這樣的:
struct Test {
int a, b;
};
void GetAnObj(Test *tmp) { // tmp要指向臨時(shí)空間
Test t{1, 2};
*tmp = t; // 把t復(fù)制給臨時(shí)空間
} // 代碼塊結(jié)束,局部變量t被釋放
void Demo() {
Test *tmp = (Test *)malloc(sizeof(Test)); // 臨時(shí)空間
GetAnObj(tmp); // 讓函數(shù)處理臨時(shí)空間的數(shù)據(jù)
Test t1 = *tmp; // 把臨時(shí)空間的數(shù)據(jù)復(fù)制給這里的局部變量t1
free(tmp); // 釋放臨時(shí)空間
}
如果我真的把代碼寫成這樣,相信一定會(huì)被各位前輩罵死,質(zhì)疑我為啥不直接用出參。的確,用出參是可以解決這種多次無意義復(fù)制的問題,所以 C++11 以前并沒有要去從語法層面來解決,但這樣做就會(huì)讓代碼不得不“面相底層實(shí)現(xiàn)”來編程。C++11 引入的右值引用,就是希望從“語法層面”解決這種問題。
試想,這片非常短命的臨時(shí)空間,究竟是否有必要存在?既然這片空間是用來返回的,返回完就會(huì)被釋放,那我何必還要單獨(dú)再搞個(gè)變量來接收,如果這片臨時(shí)空間可以持續(xù)使用的話,不就可以減少一次復(fù)制嗎?于是,“右值引用”的概念被引入。
struct Test {
int a, b;
};
Test GetAnObj() {
Test t {1, 2};
return t; // t會(huì)復(fù)制給臨時(shí)空間
}
void Demo() {
Test &&t1 = GetAnObj(); // 我設(shè)法引用這篇臨時(shí)空間,并且讓他不要立刻釋放
// 臨時(shí)空間被t1引用了,并不會(huì)立刻釋放
} // 等代碼塊結(jié)束,t1被釋放了,才讓臨時(shí)空間釋放
所以,右值引用的目的是為了延長臨時(shí)變量的生命周期,如果我們把函數(shù)返回的臨時(shí)空間中的對(duì)象視為“臨時(shí)對(duì)象”的話,正常情況下,當(dāng)函數(shù)調(diào)用結(jié)束以后,臨時(shí)對(duì)象就會(huì)被釋放,所以我們管這個(gè)短命的對(duì)象叫做“將亡對(duì)象”,簡單粗暴理解為“馬上就要掛了的對(duì)象”,它的使命就是讓外部的t1
復(fù)制一下,然后它就死了,所以這時(shí)候你對(duì)他做什么操作都是沒意義的,他就是讓人來復(fù)制的,自然就是個(gè)只讀的值了,所以才被歸結(jié)為“右值”。我們?yōu)榱俗屗灰滥敲纯欤o它延長了生命周期,因此使用了右值引用。所以,右值引用是不是應(yīng)該叫“續(xù)命引用”更加合適呢~
當(dāng)用右值引用捕獲一個(gè)將亡對(duì)象的時(shí)候,對(duì)象的生命周期從“將亡”變成了“與右值引用共存亡”,這就是右值引用的根本意義,這時(shí)的右值引用就是“將亡對(duì)象的引用”,又因?yàn)檫@時(shí)的將亡對(duì)象已經(jīng)不再“將亡”了,那它既然不再“將亡”,我們?cè)賹?duì)它進(jìn)行操作(改變成員的值)自然就是有意義的啦,所以,這里的右值引用其實(shí)就等價(jià)于一個(gè)普通的引用而已。既然就是個(gè)普通的引用,而且沒用 const 修飾,自然,可以做左值咯。右值引用做左值的時(shí)候,其實(shí)就是它所指對(duì)象做左值而已。不過又因?yàn)槠胀ㄒ貌⒉粫?huì)影響原本對(duì)象的生命周期,但右值引用會(huì),因此,右值引用更像是一個(gè)普通的變量,但我們要知道,它本質(zhì)上還是引用(底層是指針實(shí)現(xiàn)的)。
總結(jié)來說就是,右值引用綁定常量時(shí)相當(dāng)于“給一個(gè)常量提供了生命周期”,這時(shí)的“右值引用”并不是誰的引用,而是相當(dāng)于一個(gè)普通變量;而右值引用綁定將亡對(duì)象時(shí),相當(dāng)于“給將亡對(duì)象延長了生命周期”,這時(shí)的“右值引用”并不是“右值的引用”,而是“對(duì)需要續(xù)命的對(duì)象”的引用,生命周期變?yōu)榱擞抑狄帽旧淼纳芷冢ɑ蛘呃斫鉃椤敖庸堋绷诉@個(gè)引用的對(duì)象,成為了一個(gè)普通的變量)。
需要知道的是,const 引用也是可以綁定將亡對(duì)象的,正如上文所說,既然將亡對(duì)象定義為了“右值”,也就是只讀不可變的,那么自然就符合 const 引用的語義。
// 省略Test的定義,見上節(jié)
void Demo() {
const Test &t1 = GetAnObj(); // OK
}
這樣看來,const 引用同樣可以讓將亡對(duì)象延長生命周期,但其實(shí)這里的出發(fā)點(diǎn)并不同,const 引用更傾向于“引用一個(gè)不可變的量”,既然這里的將亡對(duì)象是一個(gè)“不可變的值”,那么,我就可以用 const 引用來保存“這個(gè)值”,或者這里的“值”也可以理解為這個(gè)對(duì)象的“快照”。所以,當(dāng)一個(gè) const 引用綁定一個(gè)將亡值時(shí),const 引用相當(dāng)于這個(gè)對(duì)象的“快照”,但背后還是間接地延長了它的生命周期,但只不過是不可變的。
在解釋移動(dòng)語義之前,我們先來看這樣一個(gè)例子:
class Buffer final {
public:
Buffer(size_t size);
Buffer(const Buffer &ob);
~Buffer();
int &at(size_t index);
private:
size_t buf_size_;
int *buf_;
};
Buffer::Buffer(size_t size) : buf_size_(size), buf_(malloc(sizeof(int) * size)) {}
Buffer::Buffer(const Buffer &ob) :buf_size_(ob.buf_size_),
buf_(malloc(sizeof(int) * ob.buf_size_)) {
memcpy(buf_, ob.buf_, ob.buf_size_);
}
Buffer::~Buffer() {
if (buf_ != nullptr) {
free(buf_);
}
}
int &Buffer::at(size_t index) {
return buf_[index];
}
void ProcessBuf(Buffer buf) {
buf.at(2) = 100; // 對(duì)buf做一些操作
}
void Demo() {
ProcessBuf(Buffer{16}); // 創(chuàng)建一個(gè)16個(gè)int的buffer
}
上面這段代碼定義了一個(gè)非常簡單的緩沖區(qū)處理類,ProcessBuf
函數(shù)想做的事是傳進(jìn)來一個(gè) buffer,然后對(duì)這個(gè) buffer 做一些修改的操作,最后可能把這個(gè) buffer 輸出出去之類的(代碼中沒有體現(xiàn),但是一般業(yè)務(wù)肯定會(huì)有)。
如果像上面這樣寫,會(huì)出現(xiàn)什么問題?不難發(fā)現(xiàn)在于ProcessBuf
的參數(shù),這里會(huì)發(fā)生復(fù)制。由于我們?cè)?code>Buffer類中定義了拷貝構(gòu)造函數(shù)來實(shí)現(xiàn)深復(fù)制,那么任何傳入的 buffer 都會(huì)在這里進(jìn)行一次拷貝構(gòu)造(深復(fù)制)。再觀察Demo
中調(diào)用,僅僅是傳了一個(gè)臨時(shí)對(duì)象而已。臨時(shí)對(duì)象本身也是將亡對(duì)象,復(fù)制給buf
后,就會(huì)被釋放,也就是說,我們進(jìn)行了一次無意義的深復(fù)制。
有人可能會(huì)說,那這里參數(shù)用引用能不能解決問題?比如這樣:
void ProcessBuf(Buffer &buf) {
buf.at(2) = 100;
}
void Demo() {
ProcessBuf(Buffer{16}); // ERR,普通引用不可接收將亡對(duì)象
}
所以這里需要我們注意的是,C++當(dāng)中,并不只有在顯式調(diào)用=
的時(shí)候才會(huì)賦值,在函數(shù)傳參的時(shí)候仍然由賦值語義(也就是實(shí)參賦值給形參)。所以上面就相當(dāng)于:
Buffer &buf = Buffer{16}; // ERR
所以自然不合法。那,用 const 引用可以嗎?由于 const 引用可以接收將亡對(duì)象,那自然可以用于傳參,但ProcessBuf
函數(shù)中卻對(duì)對(duì)象進(jìn)行了修改操作,所以 const 引用不能滿足要求:
void ProcessBuf(const Buffer &buf) {
buf.at(2) = 100; // 但是這里會(huì)報(bào)錯(cuò)
}
void Demo() {
ProcessBuf(Buffer{16}); // 這里確實(shí)OK了
}
正如上一節(jié)描述,const 引用傾向于表達(dá)“保存快照”的意義,因此,雖然這個(gè)對(duì)象仍然是放在內(nèi)存中的,但 const 引用并不希望它發(fā)生改變(否則就不叫快照了),因此,這里最合適的,仍然是右值引用:
void ProcessBuf(Buffer &&buf) {
buf.at(2) = 100; // 右值引用完成綁定后,相當(dāng)于普通引用,所以這里操作OK
}
void Demo() {
ProcessBuf(Buffer{16}); // 用右值引用綁定將亡對(duì)象,OK
}
我們?cè)賮砜聪旅娴膱鼍埃?/p>
void Demo() {
Buffer buf1{16};
// 對(duì)buf進(jìn)行一些操作
buf1.at(2) = 50;
// 再把buf傳給ProcessBuf
ProcessBuf(buf1); // ERR,相當(dāng)于Buffer &&buf= buf1;右值引用綁定非將亡對(duì)象
}
因?yàn)橛抑狄檬且獊斫壎▽⑼鰧?duì)象的,但這里的buf1
是Demo
函數(shù)的局部變量,并不是將亡的,所以右值引用不能接受。但如果我有這樣的需求,就是說buf1
我不打算用了,我想把它的控制權(quán)交給ProcessBuf
函數(shù)中的buf
,相當(dāng)于,我主動(dòng)讓buf1
提前“亡”,是否可以強(qiáng)制把它弄成將亡對(duì)象呢?STL 提供了std::move
函數(shù)來完成這件事,“期望強(qiáng)制把一個(gè)對(duì)象變成將亡對(duì)象”:
void Demo() {
Buffer buf1{16};
// 對(duì)buf進(jìn)行一些操作
buf1.at(2) = 50;
// 再把buf傳給ProcessBuf
ProcessBuf(std::move(buf1)); // OK,強(qiáng)制讓buf1將亡,那么右值引用就可以接收
} // 但如果讀者嘗試的話,在這里會(huì)出ERROR
std::move
的本意是提前讓一個(gè)對(duì)象“將亡”,然后把控制權(quán)“移交”給右值引用,所以才叫「move」,也就是“移動(dòng)語義”。但很可惜,C++并不能真正讓一個(gè)對(duì)象提前“亡”,所以這里的“移動(dòng)”僅僅是“語義”上的,并不是實(shí)際的。如果我們看一下std::move
的實(shí)現(xiàn)就知道了:
template <typename T>
constexpr std::remove_reference_t<T> &&move(T &&ref) noexcept {
return static_cast<std::remove_reference_t<T> &&>(ref);
}
如果這里參數(shù)中的&&
符號(hào)讓你懵了的話,可以參考后面“引用折疊”的內(nèi)容,如果對(duì)其他亂七八糟的語法還是沒整明白的話,沒關(guān)系,我來簡化一下:
template <typename T>
T &&move(T &ref) {
return static_cast<T &&>(ref);
}
哈?就這么簡單?是的!真的就這么簡單,這個(gè)std::move
不是什么多高大上的處理,就是簡單把普通引用給強(qiáng)制轉(zhuǎn)換成了右值引用,就這么簡單。
所以,我上線才說“C++并不能真正讓一個(gè)對(duì)象提前亡”,這里的std::move
就是跟編譯器玩了一個(gè)文字游戲罷了。
所以,C++的移動(dòng)語義僅僅是在語義上,在使用時(shí)必須要注意,一旦將一個(gè)對(duì)象 move 給了一個(gè)右值引用,那么不可以再操作原本的對(duì)象,但這種約束是一種軟約束,操作了也并不會(huì)有報(bào)錯(cuò),但是就可能會(huì)出現(xiàn)奇怪的問題。
有了右值引用和移動(dòng)語義,C++還引入了移動(dòng)構(gòu)造和移動(dòng)賦值,這里簡單來解釋一下:
void Demo() {
Buffer buf1{16};
Buffer buf2(std::move(buf1)); // 把buf1強(qiáng)制“亡”,但用它的“遺體”構(gòu)造新的buf2
Buffer buf3{8};
buf3 = std::move(buf2); // 把buf2強(qiáng)制“亡”,把“遺體”轉(zhuǎn)交個(gè)buf3,buf3原本的東西不要了
}
為了解決用一個(gè)將亡對(duì)象來構(gòu)造/賦值另一個(gè)對(duì)象的情況,引入了移動(dòng)構(gòu)造和移動(dòng)賦值函數(shù),既然是用一個(gè)將亡對(duì)象,那么參數(shù)自然是右值引用來接收了。
class Buffer final {
public:
Buffer(size_t size);
Buffer(const Buffer &ob);
Buffer(Buffer &&ob); // 移動(dòng)構(gòu)造函數(shù)
~Buffer();
Buffer &operator =(Buffer &&ob); // 移動(dòng)賦值函數(shù)
int &at(size_t index);
private:
size_t buf_size_;
int *buf_;
};
這里主要考慮的問題是,既然是用將亡對(duì)象來構(gòu)造新對(duì)象,那么我們應(yīng)當(dāng)盡可能多得利用將亡對(duì)象的“遺體”,在將亡對(duì)象中有一個(gè)buf_
指針,指向了一片堆空間,那這片堆空間就可以直接利用起來,而不用再復(fù)制一份了,因此,移動(dòng)構(gòu)造和移動(dòng)賦值應(yīng)該這樣實(shí)現(xiàn):
Buffer::Buffer(Buffer &&ob) : buf_size_(ob.buf_size_), // 基本類型數(shù)據(jù),只能簡單拷貝了
buf_(ob.buf_) { // 直接把ob中指向的堆空間接管過來
// 為了防止ob中的空間被重復(fù)釋放,將其置空
ob.buf_ = nullptr;
}
Buffer &Buffer::operator =(Buffer &&ob) {
// 先把自己原來持有的空間釋放掉
if (buf_ != nullptr) {
free(buf_);
}
// 然后繼承ob的buf_
buf_ = ob.buf_;
// 為了防止ob中的空間被重復(fù)釋放,將其置空
ob.buf_ = nullptr;
}
細(xì)心的讀者應(yīng)該能發(fā)現(xiàn),所謂的“移動(dòng)構(gòu)造/賦值”,其實(shí)就是一個(gè)“淺復(fù)制”而已。當(dāng)出現(xiàn)移動(dòng)語義的時(shí)候,我們想象中是“把舊對(duì)象里的東西 移動(dòng) 到新對(duì)象中”,但其實(shí)沒法做到這種移動(dòng),只能是“把舊對(duì)象引用的東西轉(zhuǎn)為新對(duì)象來引用”,本質(zhì)就是一次淺復(fù)制。
引用折疊指的是在模板參數(shù)以及 auto 類型推導(dǎo)時(shí)遇到多重引用時(shí)進(jìn)行的映射關(guān)系,我們先從最簡單的例子來說:
template <typename T>
void f(T &t) {
}
void Demo() {
int a = 3;
f<int>(a);
f<int &>(a);
f<int &&>(a);
}
當(dāng)T
實(shí)例化為int
時(shí),函數(shù)變成了:
void f(int &t);
但如果T
實(shí)例化為int &
和int &&
時(shí)呢?難道是這樣嗎?
void f(int & &t);
void f(int && &t);
我們發(fā)現(xiàn),這種情況下編譯并沒有出錯(cuò),T
本身帶引用時(shí),再跟參數(shù)后面的引用符結(jié)合,也是可以正常通過編譯的。這就是所謂的引用折疊,簡單理解為“兩個(gè)引用撞一起了,以誰為準(zhǔn)”的問題。引用折疊滿足下面規(guī)律:
左值引用短路右值引用
簡單來說就是,除非是兩個(gè)右值引用遇到一起,會(huì)推導(dǎo)出右值引用以外,其他情況都會(huì)推導(dǎo)出左值引用,所以是左值引用優(yōu)先。
& + & -> &
& + && -> &
&& + & -> &
&& + && -> &&
這種規(guī)律同樣同樣適用于auto &&
,當(dāng)auto &&
遇到左值時(shí)會(huì)推導(dǎo)出左值引用,遇到右值時(shí)才會(huì)推導(dǎo)出右值引用:
auto &&r1 = 5; // 綁定常量,推導(dǎo)出int &&
int a;
auto &&r2 = a; // 綁定變量,推導(dǎo)出int &
int &&b = 1;
auto &&r3 = b; // 右值引用一旦綁定,則相當(dāng)于普通變量,所以綁定變量,推導(dǎo)出int &
由于&
比&&
優(yōu)先級(jí)高,因此auto &
一定推出左值引用,如果用auto &
綁定常量或?qū)⑼鰧?duì)象則會(huì)報(bào)錯(cuò):
auto &r1 = 5; // ERR,左值引用不能綁定常量
auto &r2 = GetAnObj(); // ERR,左值引用不能綁定將亡對(duì)象
int &&b = 1;
auto &r3 = b; // OK,左值引用可以綁定右值引用(因?yàn)橛抑狄靡坏┙壎ê?,相?dāng)于左值)
auto &r4 = r3; // OK,左值引用可以綁定左值引用(相當(dāng)于綁定r4的引用源)
前面的章節(jié)筆者頻繁強(qiáng)調(diào)一個(gè)概念:右值引用一旦綁定,則相當(dāng)于普通的引用(左值)。
這也就意味著,“右值”性質(zhì)無法傳遞,請(qǐng)看例子:
void f1(int &&t1) {}
void f2(int &&t2) {
f1(t2); // 注意這里
}
void Demo() {
f2(5);
}
在Demo
函數(shù)中調(diào)用f2
,f2
的參數(shù)是int &&
,用來綁定常量5
沒問題,但是,在f2
函數(shù)內(nèi),t2
是一個(gè)右值引用,而右值引用一旦綁定,則相當(dāng)于左值,因此,不能再用右值引用去接收。所以f2
內(nèi)部調(diào)f1
的過程會(huì)報(bào)錯(cuò)。這就是所謂“右值引用傳遞時(shí)會(huì)失去右性”。
那么如何保持右性呢?很無奈,只能層層轉(zhuǎn)換:
void f1(int &&t1) {}
void f2(int &&t2) {
f1(std::move(t2)); // 保證右性
}
void Demo() {
f2(5);
}
但我們來考慮另一個(gè)場景,在模板函數(shù)中這件事會(huì)怎么樣?
template <typename T>
void f1(T &&t1) {}
template <typename T>
void f2(T &&t2) {
f1<T>(t2);
}
void Demo() {
f2<int &&>(5); // 傳右值
int a;
f2<int &>(a); // 傳左值
}
由于f1
和f2
都是模板,因此,傳入左值和傳入右值的可能性都要有的,我們沒法在f2
中再強(qiáng)制std::move
了,因?yàn)檫@樣做會(huì)讓左值變成右值傳遞下去,我們希望的是保持其左右性。
但如果不這樣做,當(dāng)我向f2
傳遞右值時(shí),右性無法傳遞下去,也就是t2
是int &&
類型,但是傳遞給f1
的時(shí)候,t1
變成了int &
類型,這時(shí)t1
是t2
的引用(就是左值引用綁定右值引用的場景),并不是我們想要的。那怎么解決,如何讓這種左右性質(zhì)傳遞下去呢?就要用到模板元編程來完成了:
template <typename T>
T &forward(T &t) {
return t; // 如果傳左值,那么直接傳出
}
template <typename T>
T &&forward(T &&t) {
return std::move(t); // 如果傳右值,那么保持右值性質(zhì)傳出
}
template <typename T>
void f1(T &&t1) {}
template <typename T>
void f2(T &&t2) {
f1(forward<T>(t2));
}
void Demo() {
f2<int &&>(5); // 傳右值
int a;
f2<int &>(a); // 傳左值
}
上面展示的是std::forward
的一個(gè)示例型的代碼,便于讀者理解,實(shí)際實(shí)現(xiàn)要稍微復(fù)雜一點(diǎn)。思路就是,根據(jù)傳入的參數(shù)來判斷,如果是左值引用就直接傳出,如果是右值引用就std::move
變成右值再傳出,保證其左右性。std::forward
又被稱為“完美轉(zhuǎn)發(fā)”,意義就在于傳遞引用時(shí)能保持其左右性。
C++11 提供了auto
來自動(dòng)推導(dǎo)類型,很大程度上提升了代碼的直觀性,例如:
std::unordered_map<std::string, std::vector<int>> data_map;
// 不用auto
std::unordered_map<std::string, std::vector<int>>::iterator iter = data_map.begin();
// 使用auto推導(dǎo)
auto iter = data_map.begin();
但 auto 的推導(dǎo)仍然引入了不少奇怪的問題。首先,auto
關(guān)鍵字僅僅是用來代替“類型符”的,它并沒有改變“C++類型說明符具有多重意義”這件事,在前面“類型說明符”的章節(jié)我曾介紹過,C++中,類型說明符除了表示“類型”以外,還承擔(dān)了“定義動(dòng)作”的任務(wù),auto
可以視為一種帶有類型推導(dǎo)的類型說明符,其本質(zhì)仍然是類型說明符,所以,它同樣承擔(dān)了定義動(dòng)作的任務(wù),例如:
auto a = 5; // auto承擔(dān)了“定義變量”的任務(wù)
但auto
卻不可以和[]
組合定義數(shù)組,比如:
auto arr[] = {1, 2, 3}; // ERR
在定義函數(shù)上,更加有趣,在 C++14 以前,并不支持用auto
推導(dǎo)函數(shù)返回值類型,但是卻支持返回值后置語法,所以在這種場景下,auto
僅僅是一個(gè)占位符而已,它既不表示類型,也不表示定義動(dòng)作,僅僅就是為了結(jié)構(gòu)完整占位而已:
auto func() -> int; // () -> int表示定義函數(shù),int表示函數(shù)返回值類型
到了 C++14 才支持了返回值類型自動(dòng)推導(dǎo),但并不支持自動(dòng)生成多種類型的返回值:
auto func(int cmd) {
if (cmd > 0) {
return 5; // 用5推導(dǎo)返回值為int
}
return std::string('123'); // ERR,返回值已經(jīng)推導(dǎo)為int了,不能多類型返回
}
同樣還是出自這句話“auto 是用來代替類型說明符的”,因此auto
在語義上也更加傾向于“用它代替類型說明符”這種行為,尤其是它和引用、指針類型結(jié)合時(shí),這種特性更加明顯:
int a = 5;
const int k = 9;
int &r = a;
auto b = a; // auto->int
auto c = 4; // auto->int
auto d = k; // auto->int
auto e = r; // auto->int
我們看到,無論用普通變量、只讀變量、引用、常量去初始化 auto 變量時(shí),auto
都只會(huì)推導(dǎo)其類型,而不會(huì)帶有左右性、只讀性這些內(nèi)容。
所以,auto
的類型推導(dǎo),并不是“推導(dǎo)某個(gè)表達(dá)式的類型”,而是“推導(dǎo)當(dāng)前位置合適的類型”,或者可以理解為“這里最簡單可以是什么類型”。比如說上面auto c = 4
這里,auto
可以推導(dǎo)為int
,int &&
,const int
,const int &
,const int &&
,而auto
選擇的是里面最簡單的那一種。
auto
還可以跟指針符、引用符結(jié)合,而這種時(shí)候它還是滿足上面“最簡單”的這種原則,并且此時(shí)指的是“auto
本身最簡單”,舉例來說:
int a = 5;
auto p1 = &a; // auto->int *
auto *p2 = &a; // auto->int
auto &r1 = a; // auto->int
auto *p3 = &p2; // auto->int *
auto p4 = &p2; // auto-> int **
p1
和p2
都是指針,但auto
都是用最簡原則來推導(dǎo)的,p2
這里因?yàn)槲覀円呀?jīng)顯式寫了一個(gè)*
了,所以auto
只會(huì)推導(dǎo)出int
,因此p2
最終類型仍然是int *
而不會(huì)變成int **
。同樣的道理在p3
和p4
上也成立。
在一些將“類型”和“動(dòng)作”語義分離的語言中,就完全不會(huì)有 auto 的這種困擾,它們可以用“省略類型符”來表示“自動(dòng)類型推導(dǎo)”的語義,而起“定義”語義的關(guān)鍵字得以保留而不受影響,例如在 swift 中:
var a = 5 // Int
let b = 5.6 // 只讀Double
let c: Double = 8 // 顯式指定類型
在 Go 中也是類似的:
var a = 2.5 // var表示“定義變量”動(dòng)作,自動(dòng)推導(dǎo)a的類型為float64
b := 5 // 自動(dòng)推導(dǎo)類型為int,:=符號(hào)表示了“定義動(dòng)作”語義
const c = 7 // const表示“定義只讀變量”動(dòng)作,自動(dòng)推導(dǎo)c類型為int
var d float32 = 9 // 顯式指定類型
在前面“引用折疊”的章節(jié)曾經(jīng)提到過auto &&
的推導(dǎo)原則,有可能會(huì)推導(dǎo)出左值引用來,所以auto &&
并不是要“定義一個(gè)右值引用”,而是“定義一個(gè)保持左右性的引用”,也就是說,綁定一個(gè)左值時(shí)會(huì)推導(dǎo)出左值引用,綁定一個(gè)右值時(shí)會(huì)推導(dǎo)出右值引用。
int a = 5;
int &r1 = a;
int &&r2 = 4;
auto &&y1 = a; // int &
auto &&y2 = r1; // int &
auto &&y3 = r2; // int &(注意右值引用本身是左值)
auto &&y4 = 3; // int &&
auto &&y5 = std::move(r1); // int &&
更詳細(xì)的內(nèi)容可以參考前面“引用折疊”的章節(jié)。
我相信大家現(xiàn)在看到auto
都第一印象是 C++當(dāng)中的“自動(dòng)類型推導(dǎo)”,但其實(shí)auto
并不是 C++11 引入的新關(guān)鍵在,在原始 C 語言中就有這一關(guān)鍵字的。
在原始 C 中,auto
表示“自動(dòng)變量位置”,與之對(duì)應(yīng)的是register
。在之前“const 引用”章節(jié)中筆者曾經(jīng)提到,“變量就是內(nèi)存變量”,但其實(shí)在原始 C 中,除了內(nèi)存變量以外,還有一種變量叫做“寄存器變量”,也就是直接將這個(gè)數(shù)據(jù)放到 CPU 的寄存器中。也就是說,編譯器可以控制這個(gè)變量的位置,如果更加需要讀寫速度,那么放到寄存器中更合適,因此auto
表示讓編譯器自動(dòng)決定放內(nèi)存中,還是放寄存器中。而register
修飾的則表示人工指定放在寄存器中。至于沒有關(guān)鍵字修飾的,則表示希望放到內(nèi)存中。
int a; // 內(nèi)存變量
register int b; // 寄存器變量
auto int c; // 由編譯器自動(dòng)決定放在哪里
需要注意的是,寄存器變量不能取址。這個(gè)很好理解,因?yàn)橹挥袃?nèi)存才有地址(地址本來指的就是內(nèi)存地址),寄存器是沒有的。因此,auto
修飾的變量如果被取址了,那么一定會(huì)放在內(nèi)存中:
auto int a; // 有可能放在內(nèi)存中,也有可能放在寄存器中
auto int b;
int *p = &b; // 這里b被取址了,因此b一定只能放在內(nèi)存中
register int c;
int *p2 = &c; // ERR,對(duì)寄存器變量取址,會(huì)報(bào)錯(cuò)
然而在 C++中,幾乎不會(huì)人工來控制變量的存放位置了,畢竟 C++更加上層一些,這樣超底層的語法就被摒棄了(C++11 取消了register
關(guān)鍵字,而auto
關(guān)鍵字也失去其本意,變?yōu)榱恕白詣?dòng)類型推導(dǎo)”的占位符)。而關(guān)于變量的存儲(chǔ)位置則是全權(quán)交給了編譯器,也就是說我們可以理解為,在 C++11 以后,所有的變量都是自動(dòng)變量,存儲(chǔ)位置由編譯器決定。
筆者在前面章節(jié)吐槽了const
這個(gè)命名,也吐槽了“右值引用”這個(gè)命名。那么static
就是筆者下一個(gè)要重點(diǎn)吐槽的命名了。static
這個(gè)詞本身沒有什么問題,其主要的槽點(diǎn)就在于“一詞多用”,也就是說,這個(gè)詞在不同場景下表示的是完全不同的含義。(作者可能是出于節(jié)省關(guān)鍵詞的目的吧,明明是不同的含義,卻沒有用不同的關(guān)鍵詞)。
static
,限定的是變量的生命周期static
,限定的變量/函數(shù)的作用域static
,限定的是成員變量的生命周期static
,限定的是成員函數(shù)的調(diào)用方(或隱藏參數(shù))上面是static
關(guān)鍵字的 4 種不同含義,接下來逐一我會(huì)解釋。
當(dāng)用static
修飾局部變量時(shí),static
表示其生命周期:
void f() {
static int count = 0;
count++;
}
上例中,count
是一個(gè)局部變量,既然已經(jīng)是“局部變量”了,那么它的作用域很明顯,就是f
函數(shù)內(nèi)部。而這里的static
表示的是其生命周期。普通的全局變量在其所在函數(shù)(或代碼塊)結(jié)束時(shí)會(huì)被釋放。而用static
修飾的則不會(huì),我們將其稱為“靜態(tài)局部變量”。
靜態(tài)局部變量會(huì)在首次執(zhí)行到定義語句時(shí)初始化,在主函數(shù)執(zhí)行結(jié)束后釋放,在程序執(zhí)行過程中遇到定義(和初始化)語句時(shí)會(huì)忽略。
void f() {
static int count = 0;
count++;
std::cout << count << std::endl;
}
int main(int argc, const char *argv[]) {
f(); // 第一次執(zhí)行時(shí)count被定義,并且初始化為0,執(zhí)行后count值為1,并且不會(huì)釋放
f(); // 第二次執(zhí)行時(shí)由于count已經(jīng)存在,因此初始化語句會(huì)無視,執(zhí)行后count值為2,并且不會(huì)釋放
f(); // 同上,執(zhí)行后count值為3,不會(huì)釋放
} // 主函數(shù)執(zhí)行結(jié)束后會(huì)釋放f中的count
例如上面例程的輸出結(jié)果會(huì)是:
1
2
3
詳細(xì)的說明已經(jīng)在注釋中,這里不再贅述。
當(dāng)static
修飾全局變量或函數(shù)時(shí),用于限定其作用域?yàn)椤爱?dāng)前文件內(nèi)”。同理,由于已經(jīng)是“全局”變量了,生命周期一定是符合全局的,也就是“主函數(shù)執(zhí)行前構(gòu)造,主函數(shù)執(zhí)行結(jié)束后釋放”。至于全局函數(shù)就不用說了,函數(shù)都是全局生命周期的。
因此,這時(shí)候的static
不會(huì)再對(duì)生命周期有影響,而是限定了其作用域。與之對(duì)應(yīng)的是extern
。用extern
修飾的全局變量/函數(shù)作用于整個(gè)程序內(nèi),換句話說,就是可以跨文件:
// a1.cc
int g_val = 4; // 定義全局變量
// a2.cc
extern int g_val; // 聲明全局變量
void Demo() {
std::cout << g_val << std::endl; // 使用了在另一個(gè)文件中定義的全局變量
}
而用static
修飾的全局變量/函數(shù)則只能在當(dāng)前文件中使用,不同文件間的static
全局變量/函數(shù)可以同名,并且互相獨(dú)立。
// a1.cc
static int s_val1 = 1; // 定義內(nèi)部全局變量
static int s_val2 = 2; // 定義內(nèi)部全局變量
static void f1() {} // 定義內(nèi)部函數(shù)
// a2.cc
static int s_val1 = 6; // 定義內(nèi)部全局變量,與a1.cc中的互不影響
static int s_val2; // 這里會(huì)視為定義了新的內(nèi)部全局變量,而不會(huì)視為“聲明”
static void f1(); // 聲明了一個(gè)內(nèi)部函數(shù)
void Demo() {
std::cout << s_val1 << std::endl; // 輸出6,與a1.cc中的s_val1沒有關(guān)系
std::cout << s_val2 << std::endl; // 輸出0,同樣不會(huì)訪問到a1.cc中的s_val2
f1(); // ERR,這里鏈接會(huì)報(bào)錯(cuò),因?yàn)樵赼2.cc中沒有找到f1的定義,并不會(huì)鏈接到a1.cc中的f1
}
所以我們發(fā)現(xiàn),在這種場景下,static
并不表示“靜態(tài)”的含義,而是表示“內(nèi)部”的含義,所以,為什么不再引入個(gè)類似于inner
的關(guān)鍵字呢?這里很容易讓程序員造成迷惑。
靜態(tài)成員變量指的是用static
修飾的成員變量。普通的成員變量其生命周期是跟其所屬對(duì)象綁定的。構(gòu)造對(duì)象時(shí)構(gòu)造成員變量,析構(gòu)對(duì)象時(shí)釋放成員變量。
struct Test {
int a; // 普通成員變量
};
int main(int argc, const char *argv[]) {
Test t; // 同時(shí)構(gòu)造t.a
auto t2 = new Test; // 同時(shí)構(gòu)造t2->a
delete t2; // t2所指對(duì)象析構(gòu),同時(shí)釋放t2->a
} // t析構(gòu),同時(shí)釋放t.a
而用static
修飾后,其聲明周期變?yōu)槿?,也就是“主函?shù)執(zhí)行前構(gòu)造,主函數(shù)執(zhí)行結(jié)束后釋放”,并且不再跟隨對(duì)象,而是全局一份。
struct Test {
static int a; // 靜態(tài)成員變量(基本等同于聲明全局變量)
};
int Test::a = 5; // 初始化靜態(tài)成員變量(主函數(shù)前執(zhí)行,基本等同于初始化全局變量)
int main(int argc, const char *argv[]) {
std::cout << Test::a << std::endl; // 直接訪問靜態(tài)成員變量
Test t;
std::cout << t.a << std::endl; // 通過任意對(duì)象實(shí)例訪問靜態(tài)成員變量
} // 主函數(shù)結(jié)束時(shí)釋放Test::a
所以靜態(tài)成員變量基本就相當(dāng)于一個(gè)全局變量,而這時(shí)的類更像一個(gè)命名空間了。唯一的區(qū)別在于,通過類的實(shí)例(對(duì)象)也可以訪問到這個(gè)靜態(tài)成員變量,就像上面的t.a
和Test::a
完全等價(jià)。
static
關(guān)鍵字修飾在成員函數(shù)前面,稱為“靜態(tài)成員函數(shù)”。我們知道普通的成員函數(shù)要以對(duì)象為主調(diào)方,對(duì)象本身其實(shí)是函數(shù)的一個(gè)隱藏參數(shù)(this 指針):
struct Test {
int a;
void f(); // 非靜態(tài)成員函數(shù)
};
void Test::f() {
std::cout << this->a << std::endl;
}
void Demo() {
Test t;
t.f(); // 用對(duì)象主調(diào)成員函數(shù)
}
上面其實(shí)等價(jià)于:
struct Test {
int a;
};
void f(Test *this) {
std::cout << this->a << std::endl;
}
void Demo() {
Test t;
f(&t); // 其實(shí)對(duì)象就是函數(shù)的隱藏參數(shù)
}
也就是說,obj.f(arg)
本質(zhì)上就是f(&obj, arg)
,并且這個(gè)參數(shù)強(qiáng)制叫做this
。這個(gè)特性在 Go 語言中尤為明顯,Go 不支持封裝到類內(nèi)的成員函數(shù),也不會(huì)自動(dòng)添加隱藏參數(shù),這些行為都是顯式的:
type Test struct {
a int
}
func(t *Test) f() {
fmt.Println(t.a)
}
func Demo() {
t := new(Test)
t.f()
}
回到 C++的靜態(tài)成員函數(shù)這里來。用static
修飾的成員函數(shù)表示“不需要對(duì)象作為主調(diào)方”,也就是說沒有那個(gè)隱藏的this
參數(shù)。
struct Test {
int a;
static void f(); // 靜態(tài)成員函數(shù)
};
void Test::f() {
// 沒有this,沒有對(duì)象,只能做對(duì)象無關(guān)操作
// 也可以操作靜態(tài)成員變量和其他靜態(tài)成員函數(shù)
}
可以看出,這時(shí)的靜態(tài)成員函數(shù),其實(shí)就相當(dāng)于一個(gè)普通函數(shù)而已。這時(shí)的類同樣相當(dāng)于一個(gè)命名空間,而區(qū)別在于,如果這個(gè)函數(shù)傳入了同類型的參數(shù)時(shí),可以訪問私有成員,例如:
class Test {
public:
static void f(const Test &t1, const Test &t2); // 靜態(tài)成員函數(shù)
private:
int a; // 私有成員
};
void Test::f(const Test &t1, const Test &t2) {
// t1和t2是通過參數(shù)傳進(jìn)來的,但因?yàn)槭荰est類型,因此可以訪問其私有成員
std::cout << t1.a + t2.a << std::endl;
}
或者我們可以把靜態(tài)成員函數(shù)理解為一個(gè)友元函數(shù),只不過從設(shè)計(jì)角度上來說,與這個(gè)類型的關(guān)聯(lián)度應(yīng)該是更高的。但是從語法層面來解釋,基本相當(dāng)于“寫在類里的普通函數(shù)”。
其實(shí) C++中static
造成的迷惑,同樣也是因?yàn)?C 中的缺陷被放大導(dǎo)致的。畢竟在 C 中不存在構(gòu)造、析構(gòu)和引用鏈的問題。說到這個(gè)引用鏈,其實(shí) C++中的靜態(tài)成員變量、靜態(tài)局部變量和全局變量還存在一個(gè)鏈路順序問題,可能會(huì)導(dǎo)致內(nèi)存重復(fù)釋放、訪問野指針等情況的發(fā)生。這部分的內(nèi)容詳見后面“平凡、標(biāo)準(zhǔn)布局”的章節(jié)。
總之,我們需要了解static
關(guān)鍵字有多義性,了解其在不同場景下的不同含義,更有助于我們理解 C++語言,防止踩坑。
前陣子我和一個(gè)同事對(duì)這樣一個(gè)問題進(jìn)行了非常激烈的討論:
到底應(yīng)不應(yīng)該定義 std::string 類型的全局變量
這個(gè)問題乍一看好像沒什么值得討論的地方,我相信很多程序員都在不經(jīng)意間寫過類似的代碼,并且確實(shí)沒有發(fā)現(xiàn)什么執(zhí)行上的問題,所以可能從來沒有意識(shí)到,這件事還有可能出什么問題。
我們和我同事之所以激烈討論這個(gè)問題,一切的根源來源于谷歌的 C++編程規(guī)范,其中有一條是:
Static or global variables of class type are forbidden: they cause hard-to-find bugs due to indeterminate order of construction and destruction.
Objects with static storage duration, including global variables, static variables, static class member variables, and function static variables, must be Plain Old Data (POD): only ints, chars, floats, or pointers, or arrays/structs of POD.
大致翻譯一下就是說:不允許非 POD 類型的全局變量、靜態(tài)全局變量、靜態(tài)成員變量和靜態(tài)局部變量,因?yàn)榭赡軙?huì)導(dǎo)致難以定位的 bug。而std::string
是非 POD 類型的,自然,按照規(guī)范,也不允許std::string
類型的全局變量。
但是如果我們真的寫了,貌似也從來沒有遇到過什么問題,程序也不會(huì)出現(xiàn)任何 bug 或者異常,甚至下面的幾種寫法都是在日常開發(fā)中經(jīng)常遇到的,但都不符合這谷歌的這條代碼規(guī)范。
const std::string ip = '127.0.0.1';
const uint16_t port = 80;
void Demo() {
// 開啟某個(gè)網(wǎng)絡(luò)連接
SocketSvr svr{ip, port};
// 記錄日志
WriteLog('net linked: ip:port={%s:%hu}', ip.c_str(), port);
}
std::string GetDesc(int code) {
static const std::unordered_map<int, std::string> ma {
{0, 'SUCCESS'},
{1, 'DATA_NOT_FOUND'},
{2, 'STYLE_ILLEGEL'},
{-1, 'SYSTEM_ERR'}
};
if (auto res = ma.find(code); res != ma.end()) {
return res->second;
}
return 'UNKNOWN';
}
class SingleObj {
public:
SingleObj &GetInstance();
SingleObj(const SingleObj &) = delete;
SingleObj &operator =(const SingleObj &) = delete;
private:
SingleObj();
~SingleObj();
};
SingleObj &SingleObj::GetInstance() {
static SingleObj single_obj;
return single_obj;
}
上面的幾個(gè)例子都存在“非 POD 類型全局或靜態(tài)變量”的情況。
既然谷歌規(guī)范中禁止這種情況,那一定意味著,這種寫法存在潛在風(fēng)險(xiǎn),我們需要搞明白風(fēng)險(xiǎn)點(diǎn)在哪里。 首先明確變量生命周期的問題:
這件事如果在 C 語言中,并沒有什么問題,設(shè)計(jì)也很合理。但是 C++就是這樣悲催,很多 C 當(dāng)中合理的問題在 C++中會(huì)變得不合理,并且缺陷會(huì)被放大。
由于 C 當(dāng)中的變量僅僅是數(shù)據(jù),因此,它的“構(gòu)造”和“釋放”都沒有什么副作用。但在 C++當(dāng)中,“構(gòu)造”是要調(diào)用構(gòu)造函數(shù)來完成的,“釋放”之前也是要先調(diào)用析構(gòu)函數(shù)。這就是問題所在!照理說,主函數(shù)應(yīng)該是程序入口,那么在主函數(shù)之前不應(yīng)該調(diào)用任何自定義的函數(shù)才對(duì)。但這件事放到 C++當(dāng)中就不一定成立了,我們看一下下面例程:
class Test {
public:
Test();
~Test();
};
Test::Test() {
std::cout << 'create' << std::endl;
}
Test::~Test() {
std::cout << 'destroy' << std::endl;
}
Test g_test; // 全局變量
int main(int argc, const char *argv[]) {
std::cout << 'main function' << std::endl;
return 0;
}
運(yùn)行上面程序會(huì)得到以下輸出:
create
main function
destroy
也就是說,Test 的構(gòu)造函數(shù)在主函數(shù)前被調(diào)用了。解釋起來也很簡單,因?yàn)椤叭肿兞吭谥骱瘮?shù)執(zhí)行之前構(gòu)造,主函數(shù)執(zhí)行結(jié)束后釋放”,而因?yàn)?code>Test類型是類類型,“構(gòu)造”時(shí)要調(diào)用構(gòu)造函數(shù),“釋放”時(shí)要調(diào)用析構(gòu)函數(shù)。所以上面的現(xiàn)象也就不奇怪了。
這種單一個(gè)的全局變量其實(shí)并不會(huì)出現(xiàn)什么問題,但如果有多變量的依賴,這件事就不可控了,比如下面例程:
test.h
struct Test1 {
int a;
};
extern Test1 g_test1; // 聲明全局變量
test.cc
Test1 g_test1 {4}; // 定義全局變量
main.cc
#include 'test.h'
class Test2 {
public:
Test2(const Test1 &test1); // 傳Test1類型參數(shù)
private:
int m_;
};
Test2::Test2(const Test1 &test1): m_(test1.a) {}
Test2 g_test2{g_test1}; // 用一個(gè)全局變量來初始化另一個(gè)全局變量
int main(int argc, const char *argv) {
return 0;
}
上面這種情況,程序編譯、鏈接都是沒問題的,但運(yùn)行時(shí)會(huì)概率性出錯(cuò),問題就在于,g_test1
和g_test2
都是全局變量,并且是在不同文件中定義的,并且由于全局變量構(gòu)造在主函數(shù)前,因此其初始化順序是隨機(jī)的。
假如g_test1
在g_test2
之前初始化,那么整個(gè)程序不會(huì)出現(xiàn)任何問題,但如果g_test2
在g_test1
前初始化,那么在Test2
的構(gòu)造函數(shù)中,得到的就是一個(gè)未初始化的test1
引用,這時(shí)候訪問test1.a
就是操作野指針了。
這時(shí)我們就能發(fā)現(xiàn),全局變量出問題的根源在于全局變量的初始化順序不可控,是隨機(jī)的,因此,如果出現(xiàn)依賴,則會(huì)導(dǎo)致問題。 同理,析構(gòu)發(fā)生在主函數(shù)后,那么析構(gòu)順序也是隨機(jī)的,可能出問題,比如:
struct Test1 {
int count;
};
class Test2 {
public:
Test2(Test1 *test1);
~Test2();
private:
Test1 *test1_;
};
Test2::Test2(Test1 *test1): test1_(test1) {
test1_->count++;
}
Test2::~Test2() {
test1_->count--;
}
Test1 g_test1 {0}; // 全局變量
void Demo() {
static Test2 t2{&g_test1}; // 靜態(tài)局部變量
}
int main(int argc, const char *argv[]) {
Demo(); // 構(gòu)造了t2
return 0;
}
在上面示例中,構(gòu)造t2
的時(shí)候使用了g_test1
,由于t2
是靜態(tài)局部變量,因此是在第一個(gè)調(diào)用時(shí)(主函數(shù)中調(diào)用Demo
時(shí))構(gòu)造。這時(shí)已經(jīng)是主函數(shù)執(zhí)行過程中了,因此g_test1
已經(jīng)構(gòu)造完畢的,所以構(gòu)造時(shí)不會(huì)出現(xiàn)問題。
但是,靜態(tài)成員變量是在主函數(shù)執(zhí)行完成后析構(gòu),這和全局變量相同,因此,t2
和g_test1
的析構(gòu)順序無法控制。如果t2
比g_test1
先析構(gòu),那么不會(huì)出現(xiàn)任何問題。但如果g_test1
比t2
先析構(gòu),那么在析構(gòu)t2
時(shí),對(duì)test1_
訪問count
成員這一步,就會(huì)訪問野指針。因?yàn)?code>test1_所指向的g_test1
已經(jīng)先行析構(gòu)了。
那么這個(gè)時(shí)候我們就可以確定,全局變量、靜態(tài)變量之間不能出現(xiàn)依賴關(guān)系,否則,由于其構(gòu)造、析構(gòu)順序不可控,因此可能會(huì)出現(xiàn)問題。
回到我們剛才提到的谷歌標(biāo)準(zhǔn),這里標(biāo)準(zhǔn)的制定者正是因?yàn)閾?dān)心這樣的問題發(fā)生,才禁止了非 POD 類型的全局或靜態(tài)變量。但我們分析后得知,也并不是說所有的類類型全局或靜態(tài)變量都會(huì)出現(xiàn)問題。
而且,谷歌規(guī)范中的“POD 類型”的限定也過于廣泛了。所謂“POD 類型”指的是“平凡”+“標(biāo)準(zhǔn)內(nèi)存布局”,這里我來解釋一下這兩種性質(zhì),并且分析分析為什么谷歌標(biāo)準(zhǔn)允許 POD 類型的全局或靜態(tài)變量。
“平凡(trivial)”指的是:
換句話說,六大特殊函數(shù)都是默認(rèn)的。這里要區(qū)分 2 個(gè)概念,我們要的是“語法上的平凡”還是“實(shí)際意義上的平凡”。語法上的平凡就是說能夠被編譯期識(shí)別、認(rèn)可的平凡。而實(shí)際意義上的平凡就是說里面沒有額外操作。 比如說:
class Test1 {
public:
Test1() = default; // 默認(rèn)無參構(gòu)造函數(shù)
Test1(const Test1 &) = default; // 默認(rèn)拷貝構(gòu)造函數(shù)
Test &operator =(const Test1 &) = default; // 默認(rèn)拷貝賦值函數(shù)
~Test1() = default; // 默認(rèn)析構(gòu)函數(shù)
};
class Test2 {
public:
Test2() {} // 自定義無參構(gòu)造函數(shù),但實(shí)際內(nèi)容為空
~Test2() {std::printf('destory\n');} // 自定義析構(gòu)函數(shù),但實(shí)際內(nèi)容只有打印
};
上面的例子中,Test1
就是個(gè)真正意義上的平凡類型,語法上是平凡的,因此編譯器也會(huì)認(rèn)為其是平凡的。我們可以用 STL 中的工具來判斷一個(gè)類型是否是平凡的:
bool is_test1_tri = std::is_trivial_v<Test1>; // true
但這里的 Test2,由于我們自定義了其無參構(gòu)造函數(shù)和析構(gòu)函數(shù),那么對(duì)編譯器來說,它就是非平凡的,我們用std::is_trivial
來判斷也會(huì)得到false_value
。但其實(shí)內(nèi)部并沒有什么外鏈操作,所以其實(shí)我們把Test2
類型定義全局變量時(shí)也不會(huì)出現(xiàn)任何問題,這就是所謂“實(shí)際意義上的平凡”。
C++對(duì)“平凡”的定義比較嚴(yán)格,但實(shí)際上我們看看如果要做全局變量或靜態(tài)變量的時(shí)候,是不需要這樣嚴(yán)格定義的。對(duì)于全局變量來說,只要定義全局變量時(shí),使用的是“實(shí)際意義上平凡”的構(gòu)造函數(shù),并且擁有“實(shí)際意義上平凡”的析構(gòu)函數(shù),那這個(gè)全局變量定義就不會(huì)有任何問題。而對(duì)于靜態(tài)局部變量來說,只要擁有“實(shí)際意義上平凡”的析構(gòu)函數(shù)的就一定不會(huì)出問題。
標(biāo)準(zhǔn)內(nèi)存布局的定義是:
public
,或都protected
,或都private
);我們同樣可以用 STL 中的std::is_standard_layout
來判斷一個(gè)類型是否是標(biāo)準(zhǔn)內(nèi)存布局的。這里的定義比較簡單,不在贅述。
所謂 POD 類型就是同時(shí)符合“平凡”和“標(biāo)準(zhǔn)內(nèi)存布局”的類型。符合這個(gè)類型的基本就是基本數(shù)據(jù)類型,加上一個(gè)普通 C 語言的結(jié)構(gòu)體。換句話說,符合“舊類型(C 語言中的類型)行為的類型”,它不存在虛函數(shù)指針、不存在虛表,可以視為普通二進(jìn)制來操作的。
因此,在 C++中,只有 POD 類型可以用memcpy
這種二進(jìn)制方法來復(fù)制而不會(huì)產(chǎn)生副作用,其他類型的都必須用用調(diào)用拷貝構(gòu)造。
以前有人向筆者提出疑問,為何vector
擴(kuò)容時(shí)不直接用類似于memcpy
的方式來復(fù)制,而是要以此調(diào)用拷貝構(gòu)造。原因正是在此,對(duì)于非 POD 類型的對(duì)象,其中可能會(huì)包含虛表、虛函數(shù)指針等數(shù)據(jù),復(fù)制時(shí)這些內(nèi)容可能會(huì)重置,并且內(nèi)部可能會(huì)含有一些類似于“計(jì)數(shù)”這樣操作其他引用對(duì)象的行為,因?yàn)橐欢ㄒ每截悩?gòu)造函數(shù)來保證這些行為是正常的,而不能簡單粗暴地用二進(jìn)制方式進(jìn)行拷貝。
STL 中可以用std::is_pod
來判斷是個(gè)類型是否是 POD 的。
我們?cè)倩氐焦雀枰?guī)范中,POD 的限制比較多,因此,確實(shí) POD 類型的全局/靜態(tài)變量是肯定不會(huì)出問題的,但直接將非 POD 類型的一棍子打死,筆者個(gè)人認(rèn)為有點(diǎn)過了,沒必要。
所以,筆者認(rèn)為更加精確的限定應(yīng)該是:對(duì)于全局變量、靜態(tài)成員變量來說,初始化時(shí)必須調(diào)用的是平凡的構(gòu)造函數(shù),并且其應(yīng)當(dāng)擁有平凡的析構(gòu)函數(shù),而且這里的“平凡”是指實(shí)際意義上的平凡,也就是說可以自定義,但是在內(nèi)部沒有對(duì)任何其他的對(duì)象進(jìn)行操作;對(duì)于靜態(tài)局部變量來說,其應(yīng)當(dāng)擁有平凡的析構(gòu)函數(shù),同樣指的是實(shí)際意義上的平凡,也就是它的析構(gòu)函數(shù)中沒有對(duì)任何其他的對(duì)象進(jìn)行操作。
最后舉幾個(gè)例子:
class Test1 {
public:
Test1(int a): m_(a) {}
void show() const {std::printf('%d\n', m_);}
private:
int m_;
};
class Test2 {
public:
Test2(Test1 *t): m_(t) {}
Test2(int a): m_(nullptr) {}
~Test2() {}
private:
Test1 *m_;
};
class Test3 {
public:
Test3(const Test1 &t): m_(&t) {}
~Test3() {m_->show();}
private:
Test1 *m_;
};
class Test4 {
public:
Test4(int a): m_(a) {}
~Test4() = default;
private:
Test1 m_;
};
Test1
是非平凡的(因?yàn)闊o參構(gòu)造函數(shù)沒有定義),但它仍然可以定義全局/靜態(tài)變量,因?yàn)?code>Test1(int)構(gòu)造函數(shù)是“實(shí)際意義上平凡”的。
Test2
是非平凡的,并且Test2(Test1 *)
構(gòu)造函數(shù)需要引用其他類型,因此它不能通過Test2(Test1 *)
定義全局變量或靜態(tài)成員變量,但可以通過Test2(int)
來定義全局變量或靜態(tài)成員變量,因?yàn)檫@是一個(gè)“實(shí)際意義上平凡”的構(gòu)造函數(shù)。而且因?yàn)樗奈鰳?gòu)函數(shù)是“實(shí)際意義上平凡”的,因此Test2
類型可以定義靜態(tài)局部變量。
Test3
是非平凡的,構(gòu)造函數(shù)對(duì)Test1
有引用,并且析構(gòu)函數(shù)中調(diào)用了Test1::show
方法,因此Test3
類型不能用來定義局部/靜態(tài)變量。
Test4
也是非平凡的,并且內(nèi)部存在同樣非平凡的Test1
類型成員,但是因?yàn)?code>m1_不是引用或指針,一定會(huì)隨著Test4
類型的對(duì)象的構(gòu)造而構(gòu)造,析構(gòu)而析構(gòu),不存在順序依賴問題,因此Test4
可以用來定義全局/靜態(tài)變量。
最后回到這個(gè)問題上,筆者認(rèn)為定義一個(gè)全局的std::string
類型的變量并不會(huì)出現(xiàn)什么問題,在std::string
的內(nèi)部,數(shù)據(jù)空間是通過new
的方式申請(qǐng)的,并且一般情況下都不會(huì)被其他全局變量所引用,在std::string
對(duì)象析構(gòu)時(shí),對(duì)這片空間會(huì)進(jìn)行delete
,所以并不會(huì)出現(xiàn)析構(gòu)順序問題。
但是,如果你用的不是默認(rèn)的內(nèi)存分配器,而是自定義了內(nèi)存分配器的話,那確實(shí)要考慮構(gòu)造析構(gòu)順序的問題了,你要保證在對(duì)象構(gòu)造前,內(nèi)存分配器是存在的,并且內(nèi)存分配器的析構(gòu)要在所有對(duì)象之后。
當(dāng)然了,如果你僅僅是想給字符串常量起個(gè)別名的話,有一種更好的方式:
constexpr const char *ip = '127.0.0.1';
畢竟指針一定是平凡類型,而且用constexpr
修飾后可以變?yōu)榫幾g期常量。這里詳情可以在后面“constexpr”的章節(jié)了解。
而至于其他類型的靜態(tài)局部變量(比如說單例模式,或者局部內(nèi)的map
之類的映射表),只要讓它不被析構(gòu)就好了,所以可以用堆空間的方式:
static Test &Test::GetInstance() {
static Test &inst = *new Test;
return inst;
}
std::string GetDesc(int code) {
static const auto &desc = *new std::map<int, std::string> {
{1, 'desc1'},
{2, 'desc2'},
};
auto iter = desc.find(code);
return iter == desc.end() ? 'no_desc' : iter->second;
}
在討論完平凡類型后,我們發(fā)現(xiàn)平凡析構(gòu)其實(shí)是更加值得關(guān)注的場景。這里就引申出非平凡析構(gòu)的移動(dòng)語義問題,請(qǐng)看例程:
class Buffer {
public:
Buffer(size_t size): buf(new int[size]), size(size) {}
~Buffer() {delete [] buf;}
Buffer(const Buffer &ob): buf(new int[ob.size]), size(ob.size) {}
Buffer(Buffer &&ob): buf(ob.buf), size(ob.size) {}
private:
int *buf;
size_t size;
};
void Demo() {
Buffer buf{16};
Buffer nb = std::move(buf);
} // 這里會(huì)報(bào)錯(cuò)
還是這個(gè)簡單的緩沖區(qū)的例子,如果我們調(diào)用Demo
函數(shù),那么結(jié)束時(shí)會(huì)報(bào)重復(fù)釋放內(nèi)存的異常。
那么在上面例子中,buf
和nb
中的buf
指向的是同一片空間,當(dāng)Demo
函數(shù)結(jié)束時(shí),buf
銷毀會(huì)觸發(fā)一次Buffer
的析構(gòu),nb
析構(gòu)時(shí)也會(huì)觸發(fā)一次Buffer
的析構(gòu)。而析構(gòu)函數(shù)中是delete
操作,所以堆空間會(huì)被釋放兩次,導(dǎo)致報(bào)錯(cuò)。
這也就是說,對(duì)于非平凡析構(gòu)類型,其發(fā)生移動(dòng)語義后,應(yīng)當(dāng)放棄對(duì)原始空間的控制。
如果我們修改一下代碼,那么這種問題就不會(huì)發(fā)生:
class Buffer {
public:
Buffer(size_t size): buf(new int[size]), size(size) {}
~Buffer();
Buffer(const Buffer &ob): buf(new int[ob.size]), size(ob.size) {}
Buffer(Buffer &&ob): buf(ob.buf), size(ob.size) {ob.buf = nullptr;} // 重點(diǎn)在這里
private:
int *buf;
};
Buffer::~Buffer() {
if (buf != nullptr) {
delete [] buf;
}
}
void Demo() {
Buffer buf{16};
Buffer nb = std::move(buf);
} // OK,沒有問題
由于移動(dòng)構(gòu)造函數(shù)和移動(dòng)賦值函數(shù)是我們可以自定義的,因此,可以把重復(fù)析構(gòu)產(chǎn)生的問題在這個(gè)里面考慮好。例如上面的把對(duì)應(yīng)指針置空,而析構(gòu)時(shí)再進(jìn)行判空即可。
因此,我們得出的結(jié)論是并不是說非平凡析構(gòu)的類型就不可以使用移動(dòng)語義,而是非平凡析構(gòu)類型進(jìn)行移動(dòng)構(gòu)造或移動(dòng)賦值時(shí),要考慮引用權(quán)釋放問題。
在講解私有繼承和多繼承之前,筆者要先澄清一件事:C++不是單純的面相對(duì)象的語言。同樣地,它也不是單純的面向過程的語言,也不是函數(shù)式語言,也不是接口型語言……
真的要說,C++是一個(gè)多范式語言,也就是說它并不是為了某種編程范式來創(chuàng)建的。C++的語法體系完整且龐大,很多范式都可以用 C++來展現(xiàn)。因此,不要試圖用任一一種語言范式來解釋 C++語法,不然你總能找到各種漏洞和奇怪的地方。
舉例來說,C++中的“繼承”指的是一種語法現(xiàn)象,而面向?qū)ο罄碚撝械摹袄^承”指的是一種類之間的關(guān)系。這二者是有本質(zhì)區(qū)別的,請(qǐng)讀者一定一定要區(qū)分清楚。
以面向?qū)ο鬄槔?,C++當(dāng)然可以面向?qū)ο缶幊蹋∣OP),但由于 C++并不是專為 OOP 創(chuàng)建的語言,自然就有 OOP 理論解釋不了的語法現(xiàn)象。比如說多繼承,比如說私有繼承。
C++與 java 不同,java 是完全按照 OOP 理論來創(chuàng)建的,因此所謂“抽象類”,“接口(協(xié)議)類”的語義是明確可以和 OOP 對(duì)應(yīng)上的,并且,在 OOP 理論中,“繼承”關(guān)系應(yīng)當(dāng)是'A is a B'的關(guān)系,所以不會(huì)存在 A 既是 B 又是 C 的這種情況,自然也就不會(huì)出現(xiàn)“多繼承”這樣的語法。
但是在 C++中,考慮的是對(duì)象的布局,而不是 OOP 的理論,所以出現(xiàn)私有繼承、多繼承等這樣的語法也就不奇怪了。
筆者曾經(jīng)聽有人持有下面這樣類似的觀點(diǎn):
等等這些觀點(diǎn),它們其實(shí)都有一個(gè)共同的前提,那就是“我要用 C++來支持 OOP 范式”。如果我們用 OOP 范式來約束 C++,那么上面這些觀點(diǎn)都是非常正確的,否則將不符合 OOP 的理論,例如:
class Pet {};
class Cat : public Pet {};
class Dog : public Pet {};
void Demo() {
Pet pet; // 一個(gè)不屬于貓、狗等具體類型,僅僅屬于“寵物”的實(shí)例,顯然不合理
}
Pet
既然作為一個(gè)抽象概念存在,自然就不應(yīng)當(dāng)有實(shí)體。同理,如果一個(gè)類含有未完全實(shí)現(xiàn)的虛函數(shù),就證明這個(gè)類屬于某種抽象,它就不應(yīng)該允許創(chuàng)建實(shí)例。而可以創(chuàng)建實(shí)例的類,一定就是最“具象”的定義了,它就不應(yīng)當(dāng)再被繼承。
在 OOP 的理論下,多繼承也是不合理的:
class Cat {};
class Dog {};
class SomeProperty : public Cat, public Dog {}; // 啥玩意會(huì)既是貓也是狗?
但如果是“協(xié)議父類”的多繼承就是合理的:
class Pet { // 協(xié)議類
public:
virtual void Feed() = 0; // 定義了喂養(yǎng)方式就可以成為寵物
};
class Animal {};
class Cat : public Animal, public Pet { // 遵守協(xié)議,實(shí)現(xiàn)其需方法
public:
void Feed() override; // 實(shí)現(xiàn)協(xié)議方法
};
上面例子中,Cat
雖然有 2 個(gè)父類,但Animal
才是真正意義上的父類,也就是Cat is a (kind of) Animal
的關(guān)系,而Pet
是協(xié)議父類,也就是Cat could be a Pet
,只要一個(gè)類型可以完成某些行為,那么它就可以“作為”這樣一種類型。
在 java 中,這兩種類型是被嚴(yán)格區(qū)分開的:
interface Pet { // 接口類
public void Feed();
}
abstract class Animal {} // 抽象類,不可創(chuàng)建實(shí)例
class Cat extends Animal implements Pet {
public void Feed() {}
}
子類與父類的關(guān)系叫“繼承”,與協(xié)議(或者叫接口)的關(guān)系叫“實(shí)現(xiàn)”。
與 C++同源的 Objective-C 同樣是 C 的超集,但從名稱上就可看出,這是“面向?qū)ο蟮?C”,語法自然也是針對(duì) OOP 理論的,所以 OC 仍然只支持單繼承鏈,但可以定義協(xié)議類(類似于 java 中的接口類),“繼承”和“遵守(類似于 java 中的實(shí)現(xiàn)語義)”仍然是兩個(gè)分離的概念:
@protocol Pet <NSObject> // 定義協(xié)議
- (void)Feed;
@end
@interface Animal : NSObject
@end
@interface Cat : Animal<Pet> // 繼承自Animal類,遵守Pet協(xié)議
- (void)Feed;
@end
@implementation Cat
- (void)Feed {
// 實(shí)現(xiàn)協(xié)議接口
}
@end
相比,C++只能說“可以”用做 OOP 編程,但 OOP 并不是其唯一范式,也就不會(huì)針對(duì)于 OOP 理論來限制其語法。這一點(diǎn),希望讀者一定要明白。
在此強(qiáng)調(diào),這個(gè)標(biāo)題中,第一個(gè)“繼承”指的是一種 C++語法,也就是class A : B {};
這種寫法。而第二個(gè)“繼承”指的是 OOP(面向?qū)ο缶幊蹋┑睦碚摚簿褪?A is a B 的抽象關(guān)系,類似于“狗”繼承自“動(dòng)物”的這種關(guān)系。
所以我們說,私有繼承本質(zhì)是表示組合的,而不是繼承關(guān)系,要驗(yàn)證這個(gè)說法,只需要做一個(gè)小實(shí)驗(yàn)即可。我們知道最能體現(xiàn)繼承關(guān)系的應(yīng)該就是多態(tài)了,如果父類指針能夠指向子類對(duì)象,那么即可實(shí)現(xiàn)多態(tài)效應(yīng)。請(qǐng)看下面的例程:
class Base {};
class A : public Base {};
class B : private Base {};
class C : protected Base {};
void Demo() {
A a;
B b;
C c;
Base *p = &a; // OK
p = &b; // ERR
p = &c; // ERR
}
這里我們給Base
類分別編寫了A
、B
、C
三個(gè)子類,分別是public
、private
和protected
繼承。然后用Base *
類型的指針去分別指向a
、b
、c
。發(fā)現(xiàn)只有public
繼承的a
對(duì)象可以用p
直接指向,而b
和c
都會(huì)報(bào)這樣的錯(cuò):
Cannot cast 'B' to its private base class 'Base'
Cannot cast 'C' to its protected base class 'Base'
也就是說,私有繼承是不支持多態(tài)的,那么也就印證了,他并不是 OOP 理論中的“繼承關(guān)系”,但是,由于私有繼承會(huì)繼承成員變量,也就是可以通過b
和c
去使用a
的成員,那么其實(shí)這是一種組合關(guān)系?;蛘?,大家可以理解為,把b.a.member
改寫成了b.A::member
而已。
那么私有繼承既然是用來表示組合關(guān)系的,那我們?yōu)槭裁床恢苯佑贸蓡T對(duì)象呢?為什么要使用私有繼承?這是因?yàn)橛贸蓡T對(duì)象在某種情況下是有缺陷的。
在解釋私有繼承的意義之前,我們先來看一個(gè)問題,請(qǐng)看下面例程
class T {};
// sizeof(T) = ?
T
是一個(gè)空類,里面什么都沒有,那么這時(shí)T
的大小是多少?照理說,空類的大小就是應(yīng)該是0
,但如果真的設(shè)置為0
的話,會(huì)有很嚴(yán)重的副作用,請(qǐng)看例程:
class T {};
void Demo() {
T arr[10];
sizeof(arr); // 0
T *p = arr + 5;
// 此時(shí)p==arr
p++; // ++其實(shí)無效
}
發(fā)現(xiàn)了嗎?假如T
的大小是0
,那么T
指針的偏移量就永遠(yuǎn)是0
,T
類型的數(shù)組大小也將是0
,而如果它成為了一個(gè)成員的話,問題會(huì)更嚴(yán)重:
struct Test {
T t;
int a;
};
// t和a首地址相同
由于T
是0
大小,那么此時(shí)Test
結(jié)構(gòu)體中,t
和a
就會(huì)在同一首地址。
所以,為了避免這種 0 長的問題,編譯器會(huì)針對(duì)于空類自動(dòng)補(bǔ)一個(gè)字節(jié)的大小,也就是說其實(shí)sizeof(T)
是 1,而不是 0。
這里需要注意的是,不僅是絕對(duì)的空類會(huì)有這樣的問題,只要是不含有非靜態(tài)成員變量的類都有同樣的問題,例如下面例程中的幾個(gè)類都可以認(rèn)為是空類:
class A {};
class B {
static int m1;
static int f();
};
class C {
public:
C();
~C();
void f1();
double f2(int arg) const;
};
有了自動(dòng)補(bǔ) 1 字節(jié),T
的長度變成了 1,那么T*
的偏移量也會(huì)變成 1,就不會(huì)出現(xiàn) 0 長的問題。但是,這么做就會(huì)引入另一個(gè)問題,請(qǐng)看例程:
class Empty {};
class Test {
Empty m1;
long m2;
};
// sizeof(Test)==16
由于Empty
是空類,編譯器補(bǔ)了 1 字節(jié),所以此時(shí)m1
是 1 字節(jié),而m2
是 8 字節(jié),m1
之后要進(jìn)行字節(jié)對(duì)齊,因此Test
變成了 16 字節(jié)。如果Test
中出現(xiàn)了很多空類成員,這種問題就會(huì)被繼續(xù)放大。
這就是用成員對(duì)象來表示組合關(guān)系時(shí),可能會(huì)出現(xiàn)的問題,而私有繼承就是為了解決這個(gè)問題的。
在上一節(jié)最后的歷程中,為了讓m1
不再占用空間,但又能讓Test
中繼承Empty
類的其他內(nèi)容(例如函數(shù)、類型重定義等),我們考慮將其改為繼承來實(shí)現(xiàn),EBO 就是說,當(dāng)父類為空類的時(shí)候,子類中不會(huì)再去分配父類的空間,也就是說這種情況下編譯器不會(huì)再去補(bǔ)那 1 字節(jié)了,節(jié)省了空間。
但如果使用public
繼承會(huì)怎么樣?
class Empty {};
class Test : public Empty {
long m2;
};
// 假如這里有一個(gè)函數(shù)讓傳Empty類對(duì)象
void f(const Empty &obj) {}
// 那么下面的調(diào)用將會(huì)合法
void Demo() {
Test t;
f(t); // OK
}
Test
由于是Empty
的子類,所以會(huì)觸發(fā)多態(tài)性,t
會(huì)當(dāng)做Empty
類型傳入f
中。這顯然問題很大呀!如果用這個(gè)例子看不出問題的話,我們換一個(gè)例子:
class Alloc {
public:
void *Create();
void Destroy();
};
class Vector : public Alloc {
};
// 這個(gè)函數(shù)用來創(chuàng)建buffer
void CreateBuffer(const Alloc &alloc) {
void *buffer = alloc.Create(); // 調(diào)用分配器的Create方法創(chuàng)建空間
}
void Demo() {
Vector ve; // 這是一個(gè)容器
CreateBuffer(ve); // 語法上是可以通過的,但是顯然不合理
}
內(nèi)存分配器往往就是個(gè)空類,因?yàn)樗惶峁┮恍┓椒?,不提供具體成員。Vector
是一個(gè)容器,如果這里用public
繼承,那么容器將成為分配器的一種,然后調(diào)用CreateBuffer
的時(shí)候可以傳一個(gè)容器進(jìn)去,這顯然很不合理呀!
那么此時(shí),用私有繼承就可以完美解決這個(gè)問題了
class Alloc {
public:
void *Create();
void Destroy();
};
class Vector : private Alloc {
private:
void *buffer;
size_t size;
// ...
};
// 這個(gè)函數(shù)用來創(chuàng)建buffer
void CreateBuffer(const Alloc &alloc) {
void *buffer = alloc.Create(); // 調(diào)用分配器的Create方法創(chuàng)建空間
}
void Demo() {
Vector ve; // 這是一個(gè)容器
CreateBuffer(ve); // ERR,會(huì)報(bào)錯(cuò),私有繼承關(guān)系不可觸發(fā)多態(tài)
}
此時(shí),由于私有繼承不可觸發(fā)多態(tài),那么Vector
就并不是Alloc
的一種,也就是說,從 OOP 理論上來說,他們并不是繼承關(guān)系。而由于有了私有繼承,在Vector
中可以調(diào)用Alloc
里的方法以及類型重命名,所以這其實(shí)是一種組合關(guān)系。
而又因?yàn)?EBO,所以也不用擔(dān)心Alloc
占用Vector
的成員空間的問題。
谷歌規(guī)范中規(guī)定了繼承必須是public
的,這主要還是在貼近 OOP 理論。另一方面就是說,雖然使用私有繼承是為了壓縮空間,但一定程度上也是犧牲了代碼的可讀性,讓我們不太容易看得出兩種類型之間的關(guān)系,因此在絕大多數(shù)情況下,還是應(yīng)當(dāng)使用public
繼承。不過筆者仍然持有“萬事皆不可一棒子打死”的觀點(diǎn),如果我們確實(shí)需要 EBO 的特性否則會(huì)大幅度犧牲性能的話,那么還是應(yīng)當(dāng)允許使用私有繼承。
與私有繼承類似,C++的多繼承同樣是“語法上”的繼承,而實(shí)際意義上可能并不是 OOP 中的“繼承”關(guān)系。再以前面章節(jié)的 Pet 為例:
class Pet {
public:
virtual void Feed() = 0;
};
class Animal {};
class Cat : public Animal, public Pet {
public:
void Feed() override;
};
從形式上來說,Cat
同時(shí)繼承自Anmial
和Pet
,但從 OOP 理論上來說,Cat
和Animal
是繼承關(guān)系,而和Pet
是實(shí)現(xiàn)關(guān)系,前面章節(jié)已經(jīng)介紹得很詳細(xì)了,這里不再贅述。
但由于 C++并不是完全針對(duì) OOP 的,因此支持真正意義上的多繼承,也就是說,即便父類不是這種純虛類,也同樣支持集成,從語義上來說,類似于“交叉分類”。請(qǐng)看示例:
class Organic { // 有機(jī)物
};
class Inorganic { // 無機(jī)物
};
class Acid { // 酸
};
class Salt { // 鹽
};
class AceticAcid : public Organic, public Acid { // 乙酸
};
class HydrochloricAcid : public Inorganic, public Acid { // 鹽酸
};
class SodiumCarbonate : public Inorganic, public Salt { // 碳酸鈉
};
上面就是一個(gè)交叉分類法的例子,使用多繼承語法合情合理。如果換做其他 OOP 語言,可能會(huì)強(qiáng)行把“酸”或者“有機(jī)物”定義為協(xié)議類,然后用繼承+實(shí)現(xiàn)的方式來完成。但如果從化學(xué)分類上來看,無論是“酸堿鹽”還是“有機(jī)物無機(jī)物”,都是一種強(qiáng)分類,比如說“碳酸鈉”,它就是一種“無機(jī)物”,也是一種“鹽”,你并不能用類似于“貓是一種動(dòng)物,可以作為寵物”的理論來解釋,不能說“碳酸鈉是一種鹽,可以作為一種無機(jī)物”。
因此 C++中的多繼承是哪種具體意義,取決于父類本身是什么。如果父類是個(gè)協(xié)議類,那這里就是“實(shí)現(xiàn)”語義,而如果父類本身就是個(gè)實(shí)際類,那這里就是“繼承”語義。當(dāng)然了,像私有繼承的話表示是“組合”語義。不過 C++本身并不在意這種語義,有時(shí)為了方便,我們也可能用公有繼承來表示組合語義,比如說:
class Point {
public:
double x, y;
};
class Circle : public Point {
public:
double r; // 半徑
};
這里Circle
繼承了Point
,但顯然不是說“圓是一個(gè)點(diǎn)”,這里想表達(dá)的就是圓類“包含了”點(diǎn)類的成員,所以只是為了復(fù)用。從意義上來說,Circle
類中繼承來的x
和y
顯然表達(dá)的是圓心的坐標(biāo)。不過這樣寫并不符合設(shè)計(jì)規(guī)范,但筆者用這個(gè)例子希望解釋的是C++并不在意類之間實(shí)際是什么關(guān)系,它在意的是數(shù)據(jù)復(fù)用,因此我們更需要了解一下多繼承體系中的內(nèi)存布局。
對(duì)于一個(gè)普通的類來說,內(nèi)存布局就是按照成員的聲明順序來布局的,與 C 語言中結(jié)構(gòu)體布局相同,例如:
class Test1 {
public:
char a;
int b;
short c;
};
那么Test1
的內(nèi)存布局就是
字節(jié)編號(hào) | 內(nèi)容 |
---|---|
0 | a |
1~3 | 內(nèi)存對(duì)齊保留字節(jié) |
4~7 | b |
8~9 | c |
9~11 | 內(nèi)存對(duì)齊保留字節(jié) |
但如果類中含有虛函數(shù),那么還會(huì)在末尾添加虛函數(shù)表的指針,例如:
class Test1 {
public:
char a;
int b;
short c;
virtual void f() {}
};
字節(jié)編號(hào) | 內(nèi)容 |
---|---|
0 | a |
1~3 | 內(nèi)存對(duì)齊保留字節(jié) |
4~7 | b |
8~9 | c |
9~15 | 內(nèi)存對(duì)齊保留字節(jié) |
16~23 | 虛函數(shù)表指針 |
多繼承時(shí),第一父類的虛函數(shù)表會(huì)與本類合并,其他父類的虛函數(shù)表單獨(dú)存在,并排列在本類成員的后面。
C++由于支持“普適意義上的多繼承”,那么就會(huì)有一種特殊情況——菱形繼承,請(qǐng)看例程:
struct A {
int a1, a2;
};
struct B : A {
int b1, b2;
};
struct C : A {
int c1, c2;
};
struct D : B, C {
int d1, d2;
};
根據(jù)內(nèi)存布局原則,D
類首先是B
類的元素,然后D
類自己的元素,最后是C
類元素:
字節(jié)序號(hào) | 意義 |
---|---|
0~15 | B 類元素 |
16~19 | d1 |
20~23 | d2 |
24~31 | C 類元素 |
如果再展開,會(huì)變成這樣:
字節(jié)序號(hào) | 意義 |
---|---|
0~3 | a1(B 類繼承自 A 類的) |
4~7 | a2(B 類繼承自 A 類的) |
8~11 | b1 |
12~15 | b2 |
16~19 | d1 |
20~23 | d2 |
24~27 | a1(C 類繼承自 A 類的) |
28~31 | a1(C 類繼承自 A 類的) |
32~35 | c1 |
36~39 | c2 |
可以發(fā)現(xiàn),A 類的成員出現(xiàn)了 2 份,這就是所謂“菱形繼承”產(chǎn)生的副作用。這也是 C++的內(nèi)存布局當(dāng)中的一種缺陷,多繼承時(shí)第一個(gè)父類作為主父類合并,而其余父類則是直接向后擴(kuò)寫,這個(gè)過程中沒有去重的邏輯(詳情參考上一節(jié))。這樣的話不僅浪費(fèi)空間,還會(huì)出現(xiàn)二義性問題,例如d.a1
到底是指從B
繼承來的a1
還是從C
里繼承來的呢?
C++引入虛擬繼承的概念就是為了解決這一問題。但怎么說呢,C++的復(fù)雜性往往都是因?yàn)?strong>為了解決一種缺陷而引入了另一種缺陷,虛擬繼承就是非常典型的例子,如果你直接去解釋虛擬繼承(比如說和普通繼承的區(qū)別)你一定會(huì)覺得莫名其妙,為什么要引入一種這樣奇怪的繼承方式。所以這里需要我們了解到,它是為了解決菱形繼承時(shí)空間爆炸的問題而不得不引入的。
首先我們來看一下普通的繼承和虛擬繼承的區(qū)別: 普通繼承:
struct A {
int a1, a2;
};
struct B : A {
int b1, b2;
};
B
的對(duì)象模型應(yīng)該是這樣的:
而如果使用虛擬繼承:
struct A {
int a1, a2;
};
struct B : virtual A {
int b1, b2;
};
對(duì)象模型是這樣的:
虛擬繼承的排布方式就類似于虛函數(shù)的排布,子類對(duì)象會(huì)自動(dòng)生成一個(gè)虛基表來指向虛基類成員的首地址。
就像剛才說的那樣,單純的虛擬繼承看上去很離譜,因?yàn)橥耆珱]有必要強(qiáng)行更換這樣的內(nèi)存布局,所以絕大多數(shù)情況下我們是不會(huì)用虛擬繼承的。但是菱形繼承的情況,就不一樣了,普通的菱形繼承會(huì)這樣:
struct A {
int a1, a2;
};
struct B : A {
int b1, b2;
};
struct C : A {
int c1, c2;
};
struct D : B, C {
int d1, d2;
};
D
的對(duì)象模型:
但如果使用虛擬繼承,則可以把每個(gè)類單獨(dú)的東西抽出來,重復(fù)的內(nèi)容則用指針來指向:
struct A {
int a1, a2;
};
struct B : virtual A {
int b1, b2;
};
struct C : virtual A {
int c1, c2;
};
struct D : B, C {
int d1, d2;
};
D
的對(duì)象模型將會(huì)變成:
也就是說此時(shí),共有的虛基類只會(huì)保存一份,這樣就不會(huì)有二義性,同時(shí)也節(jié)省了空間。
但需要注意的是,D
繼承自B
和C
時(shí)是普通繼承,如果用了虛擬繼承,則會(huì)在 D 內(nèi)部又額外添加一份虛基表指針。要虛擬繼承的是B
和C
對(duì)A
的繼承,這也是虛擬繼承語法非常迷惑的地方,也就是說,菱形繼承的分支處要用虛擬繼承,而匯聚處要用普通繼承。所以我們還是要明白其底層原理,以及引入這個(gè)語法的原因(針對(duì)解決的問題),才能更好的使用這個(gè)語法,避免出錯(cuò)。
隱式構(gòu)造指的就是隱式調(diào)用構(gòu)造函數(shù)。換句話說,我們不用寫出類型名,而是僅僅給出構(gòu)造參數(shù),編譯期就會(huì)自動(dòng)用它來構(gòu)造對(duì)象。舉例來說:
class Test {
public:
Test(int a, int b) {}
};
void f(const Test &t) {
}
void Demo() {
f({1, 2}); // 隱式構(gòu)造Test臨時(shí)對(duì)象,相當(dāng)于f(Test{a, b})
}
上面例子中,f
需要接受的是Test
類型的對(duì)象,然而我們?cè)谡{(diào)用時(shí)僅僅使用了構(gòu)造參數(shù),并沒有指定類型,但編譯器會(huì)進(jìn)行隱式構(gòu)造。
尤其,當(dāng)構(gòu)造參數(shù)只有 1 個(gè)的時(shí)候,可以省略大括號(hào):
class Test {
public:
Test(int a) {}
Test(int a, int b) {}
};
void f(const Test &t) {
}
void Demo() {
f(1); // 隱式構(gòu)造Test{1},單參時(shí)可以省略大括號(hào)
f({2}); // 隱式構(gòu)造Test{2}
f({1, 2}); // 隱式構(gòu)造Test{1, 2}
}
這樣做的好處顯而易見,就是可以讓代碼簡化,尤其是在構(gòu)造string
或者vector
的時(shí)候更加明顯:
void f1(const std::string &str) {}
void f2(const std::vector<int> &ve) {}
void Demo() {
f1('123'); // 隱式構(gòu)造std::string{'123'},注意字符串常量是const char *類型
f2({1, 2, 3}); // 隱式構(gòu)造std::vector,注意這里是initialize_list構(gòu)造
}
當(dāng)然,如果遇到函數(shù)重載,原類型的優(yōu)先級(jí)大于隱式構(gòu)造,例如:
class Test {
public:
Test(int a) {}
};
void f(const Test &t) {
std::cout << 1 << std::endl;
}
void f(int a) {
std::cout << 2 << std::endl;
}
void Demo() {
f(5); // 會(huì)輸出2
}
但如果有多種類型的隱式構(gòu)造則會(huì)報(bào)二義性錯(cuò)誤:
class Test1 {
public:
Test1(int a) {}
};
class Test2 {
public:
Test2(int a) {}
};
void f(const Test1 &t) {
std::cout << 1 << std::endl;
}
void f(const Test2 &t) {
std::cout << 2 << std::endl;
}
void Demo() {
f(5); // ERR,二義性錯(cuò)誤
}
在返回值場景也支持隱式構(gòu)造,例如:
struct err_t {
int err_code;
const char *err_msg;
};
err_t f() {
return {0, 'success'}; // 隱式構(gòu)造err_t
}
但隱式構(gòu)造有時(shí)會(huì)讓代碼含義模糊,導(dǎo)致意義不清晰的問題(尤其是單參的構(gòu)造函數(shù)),例如:
class System {
public:
System(int version);
};
void Operate(const System &sys, int cmd) {}
void Demo() {
Operate(1, 2); // 意義不明確,不容易讓人意識(shí)到隱式構(gòu)造
}
上例中,System
表示一個(gè)系統(tǒng),其構(gòu)造參數(shù)是這個(gè)系統(tǒng)的版本號(hào)。那么這時(shí)用版本號(hào)的隱式構(gòu)造就顯得很突兀,而且只通過Operate(1, 2)
這種調(diào)用很難讓人想到第一個(gè)參數(shù)竟然是System
類型的。
因此,是否應(yīng)當(dāng)隱式構(gòu)造,取決于隱式構(gòu)造的場景,例如我們用const char *
來構(gòu)造std::string
就很自然,用一組數(shù)據(jù)來構(gòu)造一個(gè)std::vector
也很自然,或者說,代碼的閱讀者非常直觀地能反應(yīng)出來這里發(fā)生了隱式構(gòu)造,那么這里就適合隱式構(gòu)造,否則,這里就應(yīng)當(dāng)限定必須顯式構(gòu)造。用explicit
關(guān)鍵字限定的構(gòu)造函數(shù)不支持隱式構(gòu)造:
class Test {
public:
explicit Test(int a);
explicit Test(int a, int b);
Test(int *p);
};
void f(const Test &t) {}
void Demo() {
f(1); // ERR,f不存在int參數(shù)重載,Test的隱式構(gòu)造不允許用(因?yàn)橛衑xplicit限定),所以匹配失敗
f(Test{1}); // OK,顯式構(gòu)造
f({1, 2}); // ERR,同理,f不存在int, int參數(shù)重載,Test隱式構(gòu)造不許用(因?yàn)橛衑xplicit限定),匹配失敗
f(Test{1, 2}); // OK,顯式構(gòu)造
int a;
f(&a); // OK,隱式構(gòu)造,調(diào)用Test(int *)構(gòu)造函數(shù)
}
還有一種情況就是,對(duì)于變參的構(gòu)造函數(shù)來說,更要優(yōu)先考慮要不要加explicit
,因?yàn)樽儏藛螀?,并且默認(rèn)情況下所有類型的構(gòu)造(模板的所有實(shí)例,任意類型、任意個(gè)數(shù))都會(huì)支持隱式構(gòu)造,例如:
class Test {
public:
template <typename... Args>
Test(Args&&... args);
};
void f(const Test &t) {}
void Demo() {
f(1); // 隱式構(gòu)造Test{1}
f({1, 2}); // 隱式構(gòu)造Test{1, 2}
f('abc'); // 隱式構(gòu)造Test{'abc'}
f({0, 'abc'}); // 隱式構(gòu)造Test{0, 'abc'}
}
所以避免爆炸(生成很多不可控的隱式構(gòu)造),對(duì)于變參構(gòu)造最好還是加上explicit
,如果不加的話一定要慎重考慮其可能實(shí)例化的每一種情況。
在谷歌規(guī)范中,單參數(shù)構(gòu)造函數(shù)必須用explicit
限定,但筆者認(rèn)為這個(gè)規(guī)范并不完全合理,在個(gè)別情況隱式構(gòu)造意義非常明確的時(shí)候,還是應(yīng)當(dāng)允許使用隱式構(gòu)造。另外,即便是多參數(shù)的構(gòu)造函數(shù),如果當(dāng)隱式構(gòu)造意義不明確時(shí),同樣也應(yīng)當(dāng)用explicit
來限定。所以還是要視情況而定。
C++支持隱式構(gòu)造,自然考慮的是一些場景下代碼更簡潔,但歸根結(jié)底在于C++主要靠 STL 來擴(kuò)展功能,而不是語法。舉例來說,在 Swift 中,原生語法支持?jǐn)?shù)組、map、字符串等:
let arr = [1, 2, 3] // 數(shù)組
let map = [1 : 'abc', 25 : 'hhh', -1 : 'fail'] // map
let str = '123abc' // 字符串
因此,它并不需要所謂隱式構(gòu)造的場景,因?yàn)檎Z法本身已經(jīng)表明了它的類型。
而 C++不同,C++并沒有原生支持std::vector
、std::map
、std::string
等的語法,這就會(huì)讓我們?cè)谑褂眠@些基礎(chǔ)工具的時(shí)候很頭疼,因此引入隱式構(gòu)造來簡化語法。所以歸根結(jié)底,C++語言本身考慮的是語法層面的功能,而數(shù)據(jù)邏輯層面靠 STL 來解決,二者并不耦合。但又希望程序員能夠更加方便地使用 STL,因此引入了一些語言層面的功能,但它卻像全體類型開放了。
舉例來說,Swift 中,[1, 2, 3]
的語法強(qiáng)綁定Array
類型,[k1:v1, k2,v2]
的語法強(qiáng)綁定Map
類型,因此這里的“語言”和“工具”是耦合的。但 C++并不和 STL 耦合,他的思路是{x, y, z}
就是構(gòu)造參數(shù),哪種類型都可以用,你交給vector
時(shí)就是表示數(shù)組,你交給map
時(shí)就是表示 kv 對(duì),并不會(huì)將“語法”和“類型”做任何強(qiáng)綁定。因此把隱式構(gòu)造和explicit
都提供出來,交給開發(fā)者自行處理是否支持。
這是我們需要體會(huì)的 C++設(shè)計(jì)理念,當(dāng)然,也可以算是 C++的缺陷。
字符串同樣是 C++特別容易踩坑的位置。出于對(duì) C 語言兼容、以及上一節(jié)所介紹的 C++希望將“語言”和“類型”解耦的設(shè)計(jì)理念的目的,在 C++中,字符串并沒有映射為std::string
類型,而是保留 C 語言當(dāng)中的處理方式。編譯期會(huì)將字符串常量存儲(chǔ)在一個(gè)全局區(qū),然后再使用字符串常量的位置用一個(gè)指針代替。所以基本可以等價(jià)認(rèn)為,字符串常量(字面量)是const char *
類型。
但是,更多的場景下,我們都會(huì)使用std::string
類型來保存和處理字符串,因?yàn)樗δ芨鼜?qiáng)大,使用更方便。得益于隱式構(gòu)造,我們可以把一個(gè)字符串常量輕松轉(zhuǎn)化為std::string
類型來處理。
但本質(zhì)上來說,std::string
和const char *
是兩種類型,所以一些場景下它還是會(huì)出問題。
在進(jìn)行類型推導(dǎo)時(shí),字符串常量會(huì)按const char *
來處理,有時(shí)會(huì)導(dǎo)致問題,比如:
template <typename T>
void f(T t) {
std::cout << 1 << std::endl;
}
template <typename T>
void f(T *t) {
std::cout << 2 << std::endl;
}
void Demo() {
f('123');
f(std::string{'123'});
}
代碼的原意是將“值類型”和“指針類型”分開處理,至于字符串,照理說應(yīng)當(dāng)是一個(gè)“對(duì)象”,所以要按照值類型來處理。但如果我們用的是字符串常量,則會(huì)識(shí)別為const char *
類型,直接匹配到了指針處理方式,而并不會(huì)觸發(fā)隱式構(gòu)造。
C 風(fēng)格字符串有一個(gè)約定,就是以 0 結(jié)尾。它并不會(huì)去單獨(dú)存儲(chǔ)數(shù)據(jù)長度,而是很暴力地從首地址向后查找,找到 0 為止。但std::string
不同,其內(nèi)部有統(tǒng)計(jì)個(gè)數(shù)的成員,因此不會(huì)受 0 值得影響:
std::string str1{'123\0abc'}; // 0處會(huì)截?cái)?/span>
std::string str2{'123\0abc', 7}; // 不會(huì)截?cái)?/span>
截?cái)鄦栴}在傳參時(shí)更加明顯,比如說:
void f(const char *str) {}
void Demo() {
std::string str2{'123\0abc', 7};
// 由于f只支持C風(fēng)格字符串,因此轉(zhuǎn)化后傳入
f(str2.c_str()); // 但其實(shí)已經(jīng)被截?cái)嗔?/span>
}
前面的章節(jié)曾經(jīng)提到過,C++沒有引入額外的格式符,因此把std::string
傳入格式化函數(shù)的時(shí)候,也容易發(fā)生截?cái)鄦栴}:
std::string MakeDesc(const std::string &head, double data) {
// 拼湊一個(gè)xxx:ff%的形式
char buf[128];
std::sprintf(buf, '%s:%lf%%', head.c_str(), data); // 這里有可能截?cái)?/span>
return buf; // 這里也有可能截?cái)?/span>
}
總之,C 風(fēng)格的字符串永遠(yuǎn)難逃 0 值截?cái)鄦栴},而又因?yàn)?C++中仍然保留了 C 風(fēng)格字符串的所有行為,并沒有在語言層面直接關(guān)聯(lián)std::string
,因此在使用時(shí)一定要小心截?cái)鄦栴}。
由于 C++保留了 C 風(fēng)格字符串的行為,因此在很多場景下,把const char *
就默認(rèn)為了字符串,都會(huì)按照字符串去解析。但有時(shí)可能會(huì)遇到一個(gè)真正的指針,那么此時(shí)就會(huì)有問題,比如說:
void Demo() {
int a;
char b;
std::cout << &a << std::endl; // 流接受指針,打印指針的值
std::cout << &b << std::endl; // 流接收char *,按字符串處理
}
STL 中所有流接收到char *
或const char *
時(shí),并不會(huì)按指針來解析,而是按照字符串解析。在上面例子中,&b
本身應(yīng)當(dāng)就是個(gè)單純指針,但是輸出流卻將其按照字符串處理了,也就是會(huì)持續(xù)向后搜索找到 0 值為止,那這里顯然是發(fā)生越界了。
因此,如果我們給char
、signed char
、unsigned char
類型取地址時(shí),一定要考慮會(huì)不會(huì)被識(shí)別為字符串。
原本int8_t
和uint8_t
是用來表示“8 位整數(shù)”的,但是不巧的是,他們的定義是:
using int8_t = signed char;
using uint8_t = unsigned char;
由于 C 語言歷史原因,ASCII 碼只有 7 位,所以“字符”類型有無符號(hào)是沒區(qū)別的,而當(dāng)時(shí)沒有定制規(guī)范,因此不同編譯器可能有不同處理。到后來干脆把char
當(dāng)做獨(dú)立類型了。所以char
和signed char
以及unsigned char
是不同類型。這與其他類型不同,例如int
和signed int
是同一類型。
但是類似于流的處理中,卻沒有把signed char
和unsigned char
單獨(dú)拿出來處理,都是按照字符來處理了(這里筆者也不知道什么原因)。而int8_t
和uint8_t
又是基于此定義的,所以也會(huì)出現(xiàn)奇怪問題,比如:
uint8_t n = 56; // 這里是單純想放一個(gè)整數(shù)
std::cout << n << std::endl; // 但這里會(huì)打印出8,而不是56
原本uint8_t
是想屏蔽掉char
這層含義,讓它單純地表示 8 位整數(shù)的,但是在 STL 的解析中,卻又讓它有了“字符”的含義,去按照 ASCII 碼來解析了,讓uint8_t
的定義又失去了原本該有的含義,所以這里也是很容易踩坑的地方。
(這一點(diǎn)筆者真的沒想明白為什么,明明是不同類型,但為什么沒有區(qū)分開??赡芡瑯邮菤v史原因吧,總之這個(gè)點(diǎn)可以算得上真正意義上的“缺陷”了。)
new
這個(gè)運(yùn)算符相信大家一定不陌生,即便是非 C++系其他語言一般都會(huì)保留new
這個(gè)關(guān)鍵字。而且這個(gè)已經(jīng)成為業(yè)界的一個(gè)哏了,比如說“沒有對(duì)象怎么辦?不怕,new 一個(gè)!”
從字面意思就能看得出,這是“新建”的意思,不過在 C++中,new
遠(yuǎn)不止字面看上去這么簡單。而且,delete
關(guān)鍵字基本算得上是 C++的特色了,其他語言中基本見不到。
“堆空間”的概念同樣繼承自 C 語言,它是提供給程序手動(dòng)管理、調(diào)用的內(nèi)存空間。在 C 語言中,malloc
用于分配堆空間,free
用于回收。自然,在 C++中仍然可以用malloc
和free
但使用malloc
有一個(gè)不方便的地方,我們來看一下malloc
的函數(shù)原型:
void *malloc(size_t size);
malloc
接收的是字節(jié)數(shù),也就是我們需要手動(dòng)計(jì)算出我們需要的空間是多少字節(jié)。它不能方便地通過某種類型直接算出空間,通常需要sizeof
運(yùn)算。malloc
返回值是void *
類型,是一個(gè)泛型指針,也就是沒有指定默認(rèn)解類型的,使用時(shí)通常需要類型轉(zhuǎn)換,例如:
int *data = (int *)malloc(sizeof(int));
而new
運(yùn)算符可以完美解決上面的問題,注意,在 C++中new
是一個(gè)運(yùn)算符:
int *data = new int;
同理,delete
也是一個(gè)運(yùn)算符,用于釋放空間:
delete data;
熟悉 C++運(yùn)算符重載的讀者一定清楚,C++中運(yùn)算符的本質(zhì)其實(shí)就是一個(gè)函數(shù)的語法糖,例如a + b
實(shí)際上就是operator +(a, b)
,a++
實(shí)際上就是a.operator++()
,甚至仿函數(shù)、下標(biāo)運(yùn)算也都是函數(shù)調(diào)用,比如f()
就是f.operator()()
,a[i]
就是a.operator[](i)
。
既然new
和delete
也是運(yùn)算符,那么它就應(yīng)當(dāng)也符合這個(gè)原理,一定有一個(gè)operator new
的函數(shù)存在,下面是它的函數(shù)原型:
void *operator new(size_t size);
void *operator new(size_t size, void *ptr);
這個(gè)跟我們直觀想象可能有點(diǎn)不一樣,它的返回值仍然是void *
,也并不是一個(gè)模板函數(shù)用來判斷大小。所以,new
運(yùn)算符跟其他運(yùn)算符并不一樣,它并不只是單純映射成operator new
,而是做了一些額外操作。
另外,這個(gè)擁有 2 個(gè)參數(shù)的重載又是怎么回事呢?這個(gè)等一會(huì)再來解釋。
系統(tǒng)內(nèi)置的operator new
本質(zhì)上就是malloc
,所以如果我們直接調(diào)operator new
和operator delete
的話,本質(zhì)上來說,和malloc
和free
其實(shí)沒什么區(qū)別:
int *data = static_cast<int *>(operator new(sizeof(int)));
operator delete(data);
而當(dāng)我們用運(yùn)算符的形式來書寫時(shí),編譯器會(huì)自動(dòng)處理類型的大小,以及返回值。new
運(yùn)算符必須作用于一個(gè)類型,編譯器會(huì)將這個(gè)類型的 size 作為參數(shù)傳給operator new
,并把返回值轉(zhuǎn)換為這個(gè)類型的指針,也就是說:
new T;
// 等價(jià)于
static_cast<T *>(operator new(sizeof(T)))
delete
運(yùn)算符要作用于一個(gè)指針,編譯器會(huì)將這個(gè)指針作為參數(shù)傳給operator delete
,也就是說:
delete ptr;
// 等價(jià)于
operator delete(ptr);
之所以要引入operator new
和operator delete
還有一個(gè)原因,就是可以重載。默認(rèn)情況下,它們操作的是堆空間,但是我們也可以通過重載來使得其操作自己的內(nèi)存池。
std::byte buffer[16][64]; // 一個(gè)手動(dòng)的內(nèi)存池
std::array<void *, 16> buf_mark {nullptr}; // 統(tǒng)計(jì)已經(jīng)使用的內(nèi)存池單元
struct Test {
int a, b;
static void *operator new(size_t size) noexcept; // 重載operator new
static void operator delete(void *ptr); // 重載operator delete
};
void *Test::operator new(size_t size) noexcept {
// 從buffer中分配資源
for (int i = 0; i < 16; i++) {
if (buf_mark.at(i) == nullptr) {
buf_mark.at(i) = buffer[i];
return buffer[i];
}
}
return nullptr;
}
void Test::operator delete(void *ptr) {
for (int i = 0; i < 16; i++) {
if (buf_mark.at(i) == ptr) {
buf_mark.at(i) = nullptr;
}
}
}
void Demo() {
Test *t1 = new Test; // 會(huì)在buffer中分配
delete t1; // 釋放buffer中的資源
}
另一個(gè)點(diǎn),相信大家已經(jīng)發(fā)現(xiàn)了,operator new
和operator delete
是支持異常拋出的,而我們這里引用直接用空指針來表示分配失敗的情況了,于是加上了noexcept
修飾。而默認(rèn)的情況下,可以通過接收異常來判斷是否分配成功,而不用每次都對(duì)指針進(jìn)行判空。
malloc
的另一個(gè)問題就是處理非平凡構(gòu)造的類類型。當(dāng)一個(gè)類是非平凡構(gòu)造時(shí),它可能含有虛函數(shù)表、虛基表,還有可能含有一些額外的構(gòu)造動(dòng)作(比如說分配空間等等),我們拿一個(gè)最簡單的字符串處理類為例:
class String {
public:
String(const char *str);
~String();
private:
char *buf;
size_t size;
size_t capicity;
};
String::String(const char *str):
buf((char *)std::malloc(std::strlen(str) + 1)),
size(std::strlen(str)),
capicity(std::strlen(str) + 1) {
std::memcpy(buf, str, capicity);
}
String::~String() {
if (buf != nullptr) {
std::free(buf);
}
}
void Demo() {
String *str = (String *)std::malloc(sizeof(String));
// 再使用str一定是有問題的,因?yàn)闆]有正常構(gòu)造
}
上面例子中,String
就是一個(gè)非平凡的類型,它在構(gòu)造函數(shù)中創(chuàng)建了堆空間。如果我們直接通過malloc
分配一片String
大小的空間,然后就直接用的話,顯然是會(huì)出問題的,因?yàn)闃?gòu)造函數(shù)沒有執(zhí)行,其中buf
管理的堆空間也是沒有進(jìn)行分配的。
所以,在 C++中,創(chuàng)建一個(gè)對(duì)象應(yīng)該分 2 步:
同樣,釋放一個(gè)對(duì)象也應(yīng)該分 2 步:
這個(gè)理念在 OC 語言中貫徹得非常徹底,OC 中沒有默認(rèn)的構(gòu)造函數(shù),都是通過實(shí)現(xiàn)一個(gè)類方法來進(jìn)行構(gòu)造的,因此構(gòu)造前要先分配空間:
NSString *str = [NSString alloc]; // 分配NSString大小的內(nèi)存空間
[str init]; // 調(diào)用初始化函數(shù)
// 通常簡寫為:
NSString *str = [[NSString alloc] init];
但是在 C++中,初始化方法并不是一個(gè)普通的類方法,而是特殊的構(gòu)造函數(shù),那如何手動(dòng)調(diào)用構(gòu)造函數(shù)呢?
我們知道,要想調(diào)用構(gòu)造函數(shù)(構(gòu)造一個(gè)對(duì)象),我們首先需要一個(gè)分配好的內(nèi)存空間。因此,要拿著用于構(gòu)造的內(nèi)存空間,以構(gòu)造參數(shù),才能構(gòu)造一個(gè)對(duì)象(也就是調(diào)用構(gòu)造函數(shù))。C++管這種語法叫做就地構(gòu)造(placement new)。
String *str = static_cast<String *>(std::malloc(sizeof(String))); // 分配內(nèi)存空間
new(str) String('abc'); // 在str指向的位置調(diào)用String的構(gòu)造函數(shù)
就地構(gòu)造的語法就是new(addr) T(args...)
,看得出,這也是new
運(yùn)算符的一種。這時(shí)我們?cè)倩厝タ?code>operator new的一個(gè)重載,應(yīng)該就能猜到它是干什么的了:
void *operator new(size_t size, void *ptr);
就是用于支持就地構(gòu)造的函數(shù)。
要注意的是,如果是通過就地構(gòu)造方式構(gòu)造的對(duì)象,需要再回收內(nèi)存空間之前進(jìn)行析構(gòu)。以上面String
為例,如果不析構(gòu)直接回收,那么buf
所指的空間就不能得到釋放,從而造成內(nèi)存泄漏:
str->~String(); // 析構(gòu)
std::free(str); // 釋放內(nèi)存空間
看到本節(jié)的標(biāo)題,相信讀者會(huì)恍然大悟。C++中new
運(yùn)算符同時(shí)承擔(dān)了“分配空間”和“構(gòu)造對(duì)象”的任務(wù)。上一節(jié)的例子中我們是通過malloc
和free
來管理的,自然,通過operator new
和operator delete
也是一樣的,而且它們還支持針對(duì)類型的重載。
因此,我們說,一次new
,相當(dāng)于先operator new
(分配空間)加placement new
(調(diào)用構(gòu)造函數(shù))。
String *str = new String('abc');
// 等價(jià)于
String *str = static_cast<String *>(operator new(sizeof(String)));
new(str) String('abc');
同理,一次delete
相當(dāng)于先“析構(gòu)”,再operator delete
(釋放空間)
delete str;
// 等價(jià)于
str->~String();
operator delete(str);
這就是new
和delete
的神秘面紗,它確實(shí)和普通的運(yùn)算符不一樣,除了對(duì)應(yīng)的operator
函數(shù)外,還有對(duì)構(gòu)造、析構(gòu)的處理。
但也正是由于 C++總是進(jìn)行一些隱藏操作,才會(huì)復(fù)雜度激增,有時(shí)也會(huì)出現(xiàn)一些難以發(fā)現(xiàn)的問題,所以我們一定要弄清楚它的本質(zhì)。
new []
和delete []
的語法看起來是“創(chuàng)建/刪除數(shù)組”的語法。但其實(shí)它們也并不特殊,就是封裝了一層的new
和delete
void *operator new[](size_t size);
void operator delete[](void *ptr);
可以看出,operator new[]
和operator new
完全一樣,opeator delete[]
和operator delete
也完全一樣,所以區(qū)別應(yīng)當(dāng)在編譯器的解釋上。operator new T[size]
的時(shí)候,會(huì)計(jì)算出size
個(gè)T
類型的總大小,然后調(diào)用operator new[]
,之后,會(huì)依次對(duì)每個(gè)元素進(jìn)行構(gòu)造。也就是說:
String *arr_str = new String [4] {'abc', 'def', '123'};
// 等價(jià)于
String *arr_str = static_cast<String *>(opeartor new[](sizeof(String) * 3));
new(arr_str) String('abc');
new(arr_str + 1) String('def');
new(arr_str + 2) String('123');
new(arr_str + 3) String; // 沒有寫在列表中的會(huì)用無參構(gòu)造函數(shù)
同理,delete []
會(huì)首先依次調(diào)用析構(gòu),然后再調(diào)用operator delete []
來釋放空間:
delete [] arr_str;
// 等價(jià)于
for (int i = 0; i < 4; i++) {
arr_str[i].~String();
}
operator delete[] (arr_str);
總結(jié)下來new []
相當(dāng)于一次內(nèi)存分配加多次就地構(gòu)造,delete []
運(yùn)算符相當(dāng)于多次析構(gòu)加一次內(nèi)存釋放。
constexpr
全程叫“常量表達(dá)式(constant expression)”,顧名思義,將一個(gè)表達(dá)式定義為“常量”。
關(guān)于“常量”的概念筆者在前面“const 引用”的章節(jié)已經(jīng)詳細(xì)敘述過,只有像1
,'a'
,2.5f
之類的才是真正的常量。儲(chǔ)存在內(nèi)存中的數(shù)據(jù)都應(yīng)當(dāng)叫做“變量”。
但很多時(shí)候我們?cè)诔绦蚓帉懙臅r(shí)候,會(huì)遇到一些編譯期就能確定的量,但不方便直接用常量表達(dá)的情況。最簡單的一個(gè)例子就是“魔鬼數(shù)字”:
using err_t = int;
err_t Process() {
// 某些錯(cuò)誤
return 25;
// ...
return 0;
}
作為錯(cuò)誤碼的時(shí)候,我們只能知道業(yè)界約定0
表示成功,但其他的錯(cuò)誤碼就不知道什么含義了,比如這里的25
號(hào)錯(cuò)誤碼,非常突兀,根本不知道它是什么含義。
C 中的解決的辦法就是定義宏,又有宏是預(yù)編譯期進(jìn)行替換的,因此它在編譯的時(shí)候一定是作為常量存在的,我們又可以通過宏名稱來增加可讀性:
#define ERR_DATA_NOT_FOUNT 25
#define SUCC 0
using err_t = int;
err_t Process() {
// 某些錯(cuò)誤
return ERR_DATA_NOT_FOUNT;
// ...
return SUCC;
}
(對(duì)于錯(cuò)誤碼的場景當(dāng)然還可以用枚舉來實(shí)現(xiàn),這里就不再贅述了。)
用宏雖然可以解決魔數(shù)問題,但是宏本身是不推薦使用的,詳情大家可以參考前面“宏”的章節(jié),里面介紹了很多宏濫用的情況。
不過最主要的一點(diǎn)就是宏不是類型安全的。我們既希望定義一個(gè)類型安全的數(shù)據(jù),又不希望這個(gè)數(shù)據(jù)成為“變量”來占用內(nèi)存空間。這時(shí),就可以使用 C++11 引入的constexpr
概念。
constexpr double pi = 3.141592654;
double Squ(double r) {
return pi * r * r;
}
這里的pi
雖然是double
類型的,類型安全,但因?yàn)橛?code>constexpr修飾了,因此它會(huì)在編譯期間成為“常量”,而不會(huì)占用內(nèi)存空間。
用constexpr
修飾的表達(dá)式,會(huì)保留其原有的作用域和類型(例如上面的pi
就跟全局變量的作用域是一樣的),只是會(huì)變成編譯期常量。
既然constexpr
叫“常量表達(dá)式”,那么也就是說有一些編譯期參數(shù)只能用常量,用constexpr
修飾的表達(dá)式也可以充當(dāng)。
舉例來說,模板參數(shù)必須是一個(gè)編譯期確定的量,那么除了常量外,constexpr
修飾的表達(dá)式也可以:
template <int N>
struct Array {
int data[N];
};
constexpr int default_size = 16;
const int g_size = 8;
void Demo() {
Array<8> a1; // 常量OK
Array<default_size> a2; // 常量表達(dá)式OK
Array<g_size> a3; // ERR,非常量不可以,只讀變量不是常量
}
至于其他類型的表達(dá)式,也支持constexpr
,原則在于它必須要是編譯期可以確定的類型,比如說 POD 類型:
constexpr int arr[] {1, 2, 3};
constexpr std::array<int> arr2 {1, 2, 3};
void f() {}
constexpr void (*fp)() = f;
constexpr const char *str = 'abc123';
int g_val = 5;
constexpr int *pg = &g_val;
這里可能有一些和直覺不太一樣的地方,我來解釋一下。首先,數(shù)組類型是編譯期可確定的(你可以單純理解為一組數(shù),使用時(shí)按對(duì)應(yīng)位置替換為值,并不會(huì)真的分配空間)。
std::array
是 POD 類型,那么就跟普通的結(jié)構(gòu)體、數(shù)組一樣,所以都可以作為編譯期常量。
后面幾個(gè)指針需要重點(diǎn)解釋一下。用constexpr
修飾的除了可以是絕對(duì)的常量外,在編譯期能確定的量也可以視為常量。比如這里的fp
,由于函數(shù)f
的地址,在運(yùn)行期間是不會(huì)改變的,編譯期間盡管不能確定其絕對(duì)地址,但可以確定它的相對(duì)地址,那么作為函數(shù)指針fp
,它就是f
將要保存的地址,所以,這就是編譯期可以確定的量,也可用constexpr
修飾。
同理,str
指向的是一個(gè)字符串常量,字符串常量同樣是有一個(gè)固定存放地址的,位置不會(huì)改變,所以用于指向這個(gè)數(shù)據(jù)的指針str
也可以用constexpr
修飾。要注意的是:constexpr
表達(dá)式有固定的書寫位置,與const
的位置不一定相同。比如說這里如果定義只讀變量應(yīng)該是const char *const str
,后面的const
修飾str
,前面的const
修飾char
。但換成常量表達(dá)式時(shí),constexpr
要放在最前,因此不能寫成const char *constexpr str
,而是要寫成constexpr const char *str
。當(dāng)然,少了這個(gè)const
也是不對(duì)的,因?yàn)椴粌H是指針不可變,指針?biāo)笖?shù)據(jù)也不可變。這個(gè)也是 C++中推薦的定義字符串常量別名的方式,優(yōu)于宏定義。
最后的這個(gè)pg
也是一樣的道理,因?yàn)槿肿兞康牡刂芬彩枪潭ǖ?,運(yùn)行期間不會(huì)改變,因此pg
也可以用常量表達(dá)式。
當(dāng)然,如果運(yùn)行期間可能發(fā)生改變的量(也就是編譯期間不能確定的量)就不可以用常量表達(dá)式,例如:
void Demo() {
int a;
constexpr int *p = &a; // ERR,局部變量地址編譯期間不能確定
static int b;
constexpr int *p2 = &b; // OK,靜態(tài)變量地址可以確定
constexpr std::string str = 'abc'; // ERR,非平凡POD類型不能編譯期確定內(nèi)部行為
}
希望讀者看到這一節(jié)標(biāo)題的時(shí)候不要崩潰,C++就是這么難以捉摸。
沒錯(cuò),雖然constexpr
已經(jīng)是常量表達(dá)式了,但是用constexpr
修飾變量的時(shí)候,它仍然是“定義變量”的語法,因此 C++希望它能夠兼容只讀變量的情況。
當(dāng)且僅當(dāng)一種情況下,constexpr
定義的變量會(huì)真的成為變量,那就是這個(gè)變量被取址的時(shí)候:
void Demo() {
constexpr int a = 5;
const int *p = &a; // 會(huì)讓a退化為const int類型
}
道理也很簡單,因?yàn)橹挥凶兞坎拍苋≈?。上面例子中,由于?duì)a
進(jìn)行了取地址操作,因此,a
不得不真正成為一個(gè)變量,也就是變?yōu)?code>const int類型。
那另一個(gè)問題就出現(xiàn)了,如果說,我對(duì)一個(gè)常量表達(dá)式既取了地址,又用到編譯期語法中了怎么辦?
template <int N>
struct Test {};
void Demo() {
constexpr int a = 5;
Test<a> t; // 用做常量
const int *p = &a; // 用做變量
}
沒關(guān)系,編譯器會(huì)讓它在編譯期視為常量去給那些編譯期語法(比如模板實(shí)例化)使用,之后,再把它用作變量寫到內(nèi)存中。
換句話說,在編譯期,這里的a
相當(dāng)于一個(gè)宏,所有的編譯期語法會(huì)用5
替換a
,Test<a>
就變成了Test<5>
。之后,又會(huì)讓a
成為一個(gè)只讀變量寫到內(nèi)存中,也就變成了const int a = 5;
那么const int *p = &a;
自然就是合法的了。
“就地構(gòu)造”這個(gè)詞本身就很 C++。很多程序員都能發(fā)現(xiàn),到處糾結(jié)對(duì)象有沒有拷貝,糾結(jié)出參還是返回值的只有 C++程序員。
無奈,C++確實(shí)沒法完全擺脫底層考慮,C++程序員也會(huì)更傾向于高性能代碼的編寫。當(dāng)出現(xiàn)嵌套結(jié)構(gòu)的時(shí)候,就會(huì)考慮復(fù)制問題了。
舉個(gè)最簡單的例子,給一個(gè)vector
進(jìn)行push_back
操作時(shí),會(huì)發(fā)生一次復(fù)制:
struct Test {
int a, b;
};
void Demo() {
std::vector<Test> ve;
ve.push_back(Test{1, 2}); // 用1,2構(gòu)造臨時(shí)對(duì)象,再移動(dòng)構(gòu)造
}
原因就在于,push_back
的原型是:
template <typename T>
void vector<T>::push_back(const T &);
template <typename T>
void vector<T>::push_back(T &&);
如果傳入左值,則會(huì)進(jìn)行拷貝構(gòu)造,傳入右值會(huì)移動(dòng)構(gòu)造。但是對(duì)于Test
來說,無論深淺復(fù)制,都是相同的復(fù)制。這多構(gòu)造一次Test
臨時(shí)對(duì)象本身就是多余的。
既然,我們已經(jīng)有{1, 2}
的構(gòu)造參數(shù)了,能否想辦法跳過這一次臨時(shí)對(duì)象,而是直接在vector
末尾的空間上進(jìn)行構(gòu)造呢?這就涉及了就地構(gòu)造的問題。我們?cè)谇懊妗皀ew 和 delete”的章節(jié)介紹過,“分配空間”和“構(gòu)造對(duì)象”的步驟可以拆解開來做。首先對(duì)vector
的buffer
進(jìn)行擴(kuò)容(如果需要的話),確定了要放置新對(duì)象的空間以后,直接使用placement new
進(jìn)行就地構(gòu)造。
比如針對(duì)Test
的vector
我們可以這樣寫:
template <>
void vector<Test>::emplace_back(int a, int b) {
// 需要時(shí)擴(kuò)容
// new_ptr表示末尾為新對(duì)象分配的空間
new(new_ptr) Test{a, b};
}
STL 中把容器的就地構(gòu)造方法叫做emplace
,原理就是通過傳遞構(gòu)造參數(shù),直接在對(duì)應(yīng)位置就地構(gòu)造。所以更加通用的方法應(yīng)該是:
template <typename T, typename... Args>
void vector<T>::emplace_back(Args &&...args) {
// new_ptr表示末尾為新對(duì)象分配的空間
new(new_ptr) T{std::forward<Args>(args)...};
}
就地構(gòu)造確實(shí)能在一定程度上解決多余的對(duì)象復(fù)制問題,但如果是嵌套形式就實(shí)則沒辦法了,舉例來說:
struct Test {
int a, b;
};
void Demo() {
std::vector<std::tuple<int, Test>> ve;
ve.emplace_back(1, Test{1, 2}); // tuple嵌套的Test沒法就地構(gòu)造
}
也就是說,我們沒法在就地構(gòu)造對(duì)象時(shí)對(duì)參數(shù)再就地構(gòu)造。
這件事情放在map
或者unordered_map
上更加有趣,因?yàn)檫@兩個(gè)容器的成員都是std::pair
,所以對(duì)它進(jìn)行emplace
的時(shí)候,就地構(gòu)造的是pair
而不是內(nèi)部的對(duì)象:
struct Test {
int a, b;
};
void Demo() {
std::map<int, Test> ma;
ma.emplace(1, Test{1, 2}); // 這里emplace的對(duì)象是pair<int, Test>
}
不過好在,map
和unordered_map
提供了try_emplace
方法,可以在一定程度上解決這個(gè)問題,函數(shù)原型是:
template <typename K, typename V, typename... Args>
std::pair<iterator, bool> map<K, V>::try_emplace(const K &key, Args &&...args);
這里把key
和value
拆開了,前者還是只能通過復(fù)制的方式傳遞,但后者可以就地構(gòu)造。(實(shí)際使用時(shí),value
更需要就地構(gòu)造,一般來說key
都是整數(shù)、字符串這些。)那么我們可用它代替emplace
:
void Demo() {
std::map<int, Test> ma;
ma.try_emplace(1, 1, 2); // 1, 2用于構(gòu)造Test
}
但看這個(gè)函數(shù)名也能猜到,它是“不覆蓋邏輯”。也就是如果容器中已有對(duì)應(yīng)的key
,則不會(huì)覆蓋。返回值中第一項(xiàng)表示對(duì)應(yīng)項(xiàng)迭代器(如果是新增,就返回新增這一條的迭代器,如果是已有key
則放棄新增,并返回原項(xiàng)的迭代器),第二項(xiàng)表示是否成功新增(如果已有key
會(huì)返回false
)。
void Demo() {
std::map<int, Test> ma {{1, Test{1, 2}}};
auto [iter, is_insert] = ma.try_emplace(1, 7, 8);
auto ¤t_test = iter->second;
std::cout << current_test.a << ', ' << current_test.b << std::endl; // 會(huì)打印1, 2
}
不過有一些場景利用try_emplace
會(huì)很方便,比如處理多重key
時(shí)使用map
嵌套map
的場景,如果用emplace
要寫成:
void Demo() {
std::map<int, std::map<int, std::string>> ma;
// 例如想給key為(1, 2)新增value為'abc'的
// 由于無法確定外層key為1是否已經(jīng)有了,所以要單獨(dú)判斷
if (ma.count(1) == 0) {
ma.emplace(1, std::map<int, std::string>{});
}
ma.at(1).emplace(1, 'abc');
}
但是利用try_emplace
就可以更取巧一些:
void Demo() {
std::map<int, std::map<int, std::string>> ma;
ma.try_emplace(1).first->second.try_emplace(1, 'abc');
}
解釋一下,如果ma
含有key
為1
的項(xiàng),就返回對(duì)應(yīng)迭代器,如果沒有的話則會(huì)新增(由于沒指定后面的參數(shù),所以會(huì)構(gòu)造一個(gè)空map
),并返回迭代器。迭代器在返回值的第一項(xiàng),所以取first
得到迭代器,迭代器指向的是map
內(nèi)部的pair
,取second
得到內(nèi)部的map
,再對(duì)其進(jìn)行一次try_emplace
插入內(nèi)部的元素。
當(dāng)然了,這么做確實(shí)可讀性會(huì)下降很多,具體使用時(shí)還需要自行取舍。
曾經(jīng)有很多朋友問過我,C++適不適合入門?C++適不適合干活?我學(xué) C++跟我學(xué) java 哪個(gè)更賺錢?。?/p>
筆者持有這樣的觀點(diǎn):C++并不是最適合生產(chǎn)的語言,但 C++一定是最值得學(xué)習(xí)的語言。
如果說你單純就是想干活,享受產(chǎn)出的快樂,那我不建議你學(xué) C++,因?yàn)樘菀讋裢?,找一些新語言,語法簡單清晰容易上手,自然干活效率會(huì)高很多;但如果你希望更多地理解編程語言,全面了解一些自底層到上層的原理和進(jìn)程,希望享受研究和開悟的快樂,那非 C++莫屬了。掌握了 C++再去看其他語言,相信你一定會(huì)有不同的見解的。
所以到現(xiàn)在這個(gè)時(shí)間點(diǎn),應(yīng)該說,C++仍然還是我的信仰,我認(rèn)為 C++將會(huì)在將來很長一段時(shí)間存在,并且以一個(gè)長老的身份發(fā)揮其在業(yè)界的作用和價(jià)值,但同時(shí)也會(huì)有越來越多新語言的誕生,他們?cè)谧约哼m合的地方發(fā)揮著不一樣的光彩。我也不再會(huì)否認(rèn) C++的確有設(shè)計(jì)不合理的地方,不會(huì)否認(rèn)其存在不擅長的領(lǐng)域,也不會(huì)再去鄙視那些吐槽 C++復(fù)雜的人。與此同時(shí),我也不會(huì)拒絕涉足其他的領(lǐng)域,我認(rèn)為,只有不斷學(xué)習(xí)比較,不斷總結(jié)沉淀,才能持續(xù)進(jìn)步。
聯(lián)系客服