【C++】右值引用、移動語義、完美轉發(上篇)

在C++11,引入了右值引用的概念,在此基礎上的移動語義在STL容器中使用非常廣泛。簡單來說,move語義使得你可以用廉價的move賦值替代昂貴的copy賦值,完美轉發使得可以將傳來的任意參數轉發給其他函數。然而,這些新特性的背後是什麼深意和原理呢?將從兩篇博文中做詳細的介紹。

本文實例源碼github地址https://github.com/yngzMiao/yngzmiao-blogs/tree/master/2020Q2/20200415


左值右值基礎

左值和右值

左值是一般指表達式結束後依然存在的持久化對象,右值指表達式結束時就不再存在的臨時對象。區分左值和右值的便捷方法:能對表達式取地址、有名字的對象爲左值。反之,不能取地址、匿名的對象爲右值

表達式的值類別必屬於左值或者右值,而右值又可以分成純右值將亡值兩種:

  • 純右值(prvalue):非引用返回的臨時變量、運算表達式產生的臨時變量、原始字面量和lambda表達式等;
  • 將亡值(xvalue):與右值引用相關的表達式,通常指將要被移動的對象。如,函數返回類型爲T&&、std::move的返回值、轉換爲T&&的類型轉換函數的返回值等(注意,這些都是與右值引用相關的表達式),具體的會在下文介紹。

例如:

#include <iostream>

int getValue() {
  return 1;
}

int main(int argc, char *argv[]) 
{
  int a = 0;
  int b = getValue();

  return 0;
}

對於int a = 0,a是左值,0是原始字面量右值。對於int b = getValue(),b是左值,getVar()返回的臨時值是右值。這是由於左值a在表達式結束後仍然存在,而getValue()返回的臨時值在表達式結束後就銷燬了,同時左值a可以取地址,而getValue()的返回值卻不行。這兩者的右值都是純右值。

簡單地說,純右值就是值可以放到賦值運算符右邊的值,而且大部分寫代碼的人都有一種代碼感,一下子就能反應過來,比如要是寫成:

getValue() = 10;

將10賦值給函數返回的一個int類型的返回值,想想都不對,平時誰會這麼寫,肯定只能放在賦值運算符右邊,是右值。

左值引用和右值引用

在C++中,引用簡單點說就是對變量起了一個別名,內部原理什麼的都不是本文需要涉及的。引用可以給左值起一個別名,當然也可以給右值起一個別名。綁定左值的引用就是左值引用,綁定右值的引用就是右值引用。例如:

int i = 0;
int& j = i;               // 左值引用
int&& k = 0;              // 右值引用

在例子中,i是一個變量,可以取地址,是左值;引用j綁定左值i,那麼引用j就是左值引用。0是一個原始字面量,不可以取地址,是右值;引用k綁定右值0,那麼引用k就是右值引用。

至於爲什麼定義引用的時候用&&&,這就是語法規定,和定義指針用*一樣。當然,在定義的時候,不能用左值引用去綁定一個右值,也不能用一個右值引用去綁定一個左值。例如,下面的定義是錯誤的:

int i = 0;
int& j = 0;               // Error
int&& k = i;              // Error

總結起來就是,引用可綁定的值類型(設T是個具體類型):

  1. 左值引用(T&):只能綁定到左值(非const左值)
  2. 右值引用(T&&):只能綁定到右值(非const右值)
  3. 常量左值引用(const T&):常量左值引用是個萬能的引用類型。它既可以綁定到左值也可以綁定到右值。它像右值引用一樣可以延長右值的生命期。不過相比於右值引用所引用的右值,常量左值引用的右值在它的餘生中只能是隻讀的
  4. 常量右值引用(const T&&):可綁定到右值或const右值。一般很少使用,基本沒有實際用處。

左值引用沒什麼可講的,不是C++11的新內容。下面主要對右值引用進行講解,例如:

int&& k = getValue();

