本文出自圖書 > 深入理解C++11:C++ 11新特性解析與應用
3.3 右值引用:移動語義和完美轉發
3.3.1指針成員與拷貝構造
對C++程序員來說,編寫C++程序有一條必須注意的規則,就是在類中包含了一個指針成員的話,那么就要特別小心拷貝構造函數的編寫,因為一不小心,就會出現內存泄露。
#include <iostream> using namespace std; class HasPtrMem{ public: HasPtrMem(): d(new int(0)){} ~HasPtrMem() { delete d; } int *d; //指針成員d }; int main(){ HasPtrMem a; HasPtrMem b(a); cout<<*a.d<<endl;//0 cout<<*b.d<<endl;//0 }
我們定義了一個HasPtrMem的類。這個類包含一個指針成員,該成員在構造時接受一個new操作分配堆內存返回的指針,而在析構的時候則會被delete操作用於釋放之前分配的堆內存。
在main函數中,我們聲明了HsaPtrMem類型的變量a,又使用a初始化了變量b。按照C++語法,這會調用HasPtrMem的拷貝構造函數。(這里的拷貝構造函數由編譯器隱式生成,其作用
是執行類似於memcpy的按位拷貝。這樣的構造方式有一個問題,就是a.d和b.d都指向同一塊堆內存。因此在main作用域結束的時候,a和b的析構函數紛紛被調用,當其中之一完成析構
之后(比如b),那么a.d就成了一個"懸掛指針",因為其不再指向有效的內存了。那么在該懸掛指針上釋放內存就會造成嚴重的錯誤。
這樣的拷貝方式,在C++中也常被稱為"淺拷貝"。而在為聲明構造函數的情況下,C++也會為類生成一個淺拷貝的構造函數。通常最佳的解決方案是用戶自定義拷貝構造函數來實現"深拷貝":
#include <iostream> using namespace std; class HasPtrMem{ public: HasPtrMem(): d(new int(0)){ cout<<"Construct:"<<endl; } HasPtrMem(HasPtrMem&h): d(new int(*h.d)){ cout<<"Copy construct:"<<endl; } //拷貝構造函數,從堆中分配內存,並用*h.d初始化 ~HasPtrMem() { delete d; } int *d; //指針成員d }; int main(){ HasPtrMem a; HasPtrMem b(a); cout<<*a.d<<endl;//0 cout<<*b.d<<endl;//0 }
(問題:淺拷貝和深拷貝 的差別)
我們為HasPtrMem添加了一個拷貝構造函數。拷貝構造函數從堆中分配內存,將該分配來的內存的指針交還給d, 又使用*(h.d)對 *d進行了初始化。通過這樣的方法,就避免了懸掛指針的困擾。
3.3.2 移動語義
拷貝構造函數中為指針成員分配新的內存再進行內容拷貝的做法在C++編程中幾乎被視為不可違背的。不過在一些時候,我們確實不需要這樣的拷貝語義。
#include <iostream> using namespace std; class HasPtrMem{ public: HasPtrMem(): d(new int(0)){ cout<<"Construct:" << ++n_cstr<<endl; } HasPtrMem(const HasPtrMem&h): d(new int(*h.d)){ cout<<"Copy construct:"<< ++n_cptr<<endl; } //拷貝構造函數,從堆中分配內存,並用*h.d初始化 ~HasPtrMem() { cout<<"Destruct:"<<++n_dstr<<endl; } 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 a=GetTemp(); }
(回顧:靜態變量和非靜態變量)
數據成員可以分靜態變量、非靜態變量兩種.
靜態成員:靜態類中的成員加入static修飾符,即是靜態成員.可以直接使用類名+靜態成員名訪問此靜態成員,因為靜態成員存在於內存,非靜態成員需要實例化才會
分配內存,所以靜態成員不能訪問非靜態的成員..因為靜態成員存在於內存,所以非靜態成員可以直接訪問類中靜態的成員.
非靜態成員:所有沒有加Static的成員都是非靜態成員,當類被實例化之后,可以通過實例化的類名進行訪問..非靜態成員的生存期決定於該類的生存期..而靜態成員則
不存在生存期的概念,因為靜態成員始終駐留在內容中..
我們聲明了一個返回一個HasPtrMem變量的函數。為了記錄構造函數、拷貝構造函數,以及析構函數調用的次數,我們用了一些靜態變量。在main函數中,我們簡單地聲明
了一個HasPtrMem的變量a,要求它使用GetTemp的返回值進行初始化。
//正常情況下的輸出: Construct:1 Copy construct:1 //這個是臨時對象的構造 Destruct:1 //這個應該是臨時對象的析構 Copy construct:2 Destruct:2 Destruct:3 但是在C++11或者非C++里面的結果 只是一個淺拷貝
這里的構造函數被調用了一次,是GetTemp函數中HasPtrMem()表達式顯示地調用了構造函數而打印出來的。而拷貝構造函數則被調用了兩回。一次是從GetTemp函數中HasPtrMem()生成的變量上拷貝構造出來一個臨時值,以用做GetTemp的返回值,而另一次則是由臨時值構造出main中變量a調用的。對應的,析構函數也就調用了3次。

最頭疼的就是拷貝構造函數的調用。在上面的代碼上,類HasPtrMem只有一個Int類型的指針。如果HasPtrMem的指針指向非常大的堆內存數據的話,那么拷貝構造函數就會非常昂貴。
可以想象,一旦這樣,a的初始化表達式的執行速度非常慢。臨時變量的產生和銷毀以及拷貝的發生對於程序員來說基本上是透明的,不會影響程序的正常值,因而即使該問題導致程序
的性能不如預期,也不易被程序員察覺(事實上,編譯器常常對函數返回值有專門的優化)
然后,按照C++的語義,臨時對象將在語句結束后被析構,會釋放它所包含的堆內存資源。而a在拷貝構造的時候,又會被分配堆內存。這樣意義不大,所以,考慮在臨時對象構造a的時
候不分配內存,即不使用拷貝構造。
剩下的就是移動構造:

上半部分從臨時變量中拷貝構造變量a的做法,即在拷貝時分配新的堆內存,並從臨時對象的堆內存中拷貝內容至a.d。而構造完成后,臨時對象將析構,因此,其擁有的堆內存資源會被析構函數釋放。
下半部分,在構造函數時使得a.d指向臨時對象的堆內存資源。同時我們保證臨時對象不釋放所指向的堆內存,那么,在構造完成后,臨時對象被析構,a就從中"偷"到了臨時對象所擁有的堆內存資源。
在 C++11 中,這樣的"偷走"臨時變量中資源的構造函數,就被稱為"移動構造函數"。
#include <iostream> using namespace std; class HasPtrMem{ public: HasPtrMem(): d(new int(3)){ cout<<"Construct:" << ++n_cstr<<endl; } HasPtrMem(const HasPtrMem&h): d(new int(*h.d)){ cout<<"Copy construct:"<< ++n_cptr<<endl; } //拷貝構造函數,從堆中分配內存,並用*h.d初始化 HasPtrMem(HasPtrMem &&h):d(h.d){ h.d=nullptr;//將臨時值得指針成員置空。 cout<<"Move construct:"<<++n_mvtr<<endl; } ~HasPtrMem() { delete d; cout<<"Destruct:"<<++n_dstr<<endl; } 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(){ HasPtrMem h; cout<<"Resource from"<<__func__<<":"<<hex<<h.d<<endl; return h; } int main(){ //HasPtrMem b; HasPtrMem a=GetTemp(); cout<<"Resource from"<<__func__<<":"<<hex<<a.d<<endl; }
這里其實,就多了一個構造函數HasPtrMem(HasPtrMem&&), 這個就是我們所謂的移動構造函數。與拷貝構造函數不同的是,移動構造函數接受一個所謂的"右值引用"的參數,
關於右值,讀者可以暫時理解為臨時變量的引用。移動構造函數使用了參數h的成員d初始化了本對象的成員d(而不是像拷貝構造函數一樣需要分配內存,然后將內存一次拷貝到新分配的內存中),
隨后h的成員d置為指針空值nullptr。完成了移動構造函數的全過程。
這里所謂的“偷”堆內存,就是指將本對象d指向h.d所指的內存這一條語句,相應地,我們還將h的成員d置為指針空值。這其實也是我們“偷”內存時必須做的。這是因為在移動構造完成之后,
臨時對象會立即被析構。如果不改變h.d(臨時對象的指針成員)的話,則臨時對象會析構掉本是我們“偷”來的堆內存。這樣一來,本對象中的d指針也就成了一個懸掛指針,如果我們對指針
進行解引用,就會發生嚴重的運行時錯誤。
(將指針置為nullptr只是讓這個指針不再指向任何對象,並沒有釋放原來這個指針指向的對象的內存)
//理論上的結果: Construct:1 Resource from GetTemp:0x603010 Move construct:1 Destruct:1 Move construct:2 Destruct:2 Resource from main:0x603010 Destruct:3 //實際上的結果:似乎只要涉及到需要臨時變量的生成的時候,都會有問題。 Construct:1 Resource from GetTemp:0x603010 Resource from main:0x603010 Destruct:1
如果堆內存不是一個int長度的數據,而是以MBty為單位的堆空間,那么這樣的移動帶來的性能提升是非常驚人的。
如果傳了引用或者指針到函數里面作為參數,效果雖然不差。但是從使用的方便性上來看效果卻不好,如果函數返回臨時值的話,可以在單條語句里面完成很多計算,比如可以很自然地寫出如下語句:
Caculate(GetTemp(), SomeOther(Maybe(),Useful(Values,2)));
但如果通過傳引用或者指針的方法而不返回值的話,通常就需要很多語句來完成上面的工作。
string*a; vector b;//事先聲明一些變量用於傳遞返回值 ... Useful(Values,2,a);//最后一個參數是指針,用於返回結果 SomeOther(Maybe(),a,b);//最后一個參數是引用,用於返回結果 Caculate(GetTemp(), b);
當聲明這些傳遞返回值的變量為全局的,函數再將這些引用和指針作為返回值返回給調用者,我們也需要Caculate調用之前聲明好所有的引用和指針。函數返回臨時變量的好處就是不需要聲明變量,也不需要知道生命期。程序員只需要按照最自然的方式,使用最簡單語句就可以完成大量的工作。
然后,移動語義何時會被觸發。之前我們只是提到了臨時對象,一旦我們用到的是個臨時變量,那么移動構造語義就可以得以執行。**那么,在C++中如何判斷產生了臨時對象?如何將其用於移動構造函數?是否只有臨時變量可以用於移動構造?.....
3.3.3 左值、右值與右值引用
在C語言中,我們常常會提起左值(lvalue)、右值(rvalue)這樣的稱呼。而在編譯程序時,編譯器有時也會在報出的錯誤信息中會包含左值、右值的說法。不過左值、右值通常不是通過一個嚴謹的定義而為人所知的,大多數時候左右值的定義與其判別方法是一體的。一個最為典型的判別方法就是,在賦值表達式中,出現在等號左邊的就是“左值”,而在等號右邊的,則稱為“右值”。比如:
a = b + c;
在這個賦值表達式中,a就是一個左值,而b + c則是一個右值。這種識別左值、右值的方法在C++中依然有效。不過C++中還有一個被廣泛認同的說法,那就是可以取地址的、有名字的就是左值,反之,不能取地址的、沒有名字的就是右值。那么這個加法賦值表達式中,&a是允許的操作,但&(b + c)這樣的操作則不會通過編譯。因此a是一個左值,(b + c)是一個右值。
這些判別方法通常都非常有效。更為細致地,在C++11中,右值是由兩個概念構成的,一個是將亡值(xvalue,eXpiring Value),另一個則是純右值(prvalue,Pure Rvalue)。
其中純右值就是C++98標准中右值的概念,講的是用於辨識臨時變量和一些不跟對象關聯的值。比如非引用返回的函數返回的臨時變量值(我們在前面多次提到了)就是一個純右值。一些運算表達式,比如1 + 3產生的臨時變量值,也是純右值。而不跟對象關聯的字面量值,比如:2、‘c’、true,也是純右值。此外,類型轉換函數的返回值、lambda表達式(見7.3節)等,也都是右值。
而將亡值則是C++11新增的跟右值引用相關的表達式,這樣表達式通常是將要被移動的對象(移為他用),比如返回右值引用T&&的函數返回值、std::move的返回值(稍后解釋),或者轉換為T&&的類型轉換函數的返回值(稍后解釋)。而剩余的,可以標識函數、對象的值都屬於左值。在C++11的程序中,所有的值必屬於左值、將亡值、純右值三者之一。
注意 事實上,之所以我們只知道一些關於左值、右值的判斷而很少聽到其真正的定義的一個原因就是—很難歸納。而且即使歸納了,也需要大量的解釋。
在C++11中,右值引用就是對一個右值進行引用的類型。事實上,由於右值通常不具有名字,我們也只能通過引用的方式找到它的存在。通常情況下,我們只能是從右值表達式獲得其引用。比如:
T && a = ReturnRvalue();
這個表達式中,假設ReturnRvalue返回一個右值,我們就聲明了一個名為a的右值引用,其值等於ReturnRvalue函數返回的臨時變量的值。
為了區別於C++98中的引用類型,我們稱C++98中的引用為“左值引用”(lvalue reference)。右值引用和左值引用都是屬於引用類型。無論是聲明一個左值引用還是右值引用,都必須立即進行初始化。而其原因可以理解為是引用類型本身自己並不擁有所綁定對象的內存,只是該對象的一個別名。左值引用是具名變量值的別名,而右值引用則是不具名(匿名)變量的別名。
在上面的例子中,ReturnRvalue函數返回的右值在表達式語句結束后,其生命也就終結了(通常我們也稱其具有表達式生命期),而通過右值引用的聲明,該右值又“重獲新生”,其生命期將與右值引用類型
變量a的生命期一樣。只要a還“活着”,該右值臨時量將會一直“存活”下去。
所以相比於以下語句的聲明方式:
T b = ReturnRvalue();
我們剛才的右值引用變量聲明,就會少一次對象的析構及一次對象的構造。因為a是右值引用,直接綁定了ReturnRvalue()返回的臨時量,而b只是由臨時值構造而成的,而臨時量在表達式結束后會析構因應就會多一次析構和構造的開銷。
不過值得指出的是,能夠聲明右值引用a的前提是ReturnRvalue返回的是一個右值。通常情況下,右值引用是不能夠綁定到任何的左值的。比如下面的表達式就是無法通過編譯的。
int c;
int && d = c;
相對地,在C++98標准中就已經出現的左值引用是否可以綁定到右值(由右值進行初始化)呢?比如:
T & e = ReturnRvalue();
const T & f = ReturnRvalue();
這樣的語句是否能夠通過編譯呢?這里的答案是:e的初始化會導致編譯時錯誤,而f則不會。
出現這樣的狀況的原因是,在常量左值引用在C++98標准中開始就是個“萬能”的引用類型。它可以接受非常量左值、常量左值、右值對其進行初始化。而且在使用右值對其初始化的時候,常量左值引用還可以
像右值引用一樣將右值的生命期延長。不過相比於右值引用所引用的右值,常量左值所引用的右值在它的“余生”中只能是只讀的。相對地,非常量左值只能接受非常量左值對其進行初始化。
既然常量左值引用在C++98中就已經出現,讀者可能會努力地搜索記憶,想找出在C++中使用常量左值綁定右值的情況。不過可能一切並不如願。這是因為,在C++11之前,左值、右值對於程序員來說,一直呈透明狀態。不知道什么是左值、右值,並不影響寫出正確的C++代碼。引用的是左值和右值通常也並不重要。不過事實上,在C++98通過左值引用來綁定一個右值的情況並不少見,比如:
const bool & judgement = true;
就是一個使用常量左值引用來綁定右值的例子。不過與如下聲明相比較看起來似乎差別不大。
const bool judgement = true;
可能很多程序員都沒有注意到其中的差別(從語法上講,前者直接使用了右值並為其“續命”,而后者的右值在表達式結束后就銷毀了)。
事實上,即使在C++98中,我們也常可以使用常量左值引用來減少臨時對象的開銷,如代碼清單3-20所示。
代碼清單3-20
#include <iostream>
using namespace std;
struct Copyable {
Copyable() {}
Copyable(const Copyable &o) {
cout << "Copied" << endl;
}
};
Copyable ReturnRvalue() { return Copyable(); }
void AcceptVal(Copyable) {}
void AcceptRef(const Copyable & ) {}
int main() {
cout << "Pass by value: " << endl;
AcceptVal(ReturnRvalue()); // 臨時值被拷貝傳入
cout << "Pass by reference: " << endl;
AcceptRef(ReturnRvalue()); // 臨時值被作為引用傳遞
}
// 編譯選項:g++ 3-3-5.cpp -fno-elide-constructors
在代碼清單3-20中,我們聲明了結構體Copyable,該結構體的唯一的作用就是在被拷貝構造的時候打印一句話:Copied。而兩個函數,AcceptVal使用了值傳遞參數,而AcceptRef使用了引用傳遞。在以ReturnRvalue返回的右值為參數的時候,AcceptRef就可以直接使用產生的臨時值(並延長其生命期),而AcceptVal則不能直接使用臨時對象。
編譯運行代碼清單3-20,可以得到以下結果:
Pass by value:
Copied
Copied
Pass by reference:
Copied
可以看到,由於使用了左值引用,臨時對象被直接作為函數的參數,而不需要從中拷貝一次。讀者可以自行分析一下輸出結果,這里就不贅述了。而在C++11中,同樣地,如果在代碼清單3-20中以右值引用為參數聲明如下函數:
void AcceptRvalueRef(Copyable && ) {}
也同樣可以減少臨時變量拷貝的開銷。進一步地,還可以在AcceptRvalueRef中修改該臨時值(這個時候臨時值由於被右值引用參數所引用,已經獲得了函數時間的生命期)。不過修改一個臨時值的意義通常不大,除非像3.3.2節一樣使用移動語義。
就本例而言,如果我們這樣實現函數:
void AcceptRvalueRef(Copyable && s) {
Copyable news = std::move(s);
}
這里std::move的作用是強制一個左值成為右值(看起來很奇怪?這個我們會在下面一節中解釋)。該函數就是使用右值來初始化Copyable變量news。當然,如同我們在上小節提到的,使用移動語義的前提是Copyable還需要添加一個以右值引用為參數的移動構造函數,比如:
Copyable(Copyable &&o) { /* 實現移動語義 */ }
這樣一來,如果Copyable類的臨時對象(即ReturnRvalue返回的臨時值)中包含一些大塊內存的指針,news就可以如同代碼清單3-19一樣將臨時值中的內存“竊”為己用,從而從這個以右值引用參數的AcceptRvalueRef函數中獲得最大的收益。事實上,右值引用的來由從來就跟移動語義緊緊相關。這是右值存在的一個最大的價值(另外一個價值是用於轉發,我們會在后面的小節中看到)。
對於本例而言,很有趣的是,讀者也可以思考一下:如果我們不聲明移動構造函數,而只聲明一個常量左值的構造函數會發生什么?如同我們剛才提到的,常量左值引用是個“萬能”的引用類型,無論左值還是右值,常量還是非常量,一概能夠綁定。那么如果Copyable沒有移動構造函數,下列語句:
Copyable news = std::move(s);
將調用以常量左值引用為參數的拷貝構造函數。這是一種非常安全的設計—移動不成,至少還可以執行拷貝。因此,通常情況下,程序員會為聲明了移動構造函數的類聲明一個常量左值為參數的拷貝構造函數,以保證在移動構造不成時,可以使用拷貝構造(不過,我們也會在之后看到一些特殊用途的反例)。
為了語義的完整,C++11中還存在着常量右值引用,比如我們通過以下代碼聲明一個常量右值引用。
const T && crvalueref = ReturnRvalue();
但是,一來右值引用主要就是為了移動語義,而移動語義需要右值是可以被修改的,那么常量右值引用在移動語義中就沒有用武之處;二來如果要引用右值且讓右值不可以更改,常量左值引用往往就足夠了。
因此在現在的情況下,我們還沒有看到常量右值引用有何用處。(所以移動構造函數的形參不能是const的)
3.3.4 std::move:強制轉化為右值
在C++11中,標准庫在<utility>中提供了一個有用的函數std::move,這個函數的名字具有迷惑性,因為實際上std::move並不能移動任何東西,它唯一的功能是將一個左值強制轉化為右值引用,繼而我們可以通過右值引用使用該值,以用於移動語義。從實現上講,std::move基本等同於一個類型轉換:
static_cast<T&&>(lvalue);
值得一提的是,被轉化的左值,其生命期並沒有隨着左右值的轉化而改變。如果讀者期望std::move轉化的左值變量lvalue能立即被析構,那么肯定會失望了。我們來看代碼清單3-21所示的例子。
代碼清單3-21
#include <iostream>
using namespace std;
class Moveable{
public:
Moveable():i(new int(3)) {}
~Moveable() { delete i; }
Moveable(const Moveable & m): i(new int(*m.i)) { }
Moveable(Moveable && m):i(m.i) {
m.i = nullptr;
}
int* i;
};
int main() {
Moveable a;
Moveable c(move(a)); // 會調用移動構造函數
cout << *a.i << endl; // 運行時錯誤
}
// 編譯選項:g++ -std=c++11 3-3-6.cpp -fno-elide-constructors
在代碼清單3-21中,我們為類型Moveable定義了移動構造函數。這個函數定義本身沒有什么問題,但調用的時候,使用了Moveable c(move(a));這樣的語句。這里的a本來是一個左值變量,通過std::move將其轉換為右值。這樣一來,a.i就被c的移動構造函數設置為指針空值。由於a的生命期實際要到main函數結束才結束,那么隨后對表達式*a.i進行計算的時候,就會發生嚴重的運行時錯誤。
這是個典型誤用std::move的例子。當然,標准庫提供該函數的目的不是為了讓程序員搬起石頭砸自己的腳。事實上,要使用該函數,必須是程序員清楚需要轉換的時候。比如上例中,程序員應該知道被轉化為右值的a不可以再使用。不過更多地,我們需要轉換成為右值引用的還是一個確實生命期即將結束的對象。我們來看看代碼清單3-22所示的正確例子。
代碼清單3-22
#include <iostream>
using namespace std;
class HugeMem{
public:
HugeMem(int size): sz(size > 0 ? size : 1) {
c = new int[sz];
}
~HugeMem() { delete [] c; }
HugeMem(HugeMem && hm): sz(hm.sz), c(hm.c) {
hm.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)) { // 強制轉為右值,以調用移動構造函數
m.i = nullptr;
}
int* i;
HugeMem h;
};
Moveable GetTemp() {
Moveable tmp = Moveable();
cout << hex << "Huge Mem from " << __func__
<< " @" << tmp.h.c << endl; // Huge Mem from GetTemp @0x603030
return tmp;
}
int main() {
Moveable a(GetTemp());
cout << hex << "Huge Mem from " << __func__
<< " @" << a.h.c << endl; // Huge Mem from main @0x603030
}
// 編譯選項:g++ -std=c++11 3-3-7.cpp -fno-elide-constructors
在代碼清單3-22中,我們定義了兩個類型:HugeMem和Moveable,其中Moveable包含了一個HugeMem的對象。在Moveable的移動構造函數中,我們就看到了std::move函數的使用。該函數將m.h強制轉化為右值,以迫使Moveable中的h能夠實現移動構造。這里可以使用std::move,是因為m.h是m的成員,既然m將在表達式結束后被析構,其成員也自然會被析構,因此不存在代碼清單3-21中的生存期不對的問題。另外一個問題可能是std::move使用的必要性。這里如果不使用std::move(m.h)這樣的表達式,而是直接使用m.h這個表達式將會怎樣?
其實這是C++11中有趣的地方:可以接受右值的右值引用本身卻是個左值。這里的m.h引用了一個確定的對象,而且m.h也有名字,可以使用&m.h取到地址,因此是個不折不扣的左值。不過這個左值確確實實會很快“灰飛煙滅”,因為拷貝構造函數在Moveable對象a的構造完成后也就結束了。那么這里使用std::move強制其為右值就不會有問題了。而且,如果我們不這么做,由於m.h是個左值,就會導致調用HugeMem的拷貝構造函數來構造Moveable的成員h(雖然這里沒有聲明,讀者可以自行添加實驗一下)。如果是這樣,移動語義就沒有能夠成功地向類的成員傳遞。換言之,還是會由於拷貝而導致一定的性能上的損失。
事實上,為了保證移動語義的傳遞,程序員在編寫移動構造函數的時候,應該總是記得使用std::move轉換擁有形如堆內存、文件句柄等資源的成員為右值,這樣一來,如果成員支持移動構造的話,就可以實現其移動語義。而即使成員沒有移動構造函數,那么接受常量左值的構造函數版本也會輕松地實現拷貝構造,因此也不會引起大的問題。
3.3.5 移動語義的一些其他問題
我們在前面多次提到,移動語義一定是要修改臨時變量的值。那么,如果這樣聲明移動構造函數:
Moveable(const Moveable &&)
或者這樣聲明函數:
const Moveable ReturnVal();
都會使得的臨時變量常量化,成為一個常量右值,那么臨時變量的引用也就無法修改,從而導致無法實現移動語義。因此程序員在實現移動語義一定要注意排除不必要的const關鍵字。
在C++11中,拷貝/移動構造函數實際上有以下3個版本:
T Object(T &)
T Object(const T &)
T Object(T &&)
其中常量左值引用的版本是一個拷貝構造版本,而右值引用版本是一個移動構造版本。默認情況下,編譯器會為程序員隱式地生成一個(隱式表示如果不被使用則不生成)移動構造函數。不過
①如果程序員聲明了自定義的拷貝構造函數、拷貝賦值函數、移動賦值函數、析構函數中的一個或者多個,編譯器都不會再為程序員生成默認版本。默認的移動構造函數實際上跟默認的拷貝構造函數一樣,只
能做一些按位拷貝的工作。這對實現移動語義來說是不夠的。通常情況下,如果需要移動語義,程序員必須自定義移動構造函數。當然,對一些簡單的、不包含任何資源的類型來說,實現移動語義與否都無關
緊要,因為對這樣的類型而言,移動就是拷貝,拷貝就是移動。
同樣地,②聲明了移動構造函數、移動賦值函數、拷貝賦值函數和析構函數中的一個或者多個,編譯器也不會再為程序員生成默認的拷貝構造函數。
所以在C++11中,拷貝構造/賦值和移動構造/賦值函數必須同時提供,或者同時不提供,程序員才能保證類同時具有拷貝和移動語義。只聲明其中一種的話,類都僅能實現一種語義。
其實,只實現一種語義在類的編寫中也是非常常見的。比如說只有拷貝語義的類型—事實上在C++11之前我們見過大多數的類型的構造都是只使用拷貝語義的。而只有移動語義的類型則非常有趣,因為只有移動語義表明該類型的變量所擁有的資源只能被移動,而不能被拷貝。那么這樣的資源必須是唯一的。因此,只有移動語義構造的類型往往都是“資源型”的類型,比如說智能指針,文件流等,都可以視為“資源型”的類型。在本書的第5章中,就可以看到標准庫中的僅可移動的模板類:unique_ptr。一些編譯器,如vs2011,現在也把ifstream這樣的類型實現為僅可移動的。
在標准庫的頭文件<type_traits>里,我們還可以通過一些輔助的模板類來判斷一個類型是否是可以移動的。比如is_move_constructible、is_trivially_move_constructible、is_nothrow_move_constructible,使用方法仍然是使用其成員value。比如:
cout << is_move_constructible<UnknownType>::value;
就可以打印出UnknowType是否可以移動,這在一些情況下還是非常有用的。
而有了移動語義,還有一個比較典型的應用是可以實現高性能的置換(swap)函數。看看下面這段swap模板函數代碼:
template <class T>
void swap(T& a, T& b)
{
T tmp(move(a));
a = move(b);
b = move(tmp);
}
如果T是可以移動的,那么移動構造和移動賦值將會被用於這個置換。代碼中,a先將自己的資源交給tmp,隨后b再將資源交給a,tmp隨后又將從a中得到的資源交給b,從而完成了一個置換動作。整個過程,代碼都只會按照移動語義進行指針交換,不會有資源的釋放與申請。而如果T不可移動卻是可拷貝的,那么拷貝語義會被用來進行置換。這就跟普通的置換語句是相同的了。因此在移動語義的支持下,我們僅僅通過一個通用的模板,就可能更高效地完成置換,這對於泛型編程來說,無疑是具有積極意義的。
另外一個關於移動構造的話題是異常。對於移動構造函數來說,拋出異常有時是件危險的事情。因為可能移動語義還沒完成,一個異常卻拋出來了,這就會導致一些指針就成為懸掛指針。因此程序員應該盡量編寫不拋出異常的移動構造函數,通過為其添加一個noexcept關鍵字,可以保證移動構造函數中拋出來的異常會直接調用terminate程序終止運行,而不是造成指針懸掛的狀態。而標准庫中,我們還可以用一個std::move_if_noexcept的模板函數替代move函數。該函數在類的移動構造函數沒有noexcept關鍵字修飾時返回一個左值引用從而使變量可以使用拷貝語義,而在類的移動構造函數有noexcept關鍵字時,返回一個右值引用,從而使變量可以使用移動語義。我們來看一下代碼清單3-23所示的例子。
代碼清單3-23
#include <iostream>
#include <utility>
using namespace std;
struct Maythrow {
Maythrow() {}
Maythrow(const Maythrow&) {
std::cout << "Maythorow copy constructor." << endl;
}
Maythrow(Maythrow&&) {
std::cout << "Maythorow move constructor." << endl;
}
};
struct Nothrow {
Nothrow() {}
Nothrow(Nothrow&&) noexcept {
std::cout << "Nothorow move constructor." << endl;
}
Nothrow(const Nothrow&) {
std::cout << "Nothorow move constructor." << endl;
}
};
int main() {
Maythrow m;
Nothrow n;
Maythrow mt = move_if_noexcept(m); // Maythorow copy constructor.
Nothrow nt = move_if_noexcept(n); // Nothorow move constructor.
return 0;
}
// 編譯選項:g++ -std=c++11 3-3-8.cpp
在代碼清單3-23中,可以清楚地看到move_if_noexcept的效果。事實上,move_if_noexcept是以犧牲性能保證安全的一種做法,而且要求類的開發者對移動構造函數使用noexcept進行描述,否則就會損失更多的性能。這是庫的開發者和使用者必須協同平衡考慮的。
還有一個與移動語義看似無關,但偏偏有些關聯的話題是,編譯器中被稱為RVO/NRVO的優化(RVO, Return Value Optimization,返回值優化,或者NRVO,Named Return Value optimization)。事實上,在本節中大量的代碼都使用了-fno-elide-constructors選項在g++/clang++中關閉這個優化,這樣可以使讀者在代碼中較為容易地利用函數返回的臨時量右值。
但若在編譯的時候不使用該選項的話,讀者會發現很多構造和移動都被省略了。對於下面這樣的代碼,一旦打開g++/clang++的RVO/NRVO,從ReturnValue函數中a變量拷貝/移動構造臨時變量,以及從臨時變量拷貝/移動構造b的二重奏就通通沒有了。
A ReturnRvalue() { A a(); return a; }
A b = ReturnRvalue();
b變量實際就使用了ReturnRvalue函數中a的地址,任何的拷貝和移動都沒有了。通俗地說,就是b變量直接“霸占”了a變量。這是編譯器中一個效果非常好的一個優化。不過RVO/NRVO並不是對任何情況都有效。比如有些情況下,一些構造是無法省略的。還有一些情況,即使RVO/NRVO完成了,也不能達到最好的效果。但結論是明顯的,移動語義可以解決編譯器無法解決的優化問題,因而總是有用的。