深入理解C++11核心程式設計(三)---通用為本,專用為末

weixin_33976072發表於2016-10-14

C++11的新特性具有廣泛的可用性,可以與其他已有的,或者新增的語言特性結合起來進行自由的組合,或者提升已有特性的通用性。

繼承建構函式

C++中的自定義型別--類,具有可派生性,派生類可以自動獲得基類的成員變數和介面(虛擬函式和純虛擬函式,這裡指的是public派生)。不過基類的非虛擬函式則無法再被派生類使用了。這條規則對於類中最為特別的建構函式也不例外,如果派生類要使用基類的建構函式,通常需要在建構函式中顯式宣告。

struct A{A(int i){}};
struct B:A{B(int i):A(i)};

B派生於A,B又在建構函式中呼叫A的建構函式,從而完成建構函式的"傳遞"。在B中有成員的時候:

struct A{A(int i){}};
struct B:A{
B(int i):A(i),d(i){}
int d;
};

派生於結構體A的結構體B擁有一個成員變數d,那麼在B的建構函式B(int i)中,我們可以在初始化其基類A的同時初始化成員d。

有的時候,我們的基類可能擁有數量眾多的不同版本的建構函式,而派生類卻只有一些成員函式時,那麼對於派生類而言,其構造就等同於構造基類。

在派生類中我們寫的建構函式完完全全就是為了構造基類。那麼為了遵從於語法規則,我們還需要寫很多的"透傳"的建構函式。

struct A{
A(int i){}
A(double d,int i){}
A(float f,int i,const char*c){}
//...
};
struct B:A{
B(int i): A(i){}
B(double d, int i): A(d,i){}
B(float f,int i,const char*c): A(f,i,c){}
//...
virtual void ExtraInterface(){}
};

我們的基類A有很多的建構函式的版本,而繼承於A的派生類B實際上只是新增了一個介面ExtraInterface.那麼如果我們在構造B的時候想要擁有A這樣多的構造方法的話,就必須一一"透傳"各個介面。

事實上,在C++中已經有了一個好用的規則,就是如果派生類要使用基類的成員函式的話,可以通過using宣告(using-declaration)來完成。

#include<iostream> 
using namespace std;
struct Base{
    void f(double i){
        cout<<"Base:"<<i<<endl;
    };
};
struct Derived:Base{
        using Base:: f;
        void f(int i){
            cout<<"Derived:"<<i<<endl;
        }
    };

int main(){
    Base b;
    b.f(4.5); //Base:4.5
    Derived d;
    d.f(4.5); //Base:4.5
}

我們的基類Base和派生類Derived宣告瞭同名的函式f,不過在派生類中的版本跟基類有所不同。派生類中的f函式接受int型別為引數,而基類中接受double型別的引數。我們這裡使用了using 宣告,宣告派生類Derived也使用基類版本的函式f。這樣一來,派生類中實際就擁有了兩個f函式的版本。

C++11中,這個想法被擴充套件到了建構函式上。子類可以通過使用using宣告來宣告繼承基類的建構函式。

struct A{
A(int i){}
A(double d,int i){}
A(float f,int i,const char*c){}
//...
};
struct B:A{
using A:: A;//繼承建構函式
//...
virtual void ExtraInterface(){}
};

我們通過using A:: A的宣告,把基類中的建構函式悉數繼承到派生類B中。C++11標準繼承建構函式被設計為跟派生類中的各種類預設函式(預設構造、析構、拷貝構造等)一樣,是隱式宣告的。意味著如果一個繼承建構函式不被相關程式碼使用,編譯器不會為其產生真正的函式程式碼。

不過,繼承建構函式只會初始化基類中成員變數,對於派生類中的成員變數,則無能為力。只能通過初始化一個預設值的方式來解決。如果無法滿足需求的話,只能自己來實現一個建構函式,以達到基類和成員變數都能夠初始化的目的。