對於getValue()產生的臨時值,如果不是被右值引用k綁定,在表達式結束之後就銷燬了。但,既然被右值引用k綁定後,getValue()產生的臨時值會被續命,它的生命週期將會通過右值引用得以延續,和變量k的聲明週期一樣長。

因此,通過右值引用的聲明,右值又重獲新生,其生命週期與右值引用類型變量的生命週期一樣長,只要該變量還活着,該右值臨時量將會一直存活下去

萬能引用

上文講到,當T是一個具體的類型時,T&&表示右值引用,只能綁定右值

但是,若T&&在發生自動類型推斷的時候,它是未定的引用類型,如果被一個左值初始化,它就是一個左值引用;如果它被一個右值初始化,它就是一個右值引用,它是左值引用還是右值引用取決於它的初始化。因此,也被稱爲萬能引用

需要注意,T必須是使用在函數模板形參,且必須發生在類型推導的過程中。例如:

#include <iostream>

template<typename T>
void fun(T&& t) {}

int main(int argc, char *argv[]) 
{
  int x = 10;
  fun(10);                // t是右值
  fun(x);                 // t是左值

  return 0;
}

爲什麼萬能引用是萬能的呢?先理解一下引用摺疊的概念:

在C++中,引用的引用是非法的。比如:auto& &rx = x(注意兩個&之間有空格)這種直接定義引用的引用是不合法的,但是編譯器在通過類型別名或模板參數推導等語境中,會間接定義出引用的引用,這時引用會形成摺疊

注意的是:引用摺疊只會發生在模板實例化、auto類型推導、創建和運用typedef和別名聲明、以及decltype語境中。具體規則爲:

  • 所有右值引用摺疊到右值引用上仍然是一個右值引用。如T&& &&摺疊爲T&&。
  • 所有的其他引用類型之間的摺疊都將變成左值引用。如T& &, T& &&, T&& &摺疊爲T&。可見左值引用會傳染,沾上一個左值引用就變左值引用了。根本原因:在一處聲明爲左值,就說明該對象爲持久對象,編譯器就必須保證此對象可靠(左值)

再次強調,引用摺疊發生在模板實例化的過程中!

fun(10);                // int&& &&,推導出右值引用
fun(x);                 // int& &&,推導出左值引用

這裏就可以用引用摺疊解釋這一點:

  • 當萬能引用(T&&)綁定到左值時,T會被推導爲T&類型。從而參數類型爲T& &&,引用摺疊後的類型爲T&,左值引用;
  • 當萬能引用(T&&)綁定到右值時,T會被推導爲T&&類型。從而參數類型爲T&& &&,引用摺疊後的類型爲T&&,右值引用。

萬能引用就是利用模板推導和引用摺疊的相關規則,生成不同的實例化模板來接收傳進來的參數


移動語義

移動語義的由來

在C++的學習過程中,知道如果是一個帶有堆內存的類,必須提供一個深拷貝拷貝構造函數,因爲默認的拷貝構造函數是淺拷貝,會發生指針懸掛的問題。例如:

#include <iostream>
#include <string.h>

class MyStringNoDeep {
  private:
    char * _data;
    size_t _len;
    void _init_data(const char* s) {
      _data = new char[_len + 1];
      memcpy(_data, s, _len);
      _data[_len] = '\0';
    }
  
  public:
    MyStringNoDeep() : _data(NULL), _len(0) {}
    MyStringNoDeep(const char* p) : _len(strlen(p)) {
      _init_data(p);
    }

    virtual ~MyStringNoDeep() {
      delete _data;
    }

    char* get() const {return _data;}
};    

int main(int argc, char *argv[]) 
{
  char* buf = "Hello World";
  MyStringNoDeep s = MyStringNoDeep(buf);
  std::cout << s.get() << std::endl;

  return 0;
}

編譯並運行上面的代碼,會發現報錯。原因是:內部的data指針將會被刪除兩次,一次是臨時右值MyStringNoDeep(buf)析構的時候刪除一次,第二次拷貝構造函數生成的s對象釋放時刪除一次,而這兩個對象的data指向同一塊內存地址,這就是所謂的指針懸掛問題。

如果沒有報錯,是因爲需要增加編譯選項-fno-elide-constructors。這是因爲編譯器會進行RVO優化,RVO(C++的返回值優化)是指:C++標準允許一種(編譯器)實現省略創建一個只是爲了初始化另一個同類型對象的臨時對象。基本手段是直接將返回的對象構造在調用者棧幀上,這樣調用者就可以直接訪問這個對象而不必複製。如此就只要調用一次析構函數,就不會有問題了。

關於RVO的問題,可以查看博文:【C++】C++函數需要有返回值,但非全分支return(RVO)

此時添加上深拷貝的拷貝構造函數,即可避免這個問題:

#include <iostream>
#include <string.h>

class MyStringWithDeep {
  private:
    char * _data;
    size_t _len;
    void _init_data(const char* s) {
      _data = new char[_len + 1];
      memcpy(_data, s, _len);
      _data[_len] = '\0';
    }
  
  public:
    MyStringWithDeep() : _data(NULL), _len(0) {}
    MyStringWithDeep(const char* p) : _len(strlen(p)) {
      _init_data(p);
    }

    MyStringWithDeep(const MyStringWithDeep& str) : _len(str._len) {
      _init_data(str._data);
    }

    virtual ~MyStringWithDeep() {
      delete _data;
    }

    char* get() const {return _data;}
};

int main(int argc, char *argv[]) 
{
  char* buf = "Hello World";
  MyStringWithDeep s = MyStringWithDeep(buf);
  std::cout << s.get() << std::endl;

  return 0;
}

提供深拷貝的拷貝構造函數雖然可以保證正確,但是在有些時候會造成額外的性能損耗,因爲有時候這種深拷貝是不必要的。

比如:MyStringWithDeep(buf)會返回臨時變量,然後通過這個臨時變量拷貝構造了一個新的對象s,臨時變量在拷貝構造完成之後就銷燬了,如果堆內存很大的話,那麼,這個拷貝構造的代價會很大,帶來了額外的性能損失。每次都會產生臨時變量並造成額外的性能損失,有沒有辦法避免臨時變量造成的性能損失呢?答案是肯定的,C++11已經有了解決方法。

其實避免性能損失的思路很簡單,既然臨時變量就已經有了,爲什麼一定總要拷貝構造一個新的對象s,而不能把臨時變量的生命週期變長,直接拿這個臨時變量呢?這是不是立即就想到了上面的右值引用的特性

移動語義

#include <iostream>
#include <string.h>

class MyString {
  private:
    char * _data;
    size_t _len;
    void _init_data(const char* s) {
      _data = new char[_len + 1];
      memcpy(_data, s, _len);
      _data[_len] = '\0';
    }
  
  public:
    MyString() : _data(NULL), _len(0) {}
    MyString(const char* p) : _len(strlen(p)) {
      _init_data(p);
    }

    MyString(const MyString& str) : _len(str._len) {
      std::cout << "MyString(&)" << std::endl;
      _init_data(str._data);
    }
    MyString(MyString&& str) noexcept
      : _data(str._data), _len(str._len) {
      std::cout << "MyString(&&)" << std::endl;
      str._len = 0;
      str._data = NULL;
    }

    virtual ~MyString() {
      if(_data)
        delete _data;
    }

    char* get() const {return _data;}
};

int main(int argc, char *argv[]) 
{
  char* buf = "Hello World";
  MyString s = MyString(buf);
  std::cout << s.get() << std::endl;

  return 0;
}

編譯並運行這段代碼,打印結果爲:

yngzmiao@yngzmiao-virtual-machine:~/test$ ./main 
MyString(&&)
Hello World

觀察代碼的區別,發現只多了一個接收右值引用的構造函數(稱之爲移動構造函數)。根據上文講到的引用可綁定的值類型,MyString(buf)屬於右值,MyString(const MyString& str)MyString(MyString&& str),兩個構造函數都可以接收右值。從輸出的結果表明,並沒有調用拷貝構造函數,而是調用了移動構造函數。

