天天看點

C++:31---對象引用和指派

一、對象移動概述

  • C++11标準引入了“對象移動”的概念
  • 對象移動的特性是:可以移動而非拷貝對象
  • 在C++舊标準中,沒有直接的方法移動對象。是以會有很多不必要的資源拷貝
  • 标準庫容器、string、share_ptr類既支援移動也支援拷貝。IO類和unique_ptr類可以移動但不能拷貝

對象移動的特點

  • 在很多情況下會發生對象拷貝的現象,對象拷貝之後就被銷毀了,在這種情況下,對象移動而非對象拷貝會大幅度提升性能
  • 使用移動而非拷貝的另一個原因是:類似于IO類或unique_ptr這樣的類,這些類都不能被共享資源(如指針或IO緩沖)。是以,這些類型的對象不能拷貝但可以移動

二、右值引用(&&)

  • 為了支援移動操作,C++11标準引入了新的引用類型——右值引用
  • 所謂右值引用就是必須綁定到右值的引用。我們通過&&而不是&來獲得右值引用
  • 右值有一個很重要的性質:隻能綁定到一個将要銷毀的對象

左值引用(&)

  • “引用(reference)”類型介紹參閱:
  • 為了與“右值引用”差別開來,我們本篇文章中将“”引用(reference)稱之為“左值引用”

右值引用的使用方法

  • 左值引用:
  • 不能将其綁定到要求“轉換的表達式、字面值常量、傳回右值的表達式”
  • 傳回左值的函數,連同指派、下标、解引用和前置遞增/遞減運算符,都是傳回左值的表達式。我們可以将一個左值引用綁定到這類表達式的結果上
  • 右值引用:
  • 則與左值引用相反,我們可以将一個右值引用到上面所述的表達式上,但是不能将一個右值引用直接綁定到一個左值上
  • 傳回非引用類型的函數,連同算術、關系、位以及後置遞增運算符,都生成右值。我們可以将一個const的左值引用或一個右值引用綁定到這類表達式上
  • 見下面的使用方法:
  1. int i = 42;
  2. int &r = i; //正确,r引用i
  3. int &&rr = i; //錯誤,不能将一個右值引用到左值上
  4. int &r2 = i * 42; //錯誤,i*42是一個右值
  5. const int &r3 = i * 42;//正确,我們可以将一個const的引用綁定到一個右值上
  6. int &&rr2 = i * 42; //正确,将rr2綁定到乘法結果上(右值)
  1. int ret(int i) {
  2. return i * 2;
  3. }
  4. int& ret2(int& i) {
  5. return i;
  6. }
  7. int &r = ret(1); //錯誤
  8. int &&rr = ret(1); //正确
  9. int &r2 = ret2(1); //正确
  10. int &&rr2 = ret2(1); //錯誤

左值持久、右值短暫

  • 左值一般是綁定到對象身上,是以左值是持久的
  • 而右值要麼綁定在字面值常量、要麼綁定到表達式求值過程中建立的臨時對象身上,是以:
  • 右值引用所引用的對象将要被銷毀
  • 該對象沒有其他使用者
  • 這兩個特性意味着,使用右值引用的代碼可以自由地接管所引用的對象的資源

變量是左值

  • 變量可以看做隻有一個運算對象而沒有運算符的表達式。是以不能将一個右值引用綁定到一個右值引用類型的變量上
  • 如下:
  1. int &&rr1 = 42; //正确,42是字面值
  2. int &&rr2 = rr1; //錯誤,表達式rr1是左值

标準庫move()函數

  • 雖然不能将一個右值引用綁定到一個左值上,但是我們可以顯式地将一個左值轉換成對應的右值引用類型
  • move函數就是實作上面的功能,move函數用來獲得綁定到左值上的右值引用
  • 此函數定義在頭檔案<utility>中
  1. int &&rr1 = 42; //正确,42是字面值
  2. int &&rr2 = std::move(rr1); //正确了

