首先,本章很長,也較難理解,建議讀者有大段連續(xù)的時間看這個。。。
3.3.1 指針成員與拷貝構(gòu)造
關(guān)于拷貝構(gòu)造函數(shù)的調(diào)用時間,可以看這篇文章。
如果類中包含了指針,需要小心處理,下面是一段有問題的代碼
class C {
public:
C():i(new int(0)){
cout << "none argument constructor called" << endl;
}
~C(){
cout << "destructor called" << endl;
delete i;
}
int* i;
};
int main(){
C c1;
C c2 = c1;
cout << *c1.i << endl;
cout << *c2.i << endl;
return 0;
}
XCode代碼執(zhí)行輸出
none argument constructor called
0
0
destructor called
destructor called
CppTest(50956,0x1000ad5c0) malloc: *** error for object 0x10070c510: pointer being freed was not allocated
CppTest(50956,0x1000ad5c0) malloc: *** set a breakpoint in malloc_error_break to debug
原因是編譯期會默認(rèn)為類創(chuàng)建拷貝構(gòu)造函數(shù),而默認(rèn)的拷貝構(gòu)造函數(shù)只是簡單的賦值,對類C,系統(tǒng)默認(rèn)生成的拷貝構(gòu)造函數(shù)如
C(const C& c):i(c.i){
}
導(dǎo)致c1和c2的i值一樣,即指向同一片地址,當(dāng)c1析構(gòu)之后,c2.i就成為了一個“懸掛指針”(dangling pointer),不再指向有效的內(nèi)存了,如果對懸掛指針再次進(jìn)行delete就會出現(xiàn)嚴(yán)重的錯誤。
以上系統(tǒng)生成的默認(rèn)拷貝構(gòu)造函數(shù)做的是淺拷貝(shallow copy),為了解決這個問題,通常是用戶自定義拷貝構(gòu)造函數(shù)實現(xiàn)深拷貝(deep copy),修正如下
class C {
public:
C():i(new int(0)){
cout << "none argument constructor called" << endl;
}
//增加此拷貝構(gòu)造函數(shù),根據(jù)傳入的c,new一個新的int給i變量
C(const C& c) :i(new int(*c.i)){
}
~C(){
cout << "destructor called" << endl;
delete i;
}
int* i;
};
執(zhí)行代碼后如下
none argument constructor called
0
0
destructor called
destructor called
Program ended with exit code: 0
3.3.2 移動語義
拷貝函數(shù)中為指針成員分配新的內(nèi)存再進(jìn)行內(nèi)容拷貝的方法在C++中幾乎被視為不可違背的,不過有些時候卻是不必要的。如下代碼:
//這是一個成員包含指針的類
class HasPtrMem {
public:
HasPtrMem() : d(new int(0)) {
cout << "Construct:" << ++n_cstr << endl;
}
HasPtrMem(const HasPtrMem& h) {
cout << "Copy construct:" << ++n_cptr << endl;
}
~HasPtrMem() {
cout << "Destruct:" << ++n_dstr << endl;
}
private:
int* d;
static int n_cstr;
static int n_dstr;
static int n_cptr;
};
int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;
HasPtrMem GetTemp() {
return HasPtrMem();//①
}
int main(){
HasPtrMem m = GetTemp();//②
return 0;
}
這里我沒用Xcode編譯運行,因為在Build Setting里增加-fno-elide-constructors編譯器依然還是優(yōu)化了,所以根據(jù)教材用命令行執(zhí)行
g++ -std=c++11 main.cpp -fno-elide-constructors
會在cpp文件下生成一個a.out文件,在命令行執(zhí)行./a.out輸出
Construct:1
Copy construct:1
Destruct:1
Copy construct:2
Destruct:2
Destruct:3
構(gòu)造函數(shù)被調(diào)用1次,是在①處,第一次調(diào)用拷貝構(gòu)造函數(shù)是在GetTemp return的時候,將①生成的變量拷貝構(gòu)造出一個臨時值,來當(dāng)做GetTemp的返回,第二次拷貝構(gòu)造函數(shù)是在②處。同時就有了于此對應(yīng)的三次析構(gòu)函數(shù)的調(diào)用。例子里用的是一個int類型的指針,而如果該指針指向的是非常大的堆內(nèi)存數(shù)據(jù)的話,那沒拷貝過程就會非常耗時,而且由于整個行為是透明且正確的,分析問題時也不易察覺。
在C++中,我們可以通過移動構(gòu)造函數(shù)解決此問題,修改代碼如下:
//這是一個成員包含指針的類
class HasPtrMem {
public:
HasPtrMem() : d(new int(0)) {
cout << "Construct:" << ++n_cstr << endl;
}
HasPtrMem(const HasPtrMem& h) {
cout << "Copy construct:" << ++n_cptr << endl;
}
HasPtrMem(HasPtrMem&& h):d(h.d) {
h.d = nullptr; //③注意對之前的h賦空指針
cout << "Move construct:" << ++n_mvtr << endl;
}
~HasPtrMem() {
cout << "Destruct:" << ++n_dstr << endl;
}
private:
int* d;
static int n_cstr;
static int n_dstr;
static int n_cptr;
static int n_mvtr;
};
int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;
int HasPtrMem::n_mvtr = 0;
HasPtrMem GetTemp() {
return HasPtrMem();
}
int main(){
HasPtrMem m = GetTemp();
return 0;
}
輸出
Construct:1
Move construct:1
Destruct:1
Move construct:2
Destruct:2
Destruct:3
這里通過指針賦值的方式,將d的內(nèi)存直接偷了過來,避免了拷貝構(gòu)造函數(shù)的調(diào)用。注意③,這里需要對原來的d進(jìn)行賦空值,因為在移動構(gòu)造函數(shù)完成之后,臨時對象會立即被析構(gòu),如果不改變d,那臨時對象被析構(gòu)時,因為偷來的d和原本的d指向同一塊內(nèi)存,會被釋放,成為懸掛指針,會造成錯誤。
為什么不用函數(shù)參數(shù)里帶個指針或者引用當(dāng)返回結(jié)果呢?不是性能的問題,而是代碼編寫效率及可讀性不好,如:
string *a;
int c = 1
int &b = c;
Calculate(GetTemp(),b);//最后一個參數(shù)用于返回結(jié)果
最后說明一下移動構(gòu)造函數(shù)被調(diào)用的時機:一旦用到的是臨時變量,那么移動語義就可以得到執(zhí)行。下一節(jié)講下C++的值是如何分類的。未完待續(xù),后面還有4節(jié)。。。
3.3.3 左值、右值與右值引用
關(guān)于左值(lvalue)和右值(rvalue)的判別方法:
- 在賦值表達(dá)式中,出現(xiàn)在等號左邊的是“左值”,等號右邊的是“右值”,如
a = b + c;中,a是左值,而b+c是右值; - 可以取地址的、有名字的是左值,反之是右值,對于
a = b + c;,&a是允許的操作,&(b+c)是不允許的操作,所以a是左值,b+c是右值。
而在C++11中右值是由兩個概念構(gòu)成的,一個是將亡值(xvalue, eXpriring Value),另個一個則是純右值(prvalue, Pure Rvalue)。
其中純右值包括:
- 非引用返回的函數(shù)返回的臨時變量值
- 運算表達(dá)式,如1+3產(chǎn)生的臨時變量值
- 不跟對象關(guān)聯(lián)的字面量,如2、’c‘、true
- 類型轉(zhuǎn)換函數(shù)的返回值
- lamda表達(dá)式
將亡值賊是C++11新增的跟右值引用相關(guān)的表達(dá)式,包括:
- 返回右值引用T&&的函數(shù)返回值
- std::move的返回值
- 轉(zhuǎn)換為T&&的類型轉(zhuǎn)換函數(shù)的返回值
而剩余的,可以標(biāo)識函數(shù)、對象的值都屬于左值。在C++11的程序中,所有的值必屬于左值、將亡值、純右值三者之一。
在C++11中,右值引用就是對一個右值進(jìn)行引用的類型。由于右值不具有名字,我們也只能通過引用的方式找到它的存在。通常我們只能是從右值表達(dá)式獲得其引用。比如:
T&& a = ReturnRvalue();①
右值引用和左值引用都是引用類型,都必須立即進(jìn)行初始化。引用類型本身并不擁有綁定對象的內(nèi)存,只是該對象的一個別名。左值引用是具名變量值的別名,右值引用則是匿名變量的別名。
在上面①的例子中,ReturnRvalue函數(shù)返回的右值在表達(dá)式語句結(jié)束后,其生命也就終結(jié)了,而通過右值引用的聲明,該右值又“重獲新生”,其生命期將于右值引用類型a的生命期一樣。只要a還“活著”,該右值臨時量將會一直“存活”下去。
所以相比于一下語句:
T b = ReturnRvalue();
①的聲明方式會少一次對象的析構(gòu)和一次對象構(gòu)造。因為a是右值引用,直接綁定了ReturnRvalue()返回的臨時量,而b是由臨時值構(gòu)造的,而臨時量在表達(dá)式結(jié)束后會析構(gòu)因而會多一次析構(gòu)和構(gòu)造的開銷。
注意,能夠聲明右值引用a的前提是ReturnRvalue返回的是一個右值。通常右值引用是不能夠綁定到任何左值的,如下代碼會導(dǎo)致編譯無法通過:
int c;
int &&d = c;
有的時候,我們可能不知道一個類型是否是引用類型,以及是左值引用還是右值引用。標(biāo)準(zhǔn)庫<type_traits>頭文件中提供了3個類模板:is_rvalue_reference、is_lvalue_reference和is_reference,比如:
cout << is_rvalue_reference<string &&>::value;
3.3.4 std::move 強制轉(zhuǎn)化為右值
C++11中,<utility>中提供了函數(shù)std::move,功能是將一個左值強制轉(zhuǎn)化為右值引用,繼而我們可以通過右值引用使用該值,用于移動語義。std::move基本等同于一個類型轉(zhuǎn)換:
static_cast<T&&>(lvalue);
被轉(zhuǎn)化的左值,其生命期并沒有隨著左右值的轉(zhuǎn)化而改變。下面是一個正確使用std::move的例子
class HugeMem {
public:
HugeMem(int size): sz(size>0 ? size: 1) {
c = new int[size];
}
~HugeMem() {
delete [] c;
}
HugeMem(HugeMem&& h) : sz(h.sz), c(h.c) {
h.c = nullptr;
}
int* c;
int sz;
};
class Moveable {
public:
Moveable(): i(new int[3]), h(1024) {}
~Moveable() {
delete [] i;
}
Moveable(Moveable&& m) : i(m.i), h(move(m.h)) { //使用move將m.h轉(zhuǎn)為右值引用,繼而調(diào)用HugeMem的移動構(gòu)造函數(shù)
m.i = nullptr;
}
int *i;
HugeMem h;
};
Moveable getTemp() {
Moveable tmp = Moveable();
cout << hex << "Huge Mem from " << __func__ << "@" << tmp.h.c << endl;
return tmp;
}
int main(){
Moveable a(getTemp());//因為getTemp()返回的是右值,所以會調(diào)用Moveable的移動構(gòu)造函數(shù)
cout << hex << "Huge Mem from " << __func__ << "@" << a.h.c << endl;
return 0;
}
輸出
Huge Mem from getTemp@0x104002000
Huge Mem from main@0x104002000
需要注意的是,在編寫移動構(gòu)造函數(shù)的時候,應(yīng)該總是使用std::move轉(zhuǎn)換擁有形如堆內(nèi)存、文件句柄的等資源的成員為右值,這樣一來,如果成員支持移動構(gòu)造的話,就可以實現(xiàn)其移動語義,即使成員沒有移動構(gòu)造函數(shù),也會調(diào)用拷貝構(gòu)造,因為不會引起大的問題。
3.3.5 移動語義的一些其他問題
移動語義一定是要改變臨時變量的值(這里有以為,需要解決,目前沒看出哪里一定要改變,先這么硬背吧)。如聲明:
Moveable(const Moveale &&);//這個對應(yīng)3.3.4的例子,如果這樣聲明移動構(gòu)造函數(shù)會報錯

而如果是將3.3.4的例子中的Moveable getTemp()改為const Moveable getTemp(),再執(zhí)行命令
g++ -std=c++11 main.cpp -fno-elide-constructors
注意上面的改動在Xcode中是可以運行的,可以正確調(diào)用到移動構(gòu)造函數(shù),但是通過命令行會提示
copy constructor is implicitly deleted because 'Moveable' has a user-declared move constructor
可見Moveable a(getTemp());實際是要調(diào)用Moveable的拷貝構(gòu)造函數(shù)。報錯原因顯示聲明了移動構(gòu)造函數(shù),編譯器就不會為類生成默認(rèn)的拷貝構(gòu)造函數(shù)了,所以提示沒有顯示聲明拷貝構(gòu)造函數(shù)。
在C++11中,拷貝/移動改造函數(shù)有以下3個版本:
- T Object(T&)
- T Object(const T&)
- T Object(T&&)
其中常量左值引用的版本是一個拷貝構(gòu)造函數(shù)版本,右值引用參數(shù)的是一個移動構(gòu)造函數(shù)版本。默認(rèn)情況下,編譯器會為程序員隱式地生成一個移動構(gòu)造函數(shù),但是如果聲明了一自定義的拷貝構(gòu)造函數(shù)、拷貝賦值函數(shù)、移動構(gòu)造函數(shù)、析構(gòu)函數(shù)中的一個或者多個,編譯器都不會再生成默認(rèn)版本。所以在C++11中,拷貝構(gòu)造函數(shù)、拷貝賦值函數(shù)、移動構(gòu)造函數(shù)和移動賦值函數(shù)必須同時提供,或者同時不提供,只聲明其中一種的話,類都僅能實現(xiàn)一種語義。
只實現(xiàn)一種語義在類的編寫中也是非常常見的,比如如果只實現(xiàn)移動語義,則表明該類型的變量擁有的資源只能被移動,不能被拷貝,那么這樣的資源必須是唯一的,如智能指針、文件流。
在<type_traits>里,可以通過一些輔助的模板類來判斷一個類型是否是可以移動的,如:
- is_move_constructible
- is_trivially_move_constructible
- is_nothrow_move_constructible
使用方法都是使用value成員,如
cout << is_move_constructible<UnknowTYpe>::value;
有了移動語義,可以實現(xiàn)高性能的置換函數(shù),如:
template <class T>
void swap(T& a, T& b) {
T tmp(move(a));
a = move(b);
b = move(tmp);
}
如果T是可以移動的,則不會有資源的釋放和申請,如果T不可移動但是可以拷貝,則和普通聲明一樣了。
要注意的是,盡量不要編寫會拋出異常的移動構(gòu)造函數(shù),因為有可能移動沒完成,會導(dǎo)致一些指針成為懸掛指針,通過添加noexcept關(guān)鍵字,可以保證移動構(gòu)造函數(shù)拋出異常直接終止程序。
3.3.6 完美轉(zhuǎn)發(fā)
完美轉(zhuǎn)發(fā)(perfect forwarding),是指在模板函數(shù)中,完全依照模板的參數(shù)類型講參數(shù)傳遞給模板中調(diào)用的另外一個函數(shù),如:
template <typename T>
void IamForwarding(T t) {
IrunCodeActually(t);
}
這是一個參數(shù)透傳的實現(xiàn),但是因為使用最基本類型轉(zhuǎn)發(fā),會在傳參的時候產(chǎn)生一次額外的臨時對象拷貝,因為只能說是轉(zhuǎn)發(fā),但不完美。所以通常需要的是一個引用類型餐護(hù)士,不會有拷貝的開銷。其次需要考慮函數(shù)對類型的接受能力,因為目標(biāo)函數(shù)可能需要既接受左值引用,又接受右值引用,如果轉(zhuǎn)發(fā)函數(shù)只能接受其中的一部分,也不完美。
對應(yīng)代碼
typedef const A T;
typedef T& TR;
TR& v = 1;
在C++11中引入了一條所謂“引用折疊”的新語言規(guī)則,規(guī)則如下
| TR的類型定義 | 聲明v的類型 | v的實際類型 |
|---|---|---|
| T& | TR | A& |
| T& | TR& | A& |
| T& | TR&& | A& |
| T&& | TR | A&& |
| T&& | TR& | A& |
| T&& | TR&& | A&& |
規(guī)則就是一單定義中出現(xiàn)了左值引用,引用折疊總是優(yōu)先將其折疊為左值引用。前三行TR定義為T&,則v世界類型為A&,第五行的v的類型為TR&,則v的實際類型也為A&,其他則為右值引用。于是我們把轉(zhuǎn)發(fā)函數(shù)改為:
template <typename T>
void IamForwarding(T&& t) {
IrunCodeActually(static_cast<T&&>(t));
}
對于傳入的左值引用
void IamForwarding(X& && t) {
IrunCodeActually(static_cast<X& &&>(t));
}
折疊后是
void IamForwarding(X& t) {
IrunCodeActually(static_cast<X&>(t));
}
對于右值引用
void IamForwarding(X&& && t) {
IrunCodeActually(static_cast<X&& &&>(t));
}
折疊后是
void IamForwarding(X&& t) {
IrunCodeActually(static_cast<X&&>(t));
}
此處的static_cast類似std::move的作用,將左值轉(zhuǎn)換為右值引用。不過在C++11中,用于完美轉(zhuǎn)發(fā)的函數(shù)不叫move,叫forward,所以也可以這么寫
void IamForwarding(X&& t) {
IrunCodeActually(forward(t));
}
move和forward實現(xiàn)差別不大,但是為了不同用途,有了不同命名。
下面是完美轉(zhuǎn)發(fā)的例子:
void run(int && m) { cout << "rvalue ref" << endl; }
void run(int & m) { cout << "lvalue ref" << endl; }
void run(const int && m) { cout << "const rvalue ref" << endl; }
void run(const int & m) { cout << "const lvalue ref" << endl; }
template <typename T>
void perfectForward(T&& t) {
run(forward<T>(t));
}
int main(){
int a;
int b;
const int c = 1;
const int d = 0;
perfectForward(a);
perfectForward(move(b));
perfectForward(c);
perfectForward(move(d));
return 0;
}
輸出
lvalue ref
rvalue ref
const lvalue ref
const rvalue ref