基類建構函式的引數會有預設值。對於繼承建構函式來講,引數的預設值是不會被繼承的。事實上,預設值會導致基類產生多個建構函式的版本,這些函式版本都會被派生類繼承。

struct A{
A(int a=3,double=2.4){}
}
struct B:A{
using A:: A;
};

我們的基類的建構函式A(int a=3,double=2.4)有一個接受兩個引數的建構函式,且兩個引數均有預設值。那麼A到底有多少個可能的建構函式版本呢?
事實上,B可能從A中繼承來的候選繼承建構函式有如下一些:
A(int=3,double=2.4); 這是使用兩個引數的情況。
A(int=3); 這是減掉一個引數的情況。
A(const A&); 這是預設的複製建構函式。
A(); 這是不使用引數的情況。
相應地,B中的建構函式將會包括以下一些:
B(int,double); 這是一個繼承建構函式。
B(int);這是減少掉一個引數的繼承建構函式。
B(const B&); 這是複製建構函式,這不是繼承來的。
B(); 這是不包含引數預設建構函式。

可以看見,引數預設值會導致多個建構函式版本的產生,因此程式設計師在使用有引數預設值的建構函式的基類的時候,必須小心。

繼承建構函式“衝突”的情況。通常發生在派生類擁有多個基類的時候。多個基類中的部分建構函式可能導致派生類中的繼承建構函式的函式名、引數(有的時候,我們也稱其為函式簽名)都相同,那麼繼承類中的衝突的繼承建構函式將導致不合法的派生類程式碼。

struct A{A(int){}};
struct B{B(int){}};
struct C:A,B{
using A:: A;
using B:: B;
};

A和B 的建構函式會導致C中重複定義相同型別的繼承建構函式。可以通過顯式定義繼承類的衝突的建構函式,阻止隱式生成相應的繼承建構函式來解決衝突。比如:

struct C:A,B{
using A:: A;
using B:: B;
C(int){} //其中的建構函式C(int)就很好地解決了繼承建構函式的衝突問題。(為什麼能夠解決繼承建構函式從圖的問題。)
};

如果,基類的建構函式被宣告為私有成員函式,或者派生類是從基類中虛繼承的,那麼就不能夠在派生類中宣告繼承建構函式。此外,如果一旦使用了繼承建構函式,編譯器就不會再為派生類生成預設建構函式了,程式設計師必須注意繼承建構函式沒有包含一個無引數的版本。

#include<iostream> 
using namespace std;
struct A{
    A(int){
    }
};
struct B:A{
    using A:: A;
};
    
int main(){
    B b;//B沒有預設建構函式 
    B b(3);//建構函式。 
}

委派建構函式

與繼承建構函式類似,委派建構函式也是C++11中對C++的建構函式的一項改進,其目的也是為了減少程式設計師書寫建構函式的時間。通過委派其他建構函式,多建構函式的類編寫將更加容易。
一個程式碼冗餘的例子:

#include<iostream> 
using namespace std;
class Info{
public:
Info() :type(1), name('a'){ //一次初始化,可以初始化很多變數。
    InitRest();
}
Info(int i):type(i), name('a'){
    InitRest();
}
Info(char e):type(1), name('e'){
    InitRest();
}
private:
    void InitRest(){/*其他初始化*/ 
    }
    int type;
    char name;
};
    
int main(){
    return 0;
}

在程式碼中,我們宣告瞭一個Info的自定義型別。該型別擁有2個成員變數以及3個建構函式。這裡的3個建構函式都宣告瞭初始化列表來初始化成員type和name,並且都呼叫了相同的InitRest。可以看到,除了初始化列表有的不同,而其他的部分,3個建構函式基本上是相似的,因此其程式碼存在著很多重複。
改進方法1:非靜態變數的初始化