三、移動構造函數和移動指派運算符

  • 與string一樣,我們自己的類也支援移動和拷貝。為了支援移動,我們需要自己定義移動構造函數與移動指派運算符
  • 下面是一個類的定義,用來作為下面講解的基礎
  1. class StrVec
  2. {
  3. public:
  4. StrVec()
  5. :elements(nullptr), first_free(nullptr), cap(nullptr) {}
  6. ~StrVec()
  7. {
  8. if (elements) { //如果數組不為空
  9. //釋放記憶體
  10. }
  11. }
  12. private:
  13. std::string *elements; //指向數組首元素的指針
  14. std::string *first_free;//指向數組第一個空閑元素的指針
  15. std::string *cap; //指向數組尾後位置的指針
  16. };

移動構造函數

  • 格式如下:
  • 參數為“&&”類型,因為是移動操作
  • 參數不必設定為const,因為需要改變
  • 在構造函數後添加“noexcept”關鍵字,確定移動構造函數不會抛出異常
  • 針對上面的StrVec類,其移動構造函數的定義如下:
  • noexcept確定移動構造函數不會抛出異常
  • 在參數初始化清單中将參數s的資源移動給自己(先執行)
  • 然後在函數體内釋放參數s的資源,這樣之後就達到了資源移動的目的(後執行)
  1. StrVec(StrVec &&s) noexcept
  2. :elements(s.elements),first_free(s.first_free),cap(s.cap)
  3. {
  4. s.elements = s.first_free = s.cap = nullptr;
  5. }
  • 幾點需要注意:
  • 移動構造函數不配置設定任何記憶體,隻是簡單的資源移動而已
  • 參數s在資源移動之後,其對象還是存在的。當s被銷毀時,其會執行析構函數,從上面StrVec的析構函數可以看出我們将elements設定為nullptr之後,析構函數就不會釋放資源了(因為資源是被移動了,不應該被釋放)

移動指派運算符

  • 格式如下:
  • 參數為“&&”類型,因為是移動操作
  • 參數不必設定為const,因為需要改變
  • 在函數後添加“noexcept”關鍵字,確定移動指派運算符函數不會抛出異常
  • 與拷貝指派運算符一樣,函數傳回自身引用
  • 在函數執行前,應該檢測自我指派的情況
  • 針對上面的StrVec類,其移動指派運算符函數的定義如下:
  • noexcept確定函數不會抛出異常
  • 函數執行之前先判斷一下是否為自我指派
  • 先釋放自身資源,再拷貝參數rhs的資源,最後再将rhs置為空
  1. StrVec& operator=(StrVec &&rhs)
  2. {
  3. //檢測自我指派,不能寫成*this != s
  4. if (this != &rhs) {
  5. if (this->elements) {
  6. //釋放自身的資源
  7. }
  8. //開始接管參數的資源
  9. elements = rhs.elements;
  10. first_free = rhs.first_free;
  11. cap = rhs.cap;
  12. //将參數置為空
  13. rhs.elements = rhs.first_free = rhs.cap = nullptr;
  14. }
  15. return *this;
  16. }
  • 為什麼需要檢測自我指派:
  • 我們知道,右值引用隻能綁定到一個右值身上,不能綁定到一個對象身上,是以照理說移動指派運算符不會運用于對象身上,是以檢測自我指派照理說可以取消。但是注意,我們上面介紹的move()函數,可以顯式地将一個左值轉換成對應的右值引用類型,是以參數可能是move()調用傳回的結果,是以我們需要在函數運作前檢測自我指派

四、移動後,對象仍是有效、可析構的

  • 從移動操作可以看出,一個對象(在此稱為“源對象”)在被移動之後,源對象仍然保持有效,是以這個對象在操作完成之後仍然可以被銷毀