這就是所謂的移動語義,右值引用的一個重要作用是用來支持移動語義的。

移動構造函數的寫法

觀察一下上文移動構造函數的寫法:

MyString(MyString&& str) noexcept
  : _data(str._data), _len(str._len) {
  std::cout << "MyString(&&)" << std::endl;
  str._len = 0;
  str._data = NULL;
}

可以發現移動構造函數並沒有做深拷貝,僅僅是將指針的所有者轉移到了另外一個對象,同時,將參數對象str的指針置爲空。這裏僅僅是做了淺拷貝,因此,這個構造函數避免了臨時變量的深拷貝問題,從而解決了前面提到的臨時變量拷貝構造產生的性能損失的問題。

關於移動構造函數和拷貝構造函數的區別,如下圖:

  • 拷貝構造函數:將SrcObj對象拷貝到DestObj對象,需要同時將Resourse資源也拷貝到DestObj對象去。這涉及到內存的拷貝。
  • 移動構造函數:通過偷內存的方式,將資源的所有權從一個對象轉移到另一個對象上。但只是轉移,並沒有內存的拷貝。可見Resource的所有權只是從SrcObj對象轉移到DestObj對象,由於不存在內存拷貝,其效率一般要高於複製構造。

需要注意的一個細節是,提供移動構造函數的同時也會提供一個拷貝構造函數,以防止移動不成功的時候還能拷貝構造,使代碼更安全。

移動語義是通過右值引用來匹配臨時值的,那麼,普通的左值是否也能借助移動語義來優化性能呢,那該怎麼做呢?事實上C++11爲了解決這個問題,提供了std::move方法來將左值轉換爲右值,從而方便應用移動語義。即:

char* buf = "Hello World";
MyString s0(buf);
MyString s1(s0);                        // MyString(&)
MyString s2(std::move(s0));             // MyString(&&)
MyString s3 = MyString(buf);            // MyString(&&)

如果不用std::move,會調用拷貝構造函數,而使用std::move幾乎沒有任何代價,只是轉換了資源的所有權。它實際上將左值變成右值引用,然後應用移動語義,調用移動構造函數,就避免了拷貝,提高了程序性能。如果一個對象內部有較大的對內存或者動態數組時,很有必要寫move語義的拷貝構造函數和賦值函數,避免無謂的深拷貝,以提高性能。事實上,C++11中所有的容器都實現了移動語義,方便性能優化。

這裏也要注意對move語義的誤解,move實際上它並不能移動任何東西,它唯一的功能是將一個左值強制轉換爲一個右值引用,繼而用於移動語義。std::move的原型爲:

template<typename T>
decltype(auto) move(T&& param)          //注意,形參是個引用(萬能引用)
{
  using ReturnType = typename remove_reference<T>::type&&;      //去除T自身可能攜帶的引用
  return static_cast<ReturnType>(param);          //強制轉換爲右值引用類型
}

移動構造函數的注意點:

  1. 移動語義一定是要修改臨時對象的值,所以聲明移動構造時應該形如Test(Test&&),而不能聲明爲Test(const Test&&)
  2. 默認的移動構造函數實際上跟默認的拷貝構造函數一樣,都是淺拷貝。通常情況下,必須自定義移動構造函數;
  3. 對於移動構造函數來說,拋出異常是很危險的。因爲移動語義還沒完成,一個異常就拋出來,可能會造成懸掛指針。因此,應儘量通過noexcept聲明不拋出異常,而一旦出現異常就可以直接調用std::terminate終止程序

解釋一下第三點:比如在標準庫一些容器操作提供了強異常安全保證,爲了兼容C++98的遺留代碼在升級到C++11時仍保證正確性。庫中用std::move_if_noexcept模板來替代move函數。該函數在類的移動構造函數沒有聲明noxcept關鍵字時返回一個左值引用從而使變量通過拷貝語義,而在移動構造函數有noexcept時返回一個右值引用,從而使變量可以使用移動語義。移動操作未加noexcept時,編譯器仍會強制調用一個複製操作。


相關閱讀

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章