#include<iostream> 
using namespace std;
class Info{
    public:
    Info(){
        InitRest();
    }
    Info(int i): type(i){
        InitRest();
    }
    private:
    void InitRest(){
    }
    int type{1};
    char name{'a'};
};

雖然建構函式簡單了不少,但是每個建構函式還是需要呼叫InitRest函式進行初始化。能不能在一些建構函式中連InitRest都不用呼叫呢?

我們將一個建構函式設定為"基派版本",比如本例中的Info()版本的建構函式,而其他建構函式可以通過委派"基準版本"來進行初始化。

Info(){InitRest();}
Info(int i){this->Info(); type=i;}
Info(char e){this->Info(); name=e;}

我們通過this指標呼叫我們的"基準版本"的建構函式。但是一般的編譯器都會阻止this->Info()的編譯。原則上,編譯器不允許在建構函式中呼叫建構函式,即使引數看起來並不相同。

還有一種是用placement new 來強制在本物件地址(this指標所指地址)上呼叫類的建構函式。這樣,就可以繞過編譯器的檢查,從而在2個建構函式中呼叫我們的"基準版本"。但是在已經初始化一部分的物件上再次呼叫建構函式,卻是危險的做法。

在C++11中,我們可以委派建構函式來達到期望的效果。C++11中的委派建構函式是在建構函式的初始化列表位置進行構造的、委派的。

#include<iostream> 
using namespace std;
class Info{
    public:
    Info(){
        InitRest();
    }
    Info(int i): Info(){
        type=i;
    }
    Info(char e): Info(){
        name=e;
    }
    private:
    void InitRest(){
    }
    int type{1};
    char name{'a'};
};

在 Info(int) 和 Info(char) 的初始化列表的位置,呼叫了"基準版本"的建構函式 Info() 。 這裡我們為了區分被呼叫者和呼叫者,稱在初始化列表中呼叫"基準版本"的建構函式為委派建構函式,而被呼叫的"基本版本"則為目標建構函式。在C++11中,所謂委派構造,就是指委派函式將構造的任務委派給了目標建構函式來完成這樣一種類構造的方式。

委派建構函式只能在函式體中為 type、name 等成員賦初值。 這是由於委派建構函式不能有初始化列表造成的。在C++中,建構函式不能同時"委派"和使用初始化列表,所以如果委派建構函式要給變數賦初值,初始化程式碼必須放在函式體中。比如:

struct Rule1{
int i;
Rule1(int a):i(a){}
Rule1():Rule1(40),i(1){}//無法通過編譯

Rule1的委派建構函式Rule1() 的寫法就是非法的。我們不能在初始化列表中既初始化成員,為委託其他建構函式完成構造。
(初始化列表的初始化方式總是優於建構函式完成的(實際上在編譯完成時就已經決定了))
稍微改造一下目標建構函式,使得委派建構函式依然可以在初始化列表中初始化所有成員。

class Info{
    public:
    Info():Info(1,'a'){}
    Info(int i): Info(i,'a'){}
    Info(char e): Info(1,e){}
    private:
    Info(int i,char e): type(i), name(e){/*其他初始化*/}
    int type;
    char name;
    //...
};

我們定義了一個私有的目標建構函式Info(int,char), 這個建構函式接受兩個引數,並將引數在初始化列表中初始化。由於這個目標建構函式的存在,我們可以不再需要InitRest函式了,而是將其程式碼都放入Info(int,char)中。這樣,其他委派建構函式就可以委派該目標建構函式來完成構造。

在使用委派建構函式的時候,我們建議程式設計師抽象出最為"通用"的行為做目標建構函式。這樣做一來程式碼清晰,二來行為也更加正確。由於在C++11中,目標建構函式的執行總是先於委派建構函式而造成的。因此避免目標建構函式和委派建構函式體中初始化同樣的成員通常是必要的,

在建構函式比較多的時候,我們可能會擁有不止一個委派建構函式,而一些目標建構函式很可能也是委派建構函式,這樣一來,我們就可以在委派建構函式中形成鏈狀的委派構造關係。

class Info{
public:
Info(): Info(1){} //委派建構函式
Info(int i): Info(i,'a'){} //即是目標建構函式,也是委派建構函式
Info(char e): Info(1,e){}
private:
Info(int i,char e): type(i), name(e){/*其他初始化*/}//目標建構函式
int type;
char name;
};

鏈狀委託構造,這裡我們使Info() 委託Info(int)進行構造,而Info(int)又委託Info(int,char)進行構造。在委託構造的鏈狀關係中,就是不能形成委託環。比如:

struct Rule2{
int i,c;
Rule2():Rule2(2){}
Rule2(int i):Rule2('c'){}
Rule2(char c):Rule2(2){}
};

Rule2定義中,Rule2()、Rule2(int)和Rule2(char)都依賴於別的建構函式,形成環委託構造關係。這樣的程式碼通常會導致編譯錯誤。委託構造的一個很實際的應用就是使用構造模板函式產生目標建構函式。

#include<list>
#include<vector>
#include<deque>
using namespace std;
class TDConstructed{
    template<class T>TDConstructed(T first, T last):l(first,last){} //儘可能還是多理解這個地方
    list<int> l;    
    public:
        TDConstructed(vector<short> &v):TDConstructed(v.begin(),v.end()){}
        TDConstructed(deque<int> &d):TDConstructed(d.begin(),d.end()){}
};

我們定義了一個建構函式模板。通過兩個委派建構函式的委託,建構函式模板會被例項化。T會分別被推導為 vector<short>::iterator 和 deque<int>::iterator 兩種型別。這樣一來, 我們的TDConstructed類就可以很容易地接受多種容器對其進行初始化。

(委託構造使得建構函式的泛型程式設計成為了一種可能)

在異常處理方面,如果在委派建構函式中使用try的話,那麼從目標建構函式中產生的異常,都可以在委派建構函式中被捕捉到。我們看下面的例子:

#include <iostream>
using namespace std;
class DCExcept{
    public:
        DCExcept(double d)
        try: DCExcept(1,d){
            cout<<"Run the body."<<endl;
        //其他初始化 
        }
        catch(...){
            cout<<"caught exception."<<endl;
        }
    private:
        DCExcept(int i,double d){
            cout<<"going to throw!"<endl;
            throw 0; //丟擲異常。
        }
    int type;
    double data;
};
int main(){
    DCExcept a(1.2);
}

我們在目標建構函式DCException(int,double)跑出了一個異常,並在委派建構函式DCExcept(int)中進行了捕捉。而委派建構函式的函式體部分的程式碼並沒有被執行。這樣的設計是合理的,因為如果函式體依賴於目標建構函式構造的結果,那麼當目標建構函式構造發生異常的情況下,還是不要執行委派建構函式函式體中的程式碼為好。

右值引用:移動語義和完美轉發

指標成員與拷貝構造
對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進行了初始化。通過這樣的方法,就避免了懸掛指標的困擾。

拷貝建構函式中為指標成員分配新的記憶體再進行內容拷貝的做法在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次。


ttt.jpg-132.9kB

最頭疼的就是拷貝建構函式的呼叫。在上面的程式碼上,類HasPtrMem只有一個Int型別的指標。如果HasPtrMem的指標指向非常大的堆記憶體資料的話,那麼拷貝建構函式就會非常昂貴。可以想象,一旦這樣,a的初始化表示式的執行速度非常慢。臨時變數的產生和銷燬以及拷貝的發生對於程式設計師來說基本上是透明的,不會影響程式的正常值,因而即使該問題導致程式的效能不如預期,也不易被程式設計師察覺(事實上,編譯器常常對函式返回值有專門的優化)

然後,按照C++的語義,臨時物件將在語句結束後被析構,會釋放它所包含的堆記憶體資源。而a在拷貝構造的時候,又會被分配堆記憶體。這樣意義不大,所以,考慮在臨時物件構造a的時候不分配記憶體,即不使用拷貝構造。

剩下的就是移動構造:

tt1.jpg-313.6kB

上半部分從臨時變數中拷貝構造變數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置為指標空值。

//理論上的結果:
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++中如何判斷產生了臨時物件?如何將其用於移動建構函式?是否只有臨時變數可以用於移動構造?.....

在C++98/03的語言和庫中,以及存在了一些移動語義相關的概念:

A. 智慧指標的拷貝(auto_ptr "copy")
B. 連結串列拼接(list::splice)
c. 容器內的置換(swap on containers)

這些操作都包含了從一個物件到另一個物件的資源轉移的過程,唯一欠缺的是統一的語法和語義的支援,來使我們可以使用通用的程式碼移動任意的物件。如果能夠任意地使用物件的移動,而不是拷貝,那麼標準庫中的很多地方的效能都會大大提高。

左值、右值與右值引用

在C語言中,我們常常會提起左值(lvalue)、右值(rvalue),編譯器報出的錯誤資訊裡面有時也會包含左值、右值的說法。不過,左值、右值通常不是通過一個嚴謹的定義而為人所知的,大多數時候左右值的定義與其判別方法是一體的。一個最典型的判別方法就是,在賦值表示式中,出現在等號左邊的就是"左值",而在等號右邊的,則稱為"右值"。不過C++中,有一個被廣泛認同的說法,那就是可以取值的、有名字的就是左值,反之,不能取地址的、沒有名字的就是右值。更為細緻地,在C++11中,右值是由兩個概念構成得,一個是將亡值,另一個則是純右值。

純右值就是C++98標準中右值的概念,講的是用於辨別臨時變數和一些不跟物件關聯的值。比如非引用返回的函式返回的臨時變數值,就是一個純右值。一些運算表示式,比如1+3產生的臨時變數值,也是純右值。而不跟物件關聯的字面量值,比如:2、'c'、true,也是純右值。此外,型別轉換函式的返回值、lambda表示式等,也都是右值。

而將亡值則是C++11新增的跟右值引用相關的表示式,這樣表示式通常是將要被移動的物件。比如返回右值引用T&&的函式返回值、std::move的返回值,或者轉換為T&&的型別轉換函式的返回值。而剩下的,可以標識函式、物件的值都屬於左值。在C++11的程式中,所有的值比屬於左值、將亡值、純右值三者之一。

在C++11中,右值引用就是對一個右值進行引用的型別。事實上,由於右值通常不具有名字,我們也只能通過引用的方式找到它的存在。通常情況下,我們只能是從右值表示式獲得其引用。比如:

T&&a = ReturnRvalue();

假設ReturnRvalue返回一個右值,我們就宣告瞭一個名為a的右值引用,其值等於ReturnRvalue函式返回的臨時變數的值。

右值引用和左值引用都是屬於引用型別。無論是宣告一個左值引用還是右值引用,都必須立即進行初始化。原因可以理解為是引用型別本身自己並不擁有所繫結物件的記憶體,只是該物件的一個別名。左值引用是具名變數值的別名,而右值引用則是不具名(匿名)變數的別名。(也就是說需要找一個寄主)

(問題:我可不可以理解為移動建構函式比拷貝建構函式更適合右值。所以對於對應的右值,移動建構函式更容易被匹配到。)

通常情況下,右值引用是不能夠繫結到任何的左值的。

int c
int &&d=c

相對地,在C++98標準中就已經出現的左值引用是否可以繫結到右值(由右值進行初始化)?

T&e = ReturnRvalue();
const T&f = ReturnRvalue();

這裡一共有11個特性,先只學到這裡,因為我需要先對C++先過一遍,然後,再回來看右值和強制轉換的部分。

相關文章