五、合成的移動操作

  • “合成”意為“預設的”
  • 對于移動操作,編譯器的規則如下:
  • 如果一個類定義了自己的拷貝構造函數、拷貝指派運算符或者析構函數,編譯器不會為自己合成移動構造函數和移動指派運算符
  • 隻有當一個類沒有定義任何自己版本的拷貝控制成員,且類的每個非static資料成員都可以移動時,編譯器才會為自己合成移動構造函數或移動指派運算符(附加:編譯器可以移動内置類型成員。如果一個成員是類類型,且該類有對應的移動操作,編譯器也能移動這個成員)

示範案例

//編譯器會為X和hasX合成移動操作
struct X {
int i;        //内置類型可以移動
std::string s;//string定義了自己的移動操作
};
struct hasX {
X mem; //X有合成的移動操作
};
int main()
{
X x;
X x2 = std::move(x);      //使用合成的移動構造函數
hasX hx;
hasX hx2 = std::move(hx); //使用合成的移動構造函數
return 0;
}      

六、删除的移動操作

  • 對于删除的移動操作有如下規則:
  • 與拷貝操作不同,移動操作永遠不會隐式定義為删除的(=delete)函數。
  • 是,如果我們顯示地要求編譯器生成=default的移動操作,且編譯器不能移動所有成員,則編譯器會将移動操作定義為删除的函數
  • 何時将合成的移動操作定義為删除的函數遵循與定義删除合成的拷貝操作類似的原則:
  • ①與拷貝構造函數不同,移動構造函數被定義為删除的函數的條件是:有類成員定義了自己的拷貝構造函數且未定義移動構造函數,或者是有類成員未定義自己的拷貝構造函數且編譯器不能為其合成移動構造函數(移動指派運算符的情況類似)
  • ②如果有類成員的移動構造函數或移動指派運算符被定義為删除的或是不可通路的,則類的移動構造函數或移動指派運算符被定義為删除的
  • ③類似拷貝構造函數,如果類的析構函數被定義為删除的或不可通路的,則類的移動構造函數被定義為删除的
  • ④類似拷貝指派運算符,如果有類成員是const的或是引用,則類的移動指派運算符被定義為删除的
  • 移動操作和合成的拷貝控制成員之間還有最後一個關系:
  • 一個類是否定義自己的移動操作對拷貝構造函數如何合成有影響
  • 如果類定義了一個移動構造函數和/或一個移動指派運算符,則該類的合成拷貝構造函數和拷貝指派運算符是被定義為删除的
  • 總結:定義了一個移動構造函數或移動指派運算符的類必須定義自己的拷貝操作。否則,這些成員預設地被定義為删除的

示範案例

//假設Y是一個類,且Y定義了自己的拷貝構造函數但未定義自己的移動構造函數
struct hasY {
hasY() = default;
hasY(hasY &&) = default;
Y mem;
};
int main()
{
hasY hy;
hasY hy2 = std::move(hy); //錯誤,移動構造函數是删除的
return 0;
}      

示範案例

//StrVec隻定義了移動構造函數與移動指派運算符,但是沒有定義拷貝構造函數與拷貝指派運算符
class StrVec
{
//...
public:
StrVec(StrVec &&s)noexcept{}
StrVec& operator=(StrVec &&rhs){}
//...
};
int main()
{
StrVec v1, v2;
v1 = v2;  //錯誤,SreVec的拷貝指派運算符被定義為删除的
return 0;
}      

七、移動右值、拷貝左值

  • 如果類既有“”移動構造函數,也有“拷貝構造函數”,編譯器使用普遍的函數比對機制來缺點使用哪個構造函數

示範案例

//假設SreVec的拷貝構造函數/拷貝指派運算符,移動構造函數/移動拷貝指派運算符都定義了
class StrVec{};
StrVec getVec(istream &)
{
//該函數傳回一個SreVec對象(右值)  
}
int main()
{
StrVec v1, v2;
v1 = v2;          //v2是個左值,此處調用拷貝指派運算符
v2 = getVec(cin); //getVec函數傳回一個右值,此處調用移動指派運算符
return 0;
}      

如果沒有定義移動構造函數,右值也被拷貝

  • 如果一個類有一個拷貝構造函數但未定義移動構造函數,那麼:
  • 因為類有了拷貝構造函數,編譯器不會合成移動構造函數
  • 是以,對于右值的移動操作時調用拷貝構造函數的
  • 上面的規則也适用于拷貝指派運算符
  • 示範案例:
class Foo {
public:
Foo() = default;
Foo(const Foo&); //拷貝構造函數
//未定義移動構造函數
};
int main()
{
Foo x;
Foo y(x);           //調用拷貝構造函數
Foo z(std::move(x));//因為Foo沒有定義移動構造函數,是以此處調用的是拷貝構造函數
return 0;
}      
  • 使用拷貝構造函數代替移動構造函數幾乎肯定是安全的(指派運算符情況類似)。一般情況下,拷貝構造函數滿足對應的移動構造函數的要求:它會拷貝給定對象,并将源對象置于有效狀态。實際上,拷貝構造函數甚至都不會改變源對象的值

八、拷貝并交換指派運算符和移動操作

class HasPtr {
public:
HasPtr(const std::string &s = std::string())
:ps(new std::string(s)), i(0) {}
HasPtr(const HasPtr& p)  //拷貝構造函數
:ps(new std::string(*(p.ps))), i(p.i) {}
~HasPtr() { delete ps; }
friend void swap(HasPtr&, HasPtr&);
private:
std::string *ps;
int i;
};
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
swap(lhs.ps, lhs.ps);//交換指針,而不是string資料
swap(lhs.i, lhs.i);  //交換int成員
}      
  • 現在我們為HasPtr類添加了一個移動構造函數和一個指派運算符(這個指派運算符比較特殊)
class HasPtr {
public:
//其他内容同上
//移動構造函數
HasPtr(HasPtr &&p)noexcept :ps(p.ps), i(p.i) { p.ps = 0; }
//這個指派運算符即是移動指派運算符,也是拷貝指派運算符
HasPtr& operator=(HasPtr rhs)
{
swap(*this,rhs);
return *this;
}
};      

移動構造函數

  • 移動構造函數接管了給定實參的值、函數體内将p的指針置為0,進而確定銷毀源對象是安全的
  • 此函數不會抛出異常,是以将其标記為noexcept

指派運算符

  • 此處定義的指派運算符的參數不是引用形式,意味着此參數要進行拷貝初始化
  • 依賴實參的類型,拷貝初始化:
  • 要麼使用拷貝構造函數——左值被拷貝
  • 要麼使用移動構造函數——右值被移動
  • 是以,此處定義的指派運算符就實作了拷貝指派運算符和移動指派運運算符的兩種功能
  • 例如:
  • 第一個指派中,右側對象hp2是一個左值,是以使用拷貝構造函數來初始化
  • 第二個指派中,我們調用std::move()将将一個右值綁定到hp2上。此種情況下,拷貝構造函數和移動構造函數都是可以的。但是由于實參是一個右值引用,移動構造函數時精确比對的
  1. HasPtr hp;
  2. HasPtr hp2;
  3. //hp2是一個左值。是以先調用拷貝構造函數複制一份HasPtr對象給operator=參數
  4. //再調用operator=函數将hp2指派給hp
  5. hp = hp2;
  6. //此處hp2顯式成為一個右值。是以先調用移動構造函數構造一份HasPtr對象給operator=參數
  7. //再調用operator=函數将hp2指派給hp
  8. hp = std::move(hp2);
  • 不管使用的是拷貝構造函數還是移動構造函數,指派運算符的函數體内都swap兩個對象的狀态。交換HasPtr回交換兩個對象的指針(及int)成員。在swap之後,rhs中的指針将指向原來左側對象所擁有的string(及int)。當rhs離開作用域後,這個對象将會銷毀

九、右值引用和成員函數

  • 除了構造函數和指派運算符之外,成員函數也可能提供兩個版本:一個提供拷貝,另一份通過移動
  • 一份提供拷貝:參數為const&
  • 一份提供移動:參數為非const&&
  • 使用規則:
  • 對于拷貝版本:我們可以将任何類型的對象傳遞給該版本
  • 對于移動版本:隻能傳遞給其非const的右值
  • 一般來說,我們不需要為函數定義接受一個const T&&或是一個(普通的)T&參數的版本。當我們希望從實參“竊取”資料時,通常傳遞一個右值引用。為了達到這個目的,實參不能使const的。類似的,從一個對象進行拷貝的操作不應該改變該對象,是以,通常不需要定義一個接受(普通的)T&參數的拷貝版本

示範案例

  • 對于push_back的标準庫容器提供兩個版本:
  • 一個版本有一個右值引用
  • 另一個版本有一個const左值引用
  • 例如:
  1. void push_(const X&); //拷貝版本
  2. void push_(X&&); //移動版本

示範案例

  • 作為更好的例子,我們将StrVec類進行修改,在其中添加了兩個push_back()函數
  1. class StrVec
  2. {
  3. public:
  4. //其他同上
  5. void push_back(const std::string&);//拷貝元素
  6. void push_back(std::string&&); //移動元素
  7. private:
  8. static std::allocator<std::string> alloc; //配置設定元素
  9. //其他同上
  10. };
  11. void StrVec::push_back(const std::string& s)
  12. {
  13. chk_n_alloc(); //自定義函數,用來檢測是否空間足夠
  14. //在first_free指向的元素中構造s的一個副本,此處construct會調用string的構造函數來構造新元素
  15. alloc.construct(first_free++, s);
  16. }
  17. void StrVec::push_back(std::string&&)
  18. {
  19. chk_n_alloc();
  20. //此處由于參數為std::move()類型,是以construct會調用string的移動構造函數來構造新元素
  21. alloc.construct(first_free++,std::move(s));
  22. }
  • 當我們調用push_back()時,實參類型決定了新元素是拷貝還是移動到容器中:
  1. StrVec vec;
  2. string s = "some string or another";
  3. vec.push_back(s); //s為左值,因為調用push_back(const string&)
  4. vec.push_back("done"); //調用push_back(string&&)

十、右值和左值引用成員函數(引用限定函數)

  • 通常,我們在一個對象上調用成員函數,而不管該對象是一個左值還是一個右值:例如:
  1. string s1 = "a value", s2 = "another";
  2. //s1+s2是一個右值
  3. auto n = (s1 + s2).find('a');
  • 有時候,右值的使用還可能是下面的奇怪形式 
  1. string s1 = "a value", s2 = "another";
  2. s1 + s2 = "wow"; //s1+s2是一個右值,我們此處對一個右值進行了指派(無意義)
  • 在舊标準中,我們沒有辦法阻止這種使用方式。為了維持向後相容性,新标準庫類仍然允許向右值指派。但是,我們可以在自己的類中阻止這種辦法。在此情況下,我們希望強制左側運算對象是一個左值
  • 使用方法:
  • 在參數清單後放置一個引用限定符
  • 引用限定符可以是&或&&,分别該函數可以運用于一個左值對象(&)還是一個右值對象(&&)
  • 與const關鍵字一樣,引用限定符隻能作用于(非static)成員函數,且在聲明和定義時都需要
  • 引用限定符可以和const一起使用,且const必須在限定符的前面。例如:
  1. class Foo {
  2. public:
  3. Foo someMem()&const; //錯誤,const必須在&&前面
  4. Foo anotherMem()const&;//正确
  5. };

