深入理解C++11 3.3 右值引用:移動語義和完美轉(zhuǎn)發(fā)

首先,本章很長,也較難理解,建議讀者有大段連續(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ù)會報錯
image.png

而如果是將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
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容