?
前言
C++是一門古老的語言,但仍然在不間斷更新中,不斷引用新特性。但與此同時(shí) C++又甩不掉巨大的歷史包袱,并且 C++的設(shè)計(jì)初衷和理念造成了 C++異常復(fù)雜,還出現(xiàn)了很多不合理的“缺陷”。
本文主要有 3 個(gè)目的:
- 總結(jié)一些 C++晦澀難懂的語法現(xiàn)象,解釋其背后原因,作為防踩坑之用;
- 和一些其他的編程語言進(jìn)行比較,列舉它們的優(yōu)劣;
- 發(fā)表一些我自己作為 C++程序員的看法和感受。
來自 C 語言的歷史包袱
C++有一個(gè)很大的歷史包袱,就是 C 語言。C 語言誕生時(shí)間很早,并且它是為了編寫 OS 而誕生的,語法更加底層。有人說,C 并不是針對程序員友好的語言,而是針對編譯期友好的語言。有些場景在 C 語言本身可能并沒有什么不合理,但放到 C++當(dāng)中會(huì)“爆炸”,或者說,會(huì)迅速變成一種“缺陷”,讓人異常費(fèi)解。
C++在演變過程中一直在吸收其他語言的優(yōu)勢,不斷提供新的語法、工具來進(jìn)行優(yōu)化。但為了兼容性(不僅僅是語法的兼容,還有一些設(shè)計(jì)理念的兼容),還是會(huì)留下很多坑。
數(shù)組
數(shù)組本身其實(shí)沒有什么問題,這種語法也非常常用,主要是表示連續(xù)一組相同的數(shù)據(jù)構(gòu)成的集合。但數(shù)組類型在待遇上卻和其他類型(比如說結(jié)構(gòu)體)非常不一樣。
數(shù)組的復(fù)制
我們知道,結(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ù)組類型傳參
由于數(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 件事:
-
提供一種隱式類型轉(zhuǎn)換,支持將數(shù)組類型轉(zhuǎn)換為首元素指針類型(比如說這里 arr 是
int[5]
類型,傳參時(shí)自動(dòng)轉(zhuǎn)換為int *
類型) - 函數(shù)參數(shù)的語法糖,如果在函數(shù)參數(shù)寫數(shù)組類型,那么會(huì)自動(dòng)轉(zhuǎn)換成元素指針類型,比如說下面這幾種寫法都完全等價(jià):
void?f(int?*arr);
void?f(int?arr[]);
void?f(int?arr[5]);
void?f(int?arr[100]);
所以這里非常容易誤導(dǎo)人的就在這個(gè)語法糖中,無論中括號里寫多少,或者不寫,這個(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ù),我們總不能針對于 1,2,3,4,5,6,...元素個(gè)數(shù)的數(shù)組都分別寫一個(gè)排序函數(shù)吧?其次,如果取子數(shù)組就會(huì)復(fù)制出一個(gè)新數(shù)組的話,也就不能對原數(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]
,類型說明符是從里往外描述的,而不是從左往右。
類型說明符同時(shí)承擔(dān)了動(dòng)作語義
這里的另一個(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 作為占位符仍然還是有些突兀和晦澀的。
將類型符和動(dòng)作語義分離的語言
我們來看一看其他語言是如何彌補(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)?{
??ptr.pointee?+=?1?//?給指針?biāo)赶虻闹导?
}
func?demo()?{
??var?a:?Int?=?5
??f1(&a)
}
OC 中的指針更加特殊和“奇葩”,首先,OC 完全保留了 C 中的指針用法,而額外擴(kuò)展的“類”類型則不允許出現(xiàn)在棧中,也就是說,所有對象都強(qiáng)制放在堆中,棧上只保留指針對其引用。雖然 OC 中的指針仍然是 C 指針,但由于操作對象的“奇葩”語法,倒是并不需要太擔(dān)心指針偏移的問題。
void?demo()?{
??NSObject?*obj?=?[[NSObject?alloc]?init];
??//?例如調(diào)用obj的description方法
??NSString?*desc?=?[obj?description];
??//?指針仍可偏移,但幾乎不會(huì)有人這樣來寫:
??[(obj+1)?description];?//?也會(huì)越界
}
隱式類型轉(zhuǎn)換
隱式類型轉(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í)候再對其進(jìn)行sizeof
運(yùn)算,結(jié)果是指針的大小,而并非數(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è)符號上,比如:
int?a1,?a2;
bool?b?=?a1?=?a2;
這里原本想寫b = a1 == a2
,但是錯(cuò)把==
寫成了=
,但編譯是可以完全通過的,因?yàn)?code style="padding:2px 4px;margin-right:2px;margin-left:2px;color:rgb(30,107,184);background-color:rgba(27,31,35,.05);font-family:'Microsoft YaHei';">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
(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(&s2);
??p->a?=?2;
??p->b?=?1;
??std::cout?<//?猜猜這里會(huì)輸出多少?
}
這里的指針類型問題和前面章節(jié)提到的指針偏移問題,綜合起來就是說 C/C++的指針操作的自由度過高,提升了語言的靈活度,同時(shí)也增加了其復(fù)雜度。
后置自增/自減
如果僅僅在 C 的角度上,后置自增/自減語法并沒有帶來太多的副作用,有時(shí)候在程序中作為一些小技巧反而可以讓程序更加精簡,比如說:
void?AttrCnt()?{
??static?int?count?=?0;
??std::cout?<std::endl;
}
但這個(gè)特性繼承到 C++后問題就會(huì)被放大,比如說下面的例子:
for?(auto?iter?=?ve.begin();?iter?!=?ve.end();?iter++)?{
}
這段代碼看似特別正常,但仔細(xì)想想,iter 作為一個(gè)對象類型,如果后置++
,一定會(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 style="padding:2px 4px;margin-right:2px;margin-left:2px;color:rgb(30,107,184);background-color:rgba(27,31,35,.05);font-family:'Microsoft YaHei';">+=1或-=1
語義并不完全等價(jià)。我們可以用順序迭代器來解釋。對于順序迭代器(比如說鏈表的迭代器),++
表示取下一個(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)在于:
1. 并沒有定長格式符
例如uint64_t
在 32 位環(huán)境下對應(yīng)的格式符是%llu
,但是在 64 位環(huán)境下對應(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ì)讓字符串字面量從中間斷開,非常不直觀。
2. 類型不一致
例如在 64 位環(huán)境下,long
和long long
都是 64 位長,但編譯器會(huì)識別為不同類型,在一些類型推導(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
和func
是不同實(shí)例,盡管在 64 位環(huán)境下long
和long long
真的看不出什么區(qū)別,但是編譯器就是會(huì)識別成不同類型。
格式化字符串
格式化字符串算是非常經(jīng)典的 C 的產(chǎn)物,不僅是 C++,非常多的語言都是支持這種格式符的,例如 java、Go、python 等等。
但 C++中的格式化字符串可以說完全就是 C 的那一套,根本沒有任何擴(kuò)展。換句話說,除了基本數(shù)據(jù)類型和 0 結(jié)尾的字符串以外,其他任何類型都沒有用于匹配的格式符。
例如,對于結(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è)元組
}
對于這些組合類型,我們就不得不手動(dòng)去訪問內(nèi)部成員,或者用循環(huán)訪問,非常不方便。
針對于字符串,還會(huì)有一個(gè)嚴(yán)重的潛在問題,比如:
std::string?str?=?"abc";
str.push_back('');
str.append("abc");
char?buf[32];
sprintf(buf,?"str=%s",?str.c_str());
由于 str 中出現(xiàn)了''
,如果用%s
格式符來匹配的話,會(huì)在 0 的位置截?cái)?,也就是說buf
其實(shí)只獲取到了str
中的第一個(gè) abc,第二個(gè) abc 就被丟失了。
其他語言中的格式符
而一些其他語言則是擴(kuò)展了格式符功能用于解決上述問題,例如 OC 引入了%@
格式符,用于調(diào)用對象的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
",?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)了一些問題。
1. 無法前置聲明
枚舉類型無法先聲明后定義,例如下面這段代碼會(huì)編譯報(bào)錯(cuò):
enum?Season;
struct?Data?{
??Season?se;?//?ERR
};
enum?Season?{
??Spring,
??Summer,
??Autumn,
??Winter
};
主要是因?yàn)?code style="padding:2px 4px;margin-right:2px;margin-left:2px;color:rgb(30,107,184);background-color:rgba(27,31,35,.05);font-family:'Microsoft YaHei';">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ǔ)類型的話,那你也沒轍了,就是不能前置聲明。
2. 無法確認(rèn)枚舉值的范圍
也就是說,我沒有辦法判斷某個(gè)值是不是合法的枚舉值:
enum?Season?{
??Spring,
??Summer,
??Autumn,
??Winter
};
void?Demo()?{
??Season?s?=?static_cast(5);?//?不會(huì)報(bào)錯(cuò)
}
3. 枚舉值可以相同
enum?Test?{
??Ele1?=?10,
??Ele2,
??Ele3?=?10
};
void?Demo()?{
??bool?judge?=?(Ele1?==?Ele3);?//?true
}
4. C 風(fēng)格的枚舉還存在“成員名稱全局有效”和“可以隱式轉(zhuǎn)換為整型”的缺陷
但因?yàn)?C++提供了enum class
風(fēng)格的枚舉類型,解決了這兩個(gè)問題,因此這里不再額外討論。
宏
宏這個(gè)東西,完全就是針對編譯器友好的,編譯器非常方便地在宏的指導(dǎo)下,替換源代碼中的內(nèi)容。但這個(gè)玩意對程序員(尤其是閱讀代碼的人)來說是極其不友好的,由于是預(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)樗堋昂糜谩?,所以非常容易被濫用,下面列舉了一些宏濫用的情況供參考:
1. 用宏來定義類成員
#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),對閱讀者非常不友好,與此同時(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),但咱是不是可以考慮一下更好的方式?(比如用模板)
2. 用宏來屏蔽參數(shù)
#define?strcpy_s(dst,?dst_buf_size,?src)?strcpy(dst,?src)
這位同學(xué),咱要是真想寫一個(gè)安全版本的函數(shù),咱就好好去判斷 dst_buf_size 如何?
3. 用宏來拼接函數(shù)處理
#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>
4. 用宏來改變語法風(fēng)格
#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?<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é)議版本號 |
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()?<
因此,這里的需求就是“用兩種方式來訪問同一份數(shù)據(jù)”,才是完全符合共合體定義的用法。
共合體應(yīng)該是 C 語言的特色了,其他任何高級語言都沒有類似的語法,主要還是因?yàn)?C 語言更加面相底層,C++僅僅是繼承了 C 的語法而已。
const 引用
先說說 const
先來吐槽一件事,就是 C/C++中const
這個(gè)關(guān)鍵字,這個(gè)名字起的非常非常不好!為什么這樣說呢?const 是 constant 的縮寫,翻譯成中文就是“常量”,但其實(shí)在 C/C++中,const
并不是表示“常量”的意思。
我們先來明確一件事,什么是“常量”,什么是“變量”?常量其實(shí)就是衡量,比如說1
就是常量,它永遠(yuǎn)都是這個(gè)值。再比如'A'
就是個(gè)常量,同樣,它永遠(yuǎn)都是和它 ASCII 碼對應(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ì)下降(盡管可以通過相對內(nèi)存的方式,但程序員仍需要管理偏移地址),所以在略高級一點(diǎn)的語言中,都會(huì)讓程序員有個(gè)更方便的工具來管理內(nèi)存,最簡單的方法就是給內(nèi)存地址起個(gè)名字,然后編譯器來負(fù)責(zé)翻譯成相對地址。
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ǔ)”或者“地址”的概念。而對于變量而言,“只讀變量”也只能做右值,原因很簡單,因?yàn)樗恰爸蛔x”的。
雖然常量和只讀變量是不同的含義,但它們都是用來“讀取值”的,也就是用來做右值的,所以,C++引入了“const 引用”的概念來統(tǒng)一這兩點(diǎn)。所謂 const 引用包含了 2 個(gè)方面的含義:
- 作為只讀變量的引用(指針的語法糖)
- 作為只讀變量
換言之,const 引用可能是引用,也可能只是個(gè)普通變量,如何理解呢?請看例程:
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?<//?等價(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ì)令人有疑惑甚至對人有誤導(dǎo)。
在后面“右值引用”和“因式構(gòu)造”的章節(jié)會(huì)繼續(xù)詳細(xì)介紹它們和 const 引用的聯(lián)動(dòng),以及可能出現(xiàn)的問題。
右值引用與移動(dòng)語義
C++11 的右值引用語法的引入,其實(shí)也完全是針對于底層實(shí)現(xiàn)的,而不是針對于上層的語義友好。換句話說,右值引用是為了優(yōu)化性能的,而并不是讓程序變得更易讀的。
右值引用
右值引用跟 const 引用類似,仍然是同一語法不同意義,并且右值引用的定義強(qiáng)依賴“右值”的定義。根據(jù)上一節(jié)對“左右值”的定義,我們知道,左右值來源于賦值語句,常量只能做右值,而變量做右值時(shí)僅會(huì)讀取,不會(huì)修改。按照這個(gè)定義來理解,“右值引用”就是對“右值”的引用了,而右值可能是常量,也可能是變量,那么右值引用自然也是分兩種情況來不同處理:
- 右值引用綁定一個(gè)常量
- 右值引用綁定一個(gè)變量
我們先來看右值引用綁定常量的情況:
int?&&r1?=?5;?//?右值引用綁定常量
和 const 引用一樣,常量沒有地址,沒有存儲(chǔ)位置,只有值,因此,要把這個(gè)值保存下來的話,同樣得按照“新定義變量”的形式,因此,當(dāng)右值引用綁定常量時(shí),相當(dāng)于定義了一個(gè)普通變量:
int?&&r1?=?5;
//?等價(jià)于
int?v1?=?5;?//?r1就是個(gè)普通的int變量而已,并不是引用
所以這時(shí)的右值引用并不是誰的引用,而是一個(gè)普普通通的變量。
我們再來看看右值引用綁定變量的情況:這里的關(guān)鍵問題在于,什么樣的變量適合用右值引用綁定? 如果對于普通的變量,C++不允許用右值引用來綁定,但這是為什么呢?
int?a?=?3;
int?&&r?=?a;?//?ERR,為什么不允許右值引用綁定普通變量?
我們按照上面對左右值的分析,當(dāng)一個(gè)變量做右值時(shí),該變量只讀,不會(huì)被修改,那么,“引用”這個(gè)變量自然是想讓引用成為這個(gè)變量的替身,而如果我們希望這里做的事情是“當(dāng)通過這個(gè)引用來操作實(shí)體的時(shí)候,實(shí)體不能被改變”的話,使用 const 引用就已經(jīng)可以達(dá)成目的了,沒必要引入一個(gè)新的語法。
所以,右值引用并不是為了讓引用的對象只能做右值(這其實(shí)是 const 引用做的事情),相反,右值引用本身是可以做左值的。這就是右值引用最迷惑人的地方,也是筆者認(rèn)為“右值引用”這個(gè)名字取得迷惑人的地方。
右值引用到底是想解決什么問題呢?請看下面示例:
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è)對象
}
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ū)域釋放掉。
那么我們再回來看這段代碼:
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í)空間中的對象視為“臨時(shí)對象”的話,正常情況下,當(dāng)函數(shù)調(diào)用結(jié)束以后,臨時(shí)對象就會(huì)被釋放,所以我們管這個(gè)短命的對象叫做“將亡對象”,簡單粗暴理解為“馬上就要掛了的對象”,它的使命就是讓外部的t1
復(fù)制一下,然后它就死了,所以這時(shí)候你對他做什么操作都是沒意義的,他就是讓人來復(fù)制的,自然就是個(gè)只讀的值了,所以才被歸結(jié)為“右值”。我們?yōu)榱俗屗灰滥敲纯?,而給它延長了生命周期,因此使用了右值引用。所以,右值引用是不是應(yīng)該叫“續(xù)命引用”更加合適呢~
當(dāng)用右值引用捕獲一個(gè)將亡對象的時(shí)候,對象的生命周期從“將亡”變成了“與右值引用共存亡”,這就是右值引用的根本意義,這時(shí)的右值引用就是“將亡對象的引用”,又因?yàn)檫@時(shí)的將亡對象已經(jīng)不再“將亡”了,那它既然不再“將亡”,我們再對它進(jìn)行操作(改變成員的值)自然就是有意義的啦,所以,這里的右值引用其實(shí)就等價(jià)于一個(gè)普通的引用而已。既然就是個(gè)普通的引用,而且沒用 const 修飾,自然,可以做左值咯。右值引用做左值的時(shí)候,其實(shí)就是它所指對象做左值而已。不過又因?yàn)槠胀ㄒ貌⒉粫?huì)影響原本對象的生命周期,但右值引用會(huì),因此,右值引用更像是一個(gè)普通的變量,但我們要知道,它本質(zhì)上還是引用(底層是指針實(shí)現(xiàn)的)。
總結(jié)來說就是,右值引用綁定常量時(shí)相當(dāng)于“給一個(gè)常量提供了生命周期”,這時(shí)的“右值引用”并不是誰的引用,而是相當(dāng)于一個(gè)普通變量;而右值引用綁定將亡對象時(shí),相當(dāng)于“給將亡對象延長了生命周期”,這時(shí)的“右值引用”并不是“右值的引用”,而是“對需要續(xù)命的對象”的引用,生命周期變?yōu)榱擞抑狄帽旧淼纳芷冢ɑ蛘呃斫鉃椤敖庸堋绷诉@個(gè)引用的對象,成為了一個(gè)普通的變量)。
const 引用綁定將亡對象
需要知道的是,const 引用也是可以綁定將亡對象的,正如上文所說,既然將亡對象定義為了“右值”,也就是只讀不可變的,那么自然就符合 const 引用的語義。
//?省略Test的定義,見上節(jié)
void?Demo()?{
??const?Test?&t1?=?GetAnObj();?//?OK
}
這樣看來,const 引用同樣可以讓將亡對象延長生命周期,但其實(shí)這里的出發(fā)點(diǎn)并不同,const 引用更傾向于“引用一個(gè)不可變的量”,既然這里的將亡對象是一個(gè)“不可變的值”,那么,我就可以用 const 引用來保存“這個(gè)值”,或者這里的“值”也可以理解為這個(gè)對象的“快照”。所以,當(dāng)一個(gè) const 引用綁定一個(gè)將亡值時(shí),const 引用相當(dāng)于這個(gè)對象的“快照”,但背后還是間接地延長了它的生命周期,但只不過是不可變的。
移動(dòng)語義
在解釋移動(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;?//?對buf做一些操作
}
void?Demo()?{
??ProcessBuf(Buffer{16});?//?創(chuàng)建一個(gè)16個(gè)int的buffer
}
上面這段代碼定義了一個(gè)非常簡單的緩沖區(qū)處理類,ProcessBuf
函數(shù)想做的事是傳進(jìn)來一個(gè) buffer,然后對這個(gè) buffer 做一些修改的操作,最后可能把這個(gè) buffer 輸出出去之類的(代碼中沒有體現(xiàn),但是一般業(yè)務(wù)肯定會(huì)有)。
如果像上面這樣寫,會(huì)出現(xiàn)什么問題?不難發(fā)現(xiàn)在于ProcessBuf
的參數(shù),這里會(huì)發(fā)生復(fù)制。由于我們在Buffer
類中定義了拷貝構(gòu)造函數(shù)來實(shí)現(xiàn)深復(fù)制,那么任何傳入的 buffer 都會(huì)在這里進(jìn)行一次拷貝構(gòu)造(深復(fù)制)。再觀察Demo
中調(diào)用,僅僅是傳了一個(gè)臨時(shí)對象而已。臨時(shí)對象本身也是將亡對象,復(fù)制給buf
后,就會(huì)被釋放,也就是說,我們進(jìn)行了一次無意義的深復(fù)制。有人可能會(huì)說,那這里參數(shù)用引用能不能解決問題?比如這樣:
void?ProcessBuf(Buffer?&buf)?{
??buf.at(2)?=?100;
}
void?Demo()?{
??ProcessBuf(Buffer{16});?//?ERR,普通引用不可接收將亡對象
}
所以這里需要我們注意的是,C++當(dāng)中,并不只有在顯式調(diào)用=
的時(shí)候才會(huì)賦值,在函數(shù)傳參的時(shí)候仍然由賦值語義(也就是實(shí)參賦值給形參)。所以上面就相當(dāng)于:
Buffer?&buf?=?Buffer{16};?//?ERR
所以自然不合法。那,用 const 引用可以嗎?由于 const 引用可以接收將亡對象,那自然可以用于傳參,但ProcessBuf
函數(shù)中卻對對象進(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è)對象仍然是放在內(nèi)存中的,但 const 引用并不希望它發(fā)生改變(否則就不叫快照了),因此,這里最合適的,仍然是右值引用:
void?ProcessBuf(Buffer?&&buf)?{
??buf.at(2)?=?100;?//?右值引用完成綁定后,相當(dāng)于普通引用,所以這里操作OK
}
void?Demo()?{
??ProcessBuf(Buffer{16});?//?用右值引用綁定將亡對象,OK
}
我們再來看下面的場景:
void?Demo()?{
??Buffer?buf1{16};
??//?對buf進(jìn)行一些操作
??buf1.at(2)?=?50;
??//?再把buf傳給ProcessBuf
??ProcessBuf(buf1);?//?ERR,相當(dāng)于Buffer?&&buf=?buf1;右值引用綁定非將亡對象
}
因?yàn)橛抑狄檬且獊斫壎▽⑼鰧ο蟮模@里的buf1
是Demo
函數(shù)的局部變量,并不是將亡的,所以右值引用不能接受。但如果我有這樣的需求,就是說buf1
我不打算用了,我想把它的控制權(quán)交給ProcessBuf
函數(shù)中的buf
,相當(dāng)于,我主動(dòng)讓buf1
提前“亡”,是否可以強(qiáng)制把它弄成將亡對象呢?STL 提供了std::move
函數(shù)來完成這件事,“期望強(qiáng)制把一個(gè)對象變成將亡對象”:
void?Demo()?{
??Buffer?buf1{16};
??//?對buf進(jìn)行一些操作
??buf1.at(2)?=?50;
??//?再把buf傳給ProcessBuf
??ProcessBuf(std::move(buf1));?//?OK,強(qiáng)制讓buf1將亡,那么右值引用就可以接收
}?//?但如果讀者嘗試的話,在這里會(huì)出ERROR
std::move
的本意是提前讓一個(gè)對象“將亡”,然后把控制權(quán)“移交”給右值引用,所以才叫「move」,也就是“移動(dòng)語義”。但很可惜,C++并不能真正讓一個(gè)對象提前“亡”,所以這里的“移動(dòng)”僅僅是“語義”上的,并不是實(shí)際的。如果我們看一下std::move
的實(shí)現(xiàn)就知道了:
template?<typename?T>
constexpr?std::remove_reference_t?&&move(T?&&ref)?noexcept?{
??return?static_cast<std::remove_reference_t?&&>(ref);
}
如果這里參數(shù)中的&&
符號讓你懵了的話,可以參考后面“引用折疊”的內(nèi)容,如果對其他亂七八糟的語法還是沒整明白的話,沒關(guān)系,我來簡化一下:
template?<typename?T>
T?&&move(T?&ref)?{
??return?static_cast(ref);
}
哈?就這么簡單?是的!真的就這么簡單,這個(gè)std::move
不是什么多高大上的處理,就是簡單把普通引用給強(qiáng)制轉(zhuǎn)換成了右值引用,就這么簡單。
所以,我上線才說“C++并不能真正讓一個(gè)對象提前亡”,這里的std::move
就是跟編譯器玩了一個(gè)文字游戲罷了。
所以,C++的移動(dòng)語義僅僅是在語義上,在使用時(shí)必須要注意,一旦將一個(gè)對象 move 給了一個(gè)右值引用,那么不可以再操作原本的對象,但這種約束是一種軟約束,操作了也并不會(huì)有報(bào)錯(cuò),但是就可能會(huì)出現(xiàn)奇怪的問題。
移動(dòng)構(gòu)造、移動(dòng)賦值
有了右值引用和移動(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è)將亡對象來構(gòu)造/賦值另一個(gè)對象的情況,引入了移動(dòng)構(gòu)造和移動(dòng)賦值函數(shù),既然是用一個(gè)將亡對象,那么參數(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_;
};
這里主要考慮的問題是,既然是用將亡對象來構(gòu)造新對象,那么我們應(yīng)當(dāng)盡可能多得利用將亡對象的“遺體”,在將亡對象中有一個(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í)候,我們想象中是“把舊對象里的東西 移動(dòng) 到新對象中”,但其實(shí)沒法做到這種移動(dòng),只能是“把舊對象引用的東西轉(zhuǎn)為新對象來引用”,本質(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)先。
&?+?&?->?&
&?+?&&?->?&
&&?+?&?->?&
&&?+?&&?->?&&
auto &&
這種規(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)先級高,因此auto &
一定推出左值引用,如果用auto &
綁定常量或?qū)⑼鰧ο髣t會(huì)報(bào)錯(cuò):
auto?&r1?=?5;?//?ERR,左值引用不能綁定常量
auto?&r2?=?GetAnObj();?//?ERR,左值引用不能綁定將亡對象
int?&&b?=?1;
auto?&r3?=?b;?//?OK,左值引用可以綁定右值引用(因?yàn)橛抑狄靡坏┙壎ê?,相?dāng)于左值)
auto?&r4?=?r3;?//?OK,左值引用可以綁定左值引用(相當(dāng)于綁定r4的引用源)
右值引用傳遞時(shí)失去右性
前面的章節(jié)筆者頻繁強(qiáng)調(diào)一個(gè)概念:右值引用一旦綁定,則相當(dāng)于普通的引用(左值)。
這也就意味著,“右值”性質(zhì)無法傳遞,請看例子:
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(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(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í)能保持其左右性。
auto 推導(dǎo)策略
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 是用來代替類型說明符的”,因此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,:=符號表示了“定義動(dòng)作”語義
const?c?=?7?//?const表示“定義只讀變量”動(dòng)作,自動(dòng)推導(dǎo)c類型為int
var?d?float32?=?9?//?顯式指定類型
auto 引用
在前面“引用折疊”的章節(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é)。
C 語言曾經(jīng)的 auto
我相信大家現(xiàn)在看到auto
都第一印象是 C++當(dāng)中的“自動(dòng)類型推導(dǎo)”,但其實(shí)auto
并不是 C++11 引入的新關(guān)鍵在,在原始 C 語言中就有這一關(guān)鍵字的。
在原始 C 中,auto
表示“自動(dòng)變量位置”,與之對應(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,對寄存器變量取址,會(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ǔ)位置由編譯器決定。
static
筆者在前面章節(jié)吐槽了const
這個(gè)命名,也吐槽了“右值引用”這個(gè)命名。那么static
就是筆者下一個(gè)要重點(diǎn)吐槽的命名了。static
這個(gè)詞本身沒有什么問題,其主要的槽點(diǎn)就在于“一詞多用”,也就是說,這個(gè)詞在不同場景下表示的是完全不同的含義。(作者可能是出于節(jié)省關(guān)鍵詞的目的吧,明明是不同的含義,卻沒有用不同的關(guān)鍵詞)。
-
在局部變量前的
static
,限定的是變量的生命周期 -
在全局變量/函數(shù)前的
static
,限定的變量/函數(shù)的作用域 -
在成員變量前的
static
,限定的是成員變量的生命周期 -
在成員函數(shù)前的
static
,限定的是成員函數(shù)的調(diào)用方(或隱藏參數(shù))
上面是static
關(guān)鍵字的 4 種不同含義,接下來逐一我會(huì)解釋。
靜態(tài)局部變量
當(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?<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)在注釋中,這里不再贅述。
內(nèi)部全局變量/函數(shù)
當(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ì)再對生命周期有影響,而是限定了其作用域。與之對應(yīng)的是extern
。用extern
修飾的全局變量/函數(shù)作用于整個(gè)程序內(nèi),換句話說,就是可以跨文件:
//?a1.cc
int?g_val?=?4;?//?定義全局變量
//?a2.cc
extern?int?g_val;?//?聲明全局變量
void?Demo()?{
??std::cout?<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?<std::endl;?//?輸出6,與a1.cc中的s_val1沒有關(guān)系
??std::cout?<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)成員變量
靜態(tài)成員變量指的是用static
修飾的成員變量。普通的成員變量其生命周期是跟其所屬對象綁定的。構(gòu)造對象時(shí)構(gòu)造成員變量,析構(gòu)對象時(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所指對象析構(gòu),同時(shí)釋放t2->a
}?//?t析構(gòu),同時(shí)釋放t.a
而用static
修飾后,其聲明周期變?yōu)槿?,也就是“主函?shù)執(zhí)行前構(gòu)造,主函數(shù)執(zhí)行結(jié)束后釋放”,并且不再跟隨對象,而是全局一份。
struct?Test?{
??static?int?a;?//?靜態(tài)成員變量(基本等同于聲明全局變量)
};
int?Test::a?=?5;?//?初始化靜態(tài)成員變量(主函數(shù)前執(zhí)行,基本等同于初始化全局變量)
int?main(int?argc,?const?char?*argv[])?{
??std::cout?<std::endl;?//?直接訪問靜態(tài)成員變量
??Test?t;
??std::cout?<std::endl;?//?通過任意對象實(shí)例訪問靜態(tài)成員變量
}?//?主函數(shù)結(jié)束時(shí)釋放Test::a
所以靜態(tài)成員變量基本就相當(dāng)于一個(gè)全局變量,而這時(shí)的類更像一個(gè)命名空間了。唯一的區(qū)別在于,通過類的實(shí)例(對象)也可以訪問到這個(gè)靜態(tài)成員變量,就像上面的t.a
和Test::a
完全等價(jià)。
靜態(tài)成員函數(shù)
static
關(guān)鍵字修飾在成員函數(shù)前面,稱為“靜態(tài)成員函數(shù)”。我們知道普通的成員函數(shù)要以對象為主調(diào)方,對象本身其實(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();?//?用對象主調(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í)對象就是函數(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ù)表示“不需要對象作為主調(diào)方”,也就是說沒有那個(gè)隱藏的this
參數(shù)。
struct?Test?{
??int?a;
??static?void?f();?//?靜態(tài)成員函數(shù)
};
void?Test::f()?{
??//?沒有this,沒有對象,只能做對象無關(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?<std::endl;
}
或者我們可以把靜態(tài)成員函數(shù)理解為一個(gè)友元函數(shù),只不過從設(shè)計(jì)角度上來說,與這個(gè)類型的關(guān)聯(lián)度應(yīng)該是更高的。但是從語法層面來解釋,基本相當(dāng)于“寫在類里的普通函數(shù)”。
小結(jié)
其實(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++語言,防止踩坑。
平凡、標(biāo)準(zhǔn)布局
前陣子我和一個(gè)同事對這樣一個(gè)問題進(jìn)行了非常激烈的討論:
到底應(yīng)不應(yīng)該定義 std::string 類型的全局變量
這個(gè)問題乍一看好像沒什么值得討論的地方,我相信很多程序員都在不經(jīng)意間寫過類似的代碼,并且確實(shí)沒有發(fā)現(xiàn)什么執(zhí)行上的問題,所以可能從來沒有意識到,這件事還有可能出什么問題。
我們和我同事之所以激烈討論這個(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);
}
- 靜態(tài)映射表
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)變量”的情況。
全局、靜態(tài)的生命周期問題
既然谷歌規(guī)范中禁止這種情況,那一定意味著,這種寫法存在潛在風(fēng)險(xiǎn),我們需要搞明白風(fēng)險(xiǎn)點(diǎn)在哪里。首先明確變量生命周期的問題:
- 全局變量和靜態(tài)成員變量在主函數(shù)執(zhí)行前構(gòu)造,在主函數(shù)執(zhí)行結(jié)束后釋放;
- 靜態(tài)局部變量在第一次執(zhí)行到定義位置時(shí)構(gòu)造,在主函數(shù)執(zhí)行后釋放。
這件事如果在 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ù)才對。但這件事放到 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 style="padding:2px 4px;margin-right:2px;margin-left:2px;color:rgb(30,107,184);background-color:rgba(27,31,35,.05);font-family:'Microsoft YaHei';">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í),對test1_
訪問count
成員這一步,就會(huì)訪問野指針。因?yàn)?code style="padding:2px 4px;margin-right:2px;margin-left:2px;color:rgb(30,107,184);background-color:rgba(27,31,35,.05);font-family:'Microsoft YaHei';">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)中的規(guī)定
回到我們剛才提到的谷歌標(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)”指的是:
- 擁有默認(rèn)無參構(gòu)造函數(shù)
- 擁有默認(rèn)析構(gòu)函數(shù)
- 擁有默認(rèn)拷貝構(gòu)造函數(shù)
- 擁有默認(rèn)移動(dòng)構(gòu)造函數(shù)
- 擁有默認(rèn)拷貝賦值函數(shù)
- 擁有默認(rèn)移動(dòng)賦值函數(shù)
換句話說,六大特殊函數(shù)都是默認(rèn)的。這里要區(qū)分 2 個(gè)概念,我們要的是“語法上的平凡”還是“實(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
");}?//?自定義析構(gòu)函數(shù),但實(shí)際內(nèi)容只有打印
};
上面的例子中,Test1
就是個(gè)真正意義上的平凡類型,語法上是平凡的,因此編譯器也會(huì)認(rèn)為其是平凡的。我們可以用 STL 中的工具來判斷一個(gè)類型是否是平凡的:
bool?is_test1_tri?=?std::is_trivial_v;?//?true
但這里的 Test2,由于我們自定義了其無參構(gòu)造函數(shù)和析構(gòu)函數(shù),那么對編譯器來說,它就是非平凡的,我們用std::is_trivial
來判斷也會(huì)得到false_value
。但其實(shí)內(nèi)部并沒有什么外鏈操作,所以其實(shí)我們把Test2
類型定義全局變量時(shí)也不會(huì)出現(xiàn)任何問題,這就是所謂“實(shí)際意義上的平凡”。
C++對“平凡”的定義比較嚴(yán)格,但實(shí)際上我們看看如果要做全局變量或靜態(tài)變量的時(shí)候,是不需要這樣嚴(yán)格定義的。對于全局變量來說,只要定義全局變量時(shí),使用的是“實(shí)際意義上平凡”的構(gòu)造函數(shù),并且擁有“實(shí)際意義上平凡”的析構(gòu)函數(shù),那這個(gè)全局變量定義就不會(huì)有任何問題。而對于靜態(tài)局部變量來說,只要擁有“實(shí)際意義上平凡”的析構(gòu)函數(shù)的就一定不會(huì)出問題。
標(biāo)準(zhǔn)內(nèi)存布局
標(biāo)準(zhǔn)內(nèi)存布局的定義是:
-
所有成員擁有相同的權(quán)限(比如說都
public
,或都protected
,或都private
); - 不含虛基類、虛函數(shù);
- 如果含有基類,基類必須都是標(biāo)準(zhǔn)內(nèi)存布局;
- 如果函數(shù)成員變量,成員的類型也必須是標(biāo)準(zhǔn)內(nèi)存布局。
我們同樣可以用 STL 中的std::is_standard_layout
來判斷一個(gè)類型是否是標(biāo)準(zhǔn)內(nèi)存布局的。這里的定義比較簡單,不在贅述。
POD(Plain Old Data)類型
所謂 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)造。原因正是在此,對于非 POD 類型的對象,其中可能會(huì)包含虛表、虛函數(shù)指針等數(shù)據(jù),復(fù)制時(shí)這些內(nèi)容可能會(huì)重置,并且內(nèi)部可能會(huì)含有一些類似于“計(jì)數(shù)”這樣操作其他引用對象的行為,因?yàn)橐欢ㄒ每截悩?gòu)造函數(shù)來保證這些行為是正常的,而不能簡單粗暴地用二進(jìn)制方式進(jìn)行拷貝。
STL 中可以用std::is_pod
來判斷是個(gè)類型是否是 POD 的。
小結(jié)
我們再回到谷歌規(guī)范中,POD 的限制比較多,因此,確實(shí) POD 類型的全局/靜態(tài)變量是肯定不會(huì)出問題的,但直接將非 POD 類型的一棍子打死,筆者個(gè)人認(rèn)為有點(diǎn)過了,沒必要。
所以,筆者認(rèn)為更加精確的限定應(yīng)該是:對于全局變量、靜態(tài)成員變量來說,初始化時(shí)必須調(diào)用的是平凡的構(gòu)造函數(shù),并且其應(yīng)當(dāng)擁有平凡的析構(gòu)函數(shù),而且這里的“平凡”是指實(shí)際意義上的平凡,也就是說可以自定義,但是在內(nèi)部沒有對任何其他的對象進(jìn)行操作;對于靜態(tài)局部變量來說,其應(yīng)當(dāng)擁有平凡的析構(gòu)函數(shù),同樣指的是實(shí)際意義上的平凡,也就是它的析構(gòu)函數(shù)中沒有對任何其他的對象進(jìn)行操作。
最后舉幾個(gè)例子:
class?Test1?{
?public:
??Test1(int?a):?m_(a)?{}
??void?show()?const?{std::printf("%d
",?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 style="padding:2px 4px;margin-right:2px;margin-left:2px;color:rgb(30,107,184);background-color:rgba(27,31,35,.05);font-family:'Microsoft YaHei';">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ù)對Test1
有引用,并且析構(gòu)函數(shù)中調(diào)用了Test1::show
方法,因此Test3
類型不能用來定義局部/靜態(tài)變量。
Test4
也是非平凡的,并且內(nèi)部存在同樣非平凡的Test1
類型成員,但是因?yàn)?code style="padding:2px 4px;margin-right:2px;margin-left:2px;color:rgb(30,107,184);background-color:rgba(27,31,35,.05);font-family:'Microsoft YaHei';">m1_不是引用或指針,一定會(huì)隨著Test4
類型的對象的構(gòu)造而構(gòu)造,析構(gòu)而析構(gòu),不存在順序依賴問題,因此Test4
可以用來定義全局/靜態(tài)變量。
所以全局 std::string 變量到底可以不可以?
最后回到這個(gè)問題上,筆者認(rèn)為定義一個(gè)全局的std::string
類型的變量并不會(huì)出現(xiàn)什么問題,在std::string
的內(nèi)部,數(shù)據(jù)空間是通過new
的方式申請的,并且一般情況下都不會(huì)被其他全局變量所引用,在std::string
對象析構(gòu)時(shí),對這片空間會(huì)進(jìn)行delete
,所以并不會(huì)出現(xiàn)析構(gòu)順序問題。
但是,如果你用的不是默認(rèn)的內(nèi)存分配器,而是自定義了內(nèi)存分配器的話,那確實(shí)要考慮構(gòu)造析構(gòu)順序的問題了,你要保證在對象構(gòu)造前,內(nèi)存分配器是存在的,并且內(nèi)存分配器的析構(gòu)要在所有對象之后。
當(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;
}
非平凡析構(gòu)類型的移動(dòng)語義
在討論完平凡類型后,我們發(fā)現(xiàn)平凡析構(gòu)其實(shí)是更加值得關(guān)注的場景。這里就引申出非平凡析構(gòu)的移動(dò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ò)。
這也就是說,對于非平凡析構(gòu)類型,其發(fā)生移動(dòng)語義后,應(yīng)當(dāng)放棄對原始空間的控制。
如果我們修改一下代碼,那么這種問題就不會(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è)里面考慮好。例如上面的把對應(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++是多范式語言
在講解私有繼承和多繼承之前,筆者要先澄清一件事:C++不是單純的面相對象的語言。同樣地,它也不是單純的面向過程的語言,也不是函數(shù)式語言,也不是接口型語言……
真的要說,C++是一個(gè)多范式語言,也就是說它并不是為了某種編程范式來創(chuàng)建的。C++的語法體系完整且龐大,很多范式都可以用 C++來展現(xiàn)。因此,不要試圖用任一一種語言范式來解釋 C++語法,不然你總能找到各種漏洞和奇怪的地方。
舉例來說,C++中的“繼承”指的是一種語法現(xiàn)象,而面向?qū)ο罄碚撝械摹袄^承”指的是一種類之間的關(guān)系。這二者是有本質(zhì)區(qū)別的,請讀者一定一定要區(qū)分清楚。
以面向?qū)ο鬄槔珻++當(dāng)然可以面向?qū)ο缶幊蹋∣OP),但由于 C++并不是專為 OOP 創(chuàng)建的語言,自然就有 OOP 理論解釋不了的語法現(xiàn)象。比如說多繼承,比如說私有繼承。
C++與 java 不同,java 是完全按照 OOP 理論來創(chuàng)建的,因此所謂“抽象類”,“接口(協(xié)議)類”的語義是明確可以和 OOP 對應(yīng)上的,并且,在 OOP 理論中,“繼承”關(guān)系應(yīng)當(dāng)是"A is a B"的關(guān)系,所以不會(huì)存在 A 既是 B 又是 C 的這種情況,自然也就不會(huì)出現(xiàn)“多繼承”這樣的語法。
但是在 C++中,考慮的是對象的布局,而不是 OOP 的理論,所以出現(xiàn)私有繼承、多繼承等這樣的語法也就不奇怪了。
筆者曾經(jīng)聽有人持有下面這樣類似的觀點(diǎn):
- 虛函數(shù)都應(yīng)該是純虛的
- 含有虛函數(shù)的類不應(yīng)當(dāng)支持實(shí)例化(創(chuàng)建對象)
- 能實(shí)例化的類不應(yīng)當(dāng)被繼承,有子類的類不應(yīng)當(dāng)被實(shí)例化
- 一個(gè)類至多有一個(gè)“屬性父類”,但可以有多個(gè)“協(xié)議父類”
等等這些觀點(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”,語法自然也是針對 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ì)針對于 OOP 理論來限制其語法。這一點(diǎn),希望讀者一定要明白。
私有繼承與 EBO
私有繼承本質(zhì)不是「繼承」
在此強(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)了,如果父類指針能夠指向子類對象,那么即可實(shí)現(xiàn)多態(tài)效應(yī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
對象可以用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對象呢?為什么要使用私有繼承?這是因?yàn)橛贸蓡T對象在某種情況下是有缺陷的。
空類大小
在解釋私有繼承的意義之前,我們先來看一個(gè)問題,請看下面例程
class?T?{};
//?sizeof(T)?=??
T
是一個(gè)空類,里面什么都沒有,那么這時(shí)T
的大小是多少?照理說,空類的大小就是應(yīng)該是0
,但如果真的設(shè)置為0
的話,會(huì)有很嚴(yán)重的副作用,請看例程:
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ì)針對于空類自動(dòng)補(bǔ)一個(gè)字節(jié)的大小,也就是說其實(shí)sizeof(T)
是 1,而不是 0。
這里需要注意的是,不僅是絕對的空類會(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è)問題,請看例程:
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é)對齊,因此Test
變成了 16 字節(jié)。如果Test
中出現(xiàn)了很多空類成員,這種問題就會(huì)被繼續(xù)放大。
這就是用成員對象來表示組合關(guān)系時(shí),可能會(huì)出現(xiàn)的問題,而私有繼承就是為了解決這個(gè)問題的。
空基類成員壓縮(EBO,Empty Base Class Optimization)
在上一節(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類對象
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++并不是完全針對 OOP 的,因此支持真正意義上的多繼承,也就是說,即便父類不是這種純虛類,也同樣支持集成,從語義上來說,類似于“交叉分類”。請看示例:
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)存布局。
對于一個(gè)普通的類來說,內(nèi)存布局就是按照成員的聲明順序來布局的,與 C 語言中結(jié)構(gòu)體布局相同,例如:
class?Test1?{
?public:
??char?a;
??int?b;
??short?c;
};
那么Test1
的內(nèi)存布局就是
字節(jié)編號 | 內(nèi)容 |
---|---|
0 | a |
1~3 | 內(nèi)存對齊保留字節(jié) |
4~7 | b |
8~9 | c |
9~11 | 內(nèi)存對齊保留字節(jié) |
但如果類中含有虛函數(shù),那么還會(huì)在末尾添加虛函數(shù)表的指針,例如:
class?Test1?{
?public:
??char?a;
??int?b;
??short?c;
??virtual?void?f()?{}
};
字節(jié)編號 | 內(nèi)容 |
---|---|
0 | a |
1~3 | 內(nèi)存對齊保留字節(jié) |
4~7 | b |
8~9 | c |
9~15 | 內(nèi)存對齊保留字節(jié) |
16~23 | 虛函數(shù)表指針 |
多繼承時(shí),第一父類的虛函數(shù)表會(huì)與本類合并,其他父類的虛函數(shù)表單獨(dú)存在,并排列在本類成員的后面。
菱形繼承與虛擬繼承
C++由于支持“普適意義上的多繼承”,那么就會(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;
};
根據(jù)內(nèi)存布局原則,D
類首先是B
類的元素,然后D
類自己的元素,最后是C
類元素:
字節(jié)序號 | 意義 |
---|---|
0~15 | B 類元素 |
16~19 | d1 |
20~23 | d2 |
24~31 | C 類元素 |
如果再展開,會(huì)變成這樣:
字節(jié)序號 | 意義 |
---|---|
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
的對象模型應(yīng)該是這樣的:
![5a3d7e40-3490-11ed-ba43-dac502259ad0.jpg](https://file1.elecfans.com//web2/M00/96/B9/wKgaomTnH_eAC3rkAADIoQQ9llQ102.jpg)
而如果使用虛擬繼承:
struct?A?{
??int?a1,?a2;
};
struct?B?:?virtual?A?{
??int?b1,?b2;
};
對象模型是這樣的:
![5a65a4c4-3490-11ed-ba43-dac502259ad0.jpg](https://file1.elecfans.com//web2/M00/96/B9/wKgaomTnH_eAG58kAADtkQoySW4295.jpg)
虛擬繼承的排布方式就類似于虛函數(shù)的排布,子類對象會(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
的對象模型:
![5ac5bcd8-3490-11ed-ba43-dac502259ad0.jpg](https://file1.elecfans.com//web2/M00/96/B9/wKgaomTnH_iAHzmgAAGW2ieKz7w125.jpg)
但如果使用虛擬繼承,則可以把每個(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
的對象模型將會(huì)變成:
![5ae42ba0-3490-11ed-ba43-dac502259ad0.jpg](https://file1.elecfans.com//web2/M00/96/B9/wKgaomTnH_iASQzwAAFeur_DhSQ036.jpg)
也就是說此時(shí),共有的虛基類只會(huì)保存一份,這樣就不會(huì)有二義性,同時(shí)也節(jié)省了空間。
但需要注意的是,D
繼承自B
和C
時(shí)是普通繼承,如果用了虛擬繼承,則會(huì)在 D 內(nèi)部又額外添加一份虛基表指針。要虛擬繼承的是B
和C
對A
的繼承,這也是虛擬繼承語法非常迷惑的地方,也就是說,菱形繼承的分支處要用虛擬繼承,而匯聚處要用普通繼承。所以我們還是要明白其底層原理,以及引入這個(gè)語法的原因(針對解決的問題),才能更好的使用這個(gè)語法,避免出錯(cuò)。
隱式構(gòu)造
隱式構(gòu)造指的就是隱式調(diào)用構(gòu)造函數(shù)。換句話說,我們不用寫出類型名,而是僅僅給出構(gòu)造參數(shù),編譯期就會(huì)自動(dòng)用它來構(gòu)造對象。舉例來說:
class?Test?{
?public:
??Test(int?a,?int?b)?{}
};
void?f(const?Test?&t)?{
}
void?Demo()?{
?f({1,?2});?//?隱式構(gòu)造Test臨時(shí)對象,相當(dāng)于f(Test{a,?b})
}
上面例子中,f
需要接受的是Test
類型的對象,然而我們在調(diào)用時(shí)僅僅使用了構(gòu)造參數(shù),并沒有指定類型,但編譯器會(huì)進(jìn)行隱式構(gòu)造。
尤其,當(dāng)構(gòu)造參數(shù)只有 1 個(gè)的時(shí)候,可以省略大括號:
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í)可以省略大括號
??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)先級大于隱式構(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);?//?意義不明確,不容易讓人意識到隱式構(gòu)造
}
上例中,System
表示一個(gè)系統(tǒng),其構(gòu)造參數(shù)是這個(gè)系統(tǒng)的版本號。那么這時(shí)用版本號的隱式構(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ù)
}
還有一種情況就是,對于變參的構(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)造),對于變參構(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ì)讓我們在使用這些基礎(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 對,并不會(huì)將“語法”和“類型”做任何強(qiáng)綁定。因此把隱式構(gòu)造和explicit
都提供出來,交給開發(fā)者自行處理是否支持。
這是我們需要體會(huì)的 C++設(shè)計(jì)理念,當(dāng)然,也可以算是 C++的缺陷。
C 風(fēng)格字符串
字符串同樣是 C++特別容易踩坑的位置。出于對 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ì)出問題。
類型推導(dǎo)問題
在進(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è)“對象”,所以要按照值類型來處理。但如果我們用的是字符串常量,則會(huì)識別為const char *
類型,直接匹配到了指針處理方式,而并不會(huì)觸發(fā)隱式構(gòu)造。
截?cái)鄦栴}
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{"123abc"};?//?0處會(huì)截?cái)?/span>
std::string?str2{"123abc",?7};?//?不會(huì)截?cái)?/span>
截?cái)鄦栴}在傳參時(shí)更加明顯,比如說:
void?f(const?char?*str)?{}
void?Demo()?{
??std::string?str2{"123abc",?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ì)被識別為字符串。
int8_t 和 uint8_t
原本int8_t
和uint8_t
是用來表示“8 位整數(shù)”的,但是不巧的是,他們的定義是:
using?int8_t?=?signed?char;
using?uint8_t?=?unsigned?char;
由于 C 語言歷史原因,ASCII 碼只有 7 位,所以“字符”類型有無符號是沒區(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?<std::endl;?//?但這里會(huì)打印出8,而不是56
原本uint8_t
是想屏蔽掉char
這層含義,讓它單純地表示 8 位整數(shù)的,但是在 STL 的解析中,卻又讓它有了“字符”的含義,去按照 ASCII 碼來解析了,讓uint8_t
的定義又失去了原本該有的含義,所以這里也是很容易踩坑的地方。
(這一點(diǎn)筆者真的沒想明白為什么,明明是不同類型,但為什么沒有區(qū)分開??赡芡瑯邮菤v史原因吧,總之這個(gè)點(diǎn)可以算得上真正意義上的“缺陷”了。)
new 和 delete
new
這個(gè)運(yùn)算符相信大家一定不陌生,即便是非 C++系其他語言一般都會(huì)保留new
這個(gè)關(guān)鍵字。而且這個(gè)已經(jīng)成為業(yè)界的一個(gè)哏了,比如說“沒有對象怎么辦?不怕,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;
運(yùn)算符本質(zhì)是函數(shù)調(diào)用
熟悉 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(operator?new(sizeof(T)))
delete
運(yùn)算符要作用于一個(gè)指針,編譯器會(huì)將這個(gè)指針作為參數(shù)傳給operator delete
,也就是說:
delete?ptr;
//?等價(jià)于
operator?delete(ptr);
重載 new 和 delete
之所以要引入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)的情況下,可以通過接收異常來判斷是否分配成功,而不用每次都對指針進(jìn)行判空。
構(gòu)造函數(shù)和 placement new
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è)對象應(yīng)該分 2 步:
- 分配內(nèi)存空間
- 調(diào)用構(gòu)造函數(shù)
同樣,釋放一個(gè)對象也應(yīng)該分 2 步:
- 調(diào)用析構(gòu)函數(shù)
- 釋放內(nèi)存空間
這個(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è)對象),我們首先需要一個(gè)分配好的內(nèi)存空間。因此,要拿著用于構(gòu)造的內(nèi)存空間,以構(gòu)造參數(shù),才能構(gòu)造一個(gè)對象(也就是調(diào)用構(gòu)造函數(shù))。C++管這種語法叫做就地構(gòu)造(placement new)。
String?*str?=?static_cast(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í)我們再回去看operator new
的一個(gè)重載,應(yīng)該就能猜到它是干什么的了:
void?*operator?new(size_t?size,?void?*ptr);
就是用于支持就地構(gòu)造的函數(shù)。要注意的是,如果是通過就地構(gòu)造方式構(gòu)造的對象,需要再回收內(nèi)存空間之前進(jìn)行析構(gòu)。以上面String
為例,如果不析構(gòu)直接回收,那么buf
所指的空間就不能得到釋放,從而造成內(nèi)存泄漏:
str->~String();?//?析構(gòu)
std::free(str);?//?釋放內(nèi)存空間
new = operator new + placement new
看到本節(jié)的標(biāo)題,相信讀者會(huì)恍然大悟。C++中new
運(yùn)算符同時(shí)承擔(dān)了“分配空間”和“構(gòu)造對象”的任務(wù)。上一節(jié)的例子中我們是通過malloc
和free
來管理的,自然,通過operator new
和operator delete
也是一樣的,而且它們還支持針對類型的重載。
因此,我們說,一次new
,相當(dāng)于先operator new
(分配空間)加placement new
(調(diào)用構(gòu)造函數(shù))。
String?*str?=?new?String("abc");
//?等價(jià)于
String?*str?=?static_cast(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)算符不一樣,除了對應(yīng)的operator
函數(shù)外,還有對構(gòu)造、析構(gòu)的處理。但也正是由于 C++總是進(jìn)行一些隱藏操作,才會(huì)復(fù)雜度激增,有時(shí)也會(huì)出現(xiàn)一些難以發(fā)現(xiàn)的問題,所以我們一定要弄清楚它的本質(zhì)。
new []和 delete []
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ì)依次對每個(gè)元素進(jìn)行構(gòu)造。也就是說:
String?*arr_str?=?new?String?[4]?{"abc",?"def",?"123"};
//?等價(jià)于
String?*arr_str?=?static_cast(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
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í)候我們在程序編寫的時(shí)候,會(huì)遇到一些編譯期就能確定的量,但不方便直接用常量表達(dá)的情況。最簡單的一個(gè)例子就是“魔鬼數(shù)字”:
using?err_t?=?int;
err_t?Process()?{
??//?某些錯(cuò)誤
??return?25;
??//?...
??return?0;
}
作為錯(cuò)誤碼的時(shí)候,我們只能知道業(yè)界約定0
表示成功,但其他的錯(cuò)誤碼就不知道什么含義了,比如這里的25
號錯(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;
}
(對于錯(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 style="padding:2px 4px;margin-right:2px;margin-left:2px;color:rgb(30,107,184);background-color:rgba(27,31,35,.05);font-family:'Microsoft YaHei';">constexpr修飾了,因此它會(huì)在編譯期間成為“常量”,而不會(huì)占用內(nèi)存空間。
用constexpr
修飾的表達(dá)式,會(huì)保留其原有的作用域和類型(例如上面的pi
就跟全局變量的作用域是一樣的),只是會(huì)變成編譯期常量。
constexpr 可以當(dāng)做常量使用
既然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?a2;?//?常量表達(dá)式OK
??Array?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í)按對應(yīng)位置替換為值,并不會(huì)真的分配空間)。
std::array
是 POD 類型,那么就跟普通的結(jié)構(gòu)體、數(shù)組一樣,所以都可以作為編譯期常量。
后面幾個(gè)指針需要重點(diǎn)解釋一下。用constexpr
修飾的除了可以是絕對的常量外,在編譯期能確定的量也可以視為常量。比如這里的fp
,由于函數(shù)f
的地址,在運(yùn)行期間是不會(huì)改變的,編譯期間盡管不能確定其絕對地址,但可以確定它的相對地址,那么作為函數(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
也是不對的,因?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)部行為
}
constexpr 表達(dá)式也可能變成變量
希望讀者看到這一節(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)橹挥凶兞坎拍苋≈?。上面例子中,由于?code style="padding:2px 4px;margin-right:2px;margin-left:2px;color:rgb(30,107,184);background-color:rgba(27,31,35,.05);font-family:'Microsoft YaHei';">a進(jìn)行了取地址操作,因此,a
不得不真正成為一個(gè)變量,也就是變?yōu)?code style="padding:2px 4px;margin-right:2px;margin-left:2px;color:rgb(30,107,184);background-color:rgba(27,31,35,.05);font-family:'Microsoft YaHei';">const int類型。
那另一個(gè)問題就出現(xiàn)了,如果說,我對一個(gè)常量表達(dá)式既取了地址,又用到編譯期語法中了怎么辦?
template?<int?N>
struct?Test?{};
void?Demo()?{
??constexpr?int?a?=?5;
??Test?t;?//?用做常量
??const?int?*p?=?&a;?//?用做變量
}
沒關(guān)系,編譯器會(huì)讓它在編譯期視為常量去給那些編譯期語法(比如模板實(shí)例化)使用,之后,再把它用作變量寫到內(nèi)存中。
換句話說,在編譯期,這里的a
相當(dāng)于一個(gè)宏,所有的編譯期語法會(huì)用5
替換a
,Test
就變成了
Test<5>
。之后,又會(huì)讓a
成為一個(gè)只讀變量寫到內(nèi)存中,也就變成了const int a = 5;
那么const int *p = &a;
自然就是合法的了。
就地構(gòu)造
“就地構(gòu)造”這個(gè)詞本身就很 C++。很多程序員都能發(fā)現(xiàn),到處糾結(jié)對象有沒有拷貝,糾結(jié)出參還是返回值的只有 C++程序員。
struct?Test?{
??int?a,?b;
};
void?Demo()?{
??std::vector?ve;
??ve.push_back(Test{1,?2});?//?用1,2構(gòu)造臨時(shí)對象,再移動(dòng)構(gòu)造
}
template?<typename?T>
void?vector::push_back(const?T?&);
template?<typename?T>
void?vector::push_back(T?&&);
既然,我們已經(jīng)有{1, 2}
的構(gòu)造參數(shù)了,能否想辦法跳過這一次臨時(shí)對象,而是直接在vector
末尾的空間上進(jìn)行構(gòu)造呢?這就涉及了就地構(gòu)造的問題。我們在前面“new 和 delete”的章節(jié)介紹過,“分配空間”和“構(gòu)造對象”的步驟可以拆解開來做。首先對vector
的buffer
進(jìn)行擴(kuò)容(如果需要的話),確定了要放置新對象的空間以后,直接使用placement new
進(jìn)行就地構(gòu)造。
template?<>
void?vector::emplace_back(int?a,?int?b)?{
??//?需要時(shí)擴(kuò)容
??//?new_ptr表示末尾為新對象分配的空間
??new(new_ptr)?Test{a,?b};
}
STL 中把容器的就地構(gòu)造方法叫做emplace
,原理就是通過傳遞構(gòu)造參數(shù),直接在對應(yīng)位置就地構(gòu)造。所以更加通用的方法應(yīng)該是:
template?<typename?T,?typename...?Args>
void?vector::emplace_back(Args?&&...args)?{
??//?new_ptr表示末尾為新對象分配的空間
??new(new_ptr)?T{std::forward(args)...};
}
嵌套就地構(gòu)造
就地構(gòu)造確實(shí)能在一定程度上解決多余的對象復(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)造對象時(shí)對參數(shù)再就地構(gòu)造。
struct?Test?{
??int?a,?b;
};
void?Demo()?{
??std::map<int,?Test>?ma;
??ma.emplace(1,?Test{1,?2});?//?這里emplace的對象是pair,>
}
不過好在,map
和unordered_map
提供了try_emplace
方法,可以在一定程度上解決這個(gè)問題,函數(shù)原型是:
template?<typename?K,?typename?V,?typename...?Args>
std::pairbool>?map::try_emplace(const?K?&key,?Args?&&...args);
,>,>
void?Demo()?{
??std::map<int,?Test>?ma;
??ma.try_emplace(1,?1,?2);?//?1,?2用于構(gòu)造Test
}
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?<",?"?<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");
}
void?Demo()?{
??std::map<int,?std::map<int,?std::string>>?ma;
??ma.try_emplace(1).first->second.try_emplace(1,?"abc");
}
當(dāng)然了,這么做確實(shí)可讀性會(huì)下降很多,具體使用時(shí)還需要自行取舍。
總結(jié)
曾經(jīng)有很多朋友問過我,C++適不適合入門?C++適不適合干活?我學(xué) C++跟我學(xué) java 哪個(gè)更賺錢?。?/a>
筆者持有這樣的觀點(diǎn):C++并不是最適合生產(chǎn)的語言,但 C++一定是最值得學(xué)習(xí)的語言。
審核編輯:湯梓紅,>,>,>
評論