示範案例

  1. class Foo {
  2. public:
  3. //此參數後面有一個&,是以這個函數隻能被一個左值對象調用
  4. Foo &operator=(const Foo&)&;
  5. };
  6. Foo &Foo::operator=(const Foo& rhs)&
  7. {
  8. //執行将rhs賦予本對象的操作(代碼省略)
  9. return *this;
  10. }
  • 在上面我們在operatror=的後面添加了一個&,是以operatror=隻能運用于一個左值,見下面的代碼
  1. Foo& retFoo()
  2. {
  3. //一個函數,傳回Foo類,傳回左值(引用)
  4. }
  5. Foo retVal()
  6. {
  7. //一個函數,傳回Foo類,傳回右值
  8. }
  9. int main()
  10. {
  11. Foo i, j;
  12. i = j;
  13. retFoo() = j; //正确,retFoo()傳回一個左值
  14. retVal() = j; //錯誤,retVal()傳回一個右值
  15. i = retFoo(); //正确,我們可以将一個左值作為指派操作的右側運算對象
  16. i = retVal(); //正确,我們可以将一個右值作為指派操作的右側運算對象
  17. return 0;
  18. }

示範案例②

  1. class Foo {
  2. public:
  3. void push_back() && {} //這個函數隻能被一個右值Foo對象調用
  4. };
  5. Foo& retFoo()
  6. {
  7. //一個函數,傳回Foo類,傳回左值(引用)
  8. }
  9. Foo retVal()
  10. {
  11. //一個函數,傳回Foo類,傳回右值
  12. }
  13. int main()
  14. {
  15. Foo i;
  16. i.push_back(); //錯誤,i是一個左值
  17. retFoo().push_back(); //錯誤
  18. retVal().push_back(); //錯誤(這個沒搞懂,retVal應該傳回右值的啊,但是編譯器報錯)
  19. std::move(retVal()).push_back();//正确
  20. return 0;
  21. }

示範案例

  1. class Foo {
  2. public:
  3. //此函數隻可以用于右值
  4. Foo sorted() && {
  5. std::sort(data.begin(), data.end());
  6. return *this;
  7. }
  8. //此函數可以用于左值或const類型的右值(因為其帶有const,見下面的重載介紹)
  9. Foo sorted()const & {
  10. Foo ret(*this); //拷貝一個副本
  11. std::sort(ret.data.begin(), ret.data.end()); //排序副本
  12. return ret; //傳回結果
  13. }
  14. private:
  15. std::vector<int> data;
  16. };
  17. Foo& retFoo()
  18. {
  19. //一個函數,傳回Foo類,傳回左值(引用)
  20. }
  21. Foo retVal()
  22. {
  23. //一個函數,傳回Foo類,傳回右值
  24. }
  25. const Foo retVal2()
  26. {
  27. //一個函數,傳回Foo類,傳回右值,且為const
  28. }
  29. int main()
  30. {
  31. retFoo().sorted(); //調用sorted()const&
  32. retVal().sorted(); //調用sorted() &&
  33. retVal2().sorted();//調用sorted()const&
  34. return 0;
  35. }
  • 對于sorted() &&:
  • 如果對象是一個右值,意味着沒有其他使用者,是以我們可以在函數内改變對象的内容
  • 對于sorted()const&:

十一、重載和引用函數

  • const成員函數重載時,可以定義兩個版本:一個有const、一個沒有const
class Foo {
public:
//下面兩者形成重載
Foo sorted();
Foo sorted()const;
};      
  • 引用限定函數規則不一樣:重載時必須兩者都加上引用限定符
  1. class Foo {
  2. public:
  3. Foo sorted()&&;
  4. Foo sorted()const&&; //正确,與上面形成重載
  5. //Foo sorted()const; 這個是錯誤的
  6. };
  • 附加:如果一個成員函數有引用限定符,則具有相同參數清單的所有版本都必須有引用限定符

繼續閱讀