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

上篇中,主要講解了右值引用和移動語義的具體定義和用法。在C++11中幾乎所有的容器都實現了移動語義,以方便性能優化。本文以C++11容器中的insert方法爲例,詳細講解在容器中移動語義是如何提高性能的,同時,在這個過程中STL又解決了什麼問題。

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


測試性能

MyString類和MyStringNoMove類

創建兩個類,其中MyString類提供了拷貝構造函數、移動構造函數,而MyStringNoMove類只提供了拷貝構造函數,並沒有提供移動構造函數。

同時,設置一系列靜態成員函數用於記錄各構造函數、運算符重載函數、析構函數的調用次數。

class MyString {
  public:
    static size_t DCtor;            // 默認構造函數
    static size_t Ctor;             // 構造函數
    static size_t CCtor;            // 拷貝構造函數
    static size_t CAsgn;            // 拷貝賦值運算符重載
    static size_t MCtor;            // move構造
    static size_t MAsgn;            // move賦值運算符重載
    static size_t Dtor;             // 析構函數
  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) {++DCtor;}
    MyString(const char* p) : _len(strlen(p)) {
      ++Ctor;
      _init_data(p);
    }

    MyString(const MyString& str) : _len(str._len) {
      ++CCtor;
      _init_data(str._data);
    }
    MyString(MyString&& str) noexcept
      : _data(str._data), _len(str._len) {
        ++MCtor;
      str._len = 0;
      str._data = NULL;
    }

    MyString& operator= (const MyString& str) {
      ++CAsgn;
      if(_data != str._data) {
        if(_data)
          delete _data;
        _len = str._len;
        _init_data(str._data);
      }
      return *this;
    }
    MyString& operator= (MyString&& str) noexcept {
      ++MAsgn;
      if(_data != str._data) {
        if(_data)
          delete _data;
        _len = str._len;
        _data = str._data;
        str._len = 0;
        str._data = NULL;
      }
      return *this;
    }

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

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

size_t MyString::DCtor = 0;
size_t MyString::Ctor = 0;
size_t MyString::CCtor = 0;
size_t MyString::CAsgn = 0;
size_t MyString::MCtor = 0;
size_t MyString::MAsgn = 0;
size_t MyString::Dtor = 0;

class MyStringNoMove {
  public:
    static size_t DCtor;            // 默認構造函數
    static size_t Ctor;             // 構造函數
    static size_t CCtor;            // 拷貝構造函數
    static size_t CAsgn;            // 拷貝賦值
    static size_t MCtor;            // move構造
    static size_t MAsgn;            // move賦值
    static size_t Dtor;             // 析構函數
  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:
    MyStringNoMove() : _data(NULL), _len(0) {++DCtor;}
    MyStringNoMove(const char* p) : _len(strlen(p)) {
      ++Ctor;
      _init_data(p);
    }

    MyStringNoMove(const MyStringNoMove& str) : _len(str._len) {
      ++CCtor;
      _init_data(str._data);
    }

    MyStringNoMove& operator= (const MyStringNoMove& str) {
      ++CAsgn;
      if(_data != str._data) {
        if(_data)
          delete _data;
        _len = str._len;
        _init_data(str._data);
      }
      return *this;
    }

    virtual ~MyStringNoMove() {
      ++Dtor;
      if(_data)
        delete _data;
    }

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

size_t MyStringNoMove::DCtor = 0;
size_t MyStringNoMove::Ctor = 0;
size_t MyStringNoMove::CCtor = 0;
size_t MyStringNoMove::CAsgn = 0;
size_t MyStringNoMove::MCtor = 0;
size_t MyStringNoMove::MAsgn = 0;
size_t MyStringNoMove::Dtor = 0;

test_moveable函數

既然準備測試移動語義帶來的性能優化,提供test_moveable函數來進行測試。test_moveable函數就是重複創建隨機數構造T類型對象,並將其放入到vector中,重複次數爲value。具體的代碼爲:

#include <iostream>

template<typename T>
void output_static_data(const T& myStr) {
  std::cout << typeid(myStr).name() << "--" << std::endl;
  std::cout << "CCtor = " << T::CCtor << " MCtor = " << T::MCtor
            << " CAsgn = " << T::CAsgn << " MAsgn = " << T::MAsgn
            << " Dtor = " << T::Dtor << " Ctor = " << T::Ctor
            << " DCtor = " << T::DCtor << std::endl;
}

template<typename T>
void test_moveable(T t, long value) {
  char buf[10];
  typedef typename std::iterator_traits<typename T::iterator>::value_type Vtype;

  std::chrono::milliseconds time1 = 
    std::chrono::duration_cast<std::chrono::milliseconds>(
    std::chrono::system_clock::now().time_since_epoch());

  for(long i = 0; i < value; ++i) {
    snprintf(buf, 10, "%d", rand());
    auto iter = t.end();
    t.insert(iter, Vtype(buf));
  }

  std::chrono::milliseconds time2 = 
    std::chrono::duration_cast<std::chrono::milliseconds>(
    std::chrono::system_clock::now().time_since_epoch());
  std::cout << "construction : " << (time2 - time1).count() << std::endl;
  output_static_data(*(t.begin()));
}

int main(int argc, char *argv[]) 
{
  test_moveable(std::vector<MyString>(), 10000000L);
  test_moveable(std::vector<MyStringNoMove>(), 10000000L);
}

代碼裏比較艱澀的部分是如下這一行:

typedef typename std::iterator_traits<typename T::iterator>::value_type Vtype;

詳細內容是:通過迭代器iterator的萃取機來進行類型萃取,獲取std::vector的模板參數類型

insert函數

對於容器的insert函數,以std::vector爲例,有兩種定義方式:

iterator insert(const_iterator __position, const value_type& __x);
iterator insert(const_iterator __position, value_type&& __x);

這個時候大概就能理解整個流程的過程了。當運行到t.insert(iter, Vtype(buf))

  • 如果Vtype的類型是MyStringNoMove,由於Vtype(buf)是一個右值,會調用insert(..., value_type&& __x)。但由於MyStringNoMove並沒有提供移動構造函數,就會調用拷貝構造函數生成一個對象,並將該對象insert到vector的末尾;
  • 如果Vtype的類型是MyString,由於Vtype(buf)是一個右值,會調用insert(..., value_type&& __x)。但由於MyString提供了移動構造函數,就會直接調用移動構造函數將臨時對象Vtype(buf)的生命週期延長,將該臨時對象insert到vector的末尾。

可以看到,對於容器的insert函數而言,如果模板參數類型沒有移動構造函數,將會調用拷貝構造函數進行很多的拷貝操作;但如果模板參數類型有移動構造函數,就會直接調用移動構造函數直接轉換了資源的所有權。性能會提高很多。


完美轉發

insert過程存在的問題

但是,上文講述的insert函數的整個流程必須要建立在一個前提上:insert(..., value_type&& __x)函數內調用構造函數生成對象的時候,_x必須還需要是個右值。這是什麼意思呢?

來看幾個簡單的例子:

#include <iostream>

void process(int& i) {
  std::cout << "process(&) " << i << std::endl;
}

void process(int&& i) {
  std::cout << "process(&&) " << i << std::endl;
}

void forward(int&& i) {
  std::cout << "forward(&&) " << i << std::endl;
  process(i);
}

int main(int argc, char *argv[]) 
{
  int c = 0;
  process(c);                     // process(&) 0
  process(1);                     // process(&&) 1
  process(std::move(c));          // process(&&) 0

  forward(2);                     // forward(&&) 2  process(&) 2
  forward(std::move(c));          // forward(&&) 0  process(&) 0

  return 0;
}

可以看出,前三句的打印輸出並沒有什麼問題,主要是後兩句的打印輸出。經過forward函數的轉發之後,無論是2(純右值)還是std::move(將亡值),在接下去調用process函數的時候,都由右值轉化爲了左值

也就是說,如果不經過什麼特殊處理的話,調用insert(..., value_type&& __x)函數之後,_x就變成了一個左值,就算MyString有移動構造函數,也沒有辦法調用到啊。只能繼續調用拷貝構造函數了。

爲什麼右值引用變成了左值?

右值引用獨立於左值和右值,意思是右值引用類型的變量可能是左值也可能是右值。這比較拗口。簡單地說,右值引用綁定一個右值,但引用本身也是個變量,這個變量可以是左值也可以是右值但是,任何的函數內部,對形參的直接使用,都是按照左值進行的

完美轉發的引入

完美轉發就是std::forward,其原型爲:

template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{
  return static_cast<T&&>(param);         // 可能會發生引用摺疊!
}

可以看出,完美轉發必須使用在模板實例化的過程中!它的原理是利用萬能引用的特性:如果被一個左值初始化,它就是一個左值引用;如果它被一個右值初始化,它就是一個右值引用,它是左值引用還是右值引用取決於它的初始化

在也就是說,無論param是左值還是右值,都強行轉化爲與T相同的引用類型。此時,就可以理解了:

template<typename T>
void forward(T&& i) {
  std::cout << "forward(&&) " << i << std::endl;
  process(std::forward<T>(i));
}

此時可以理解了,儘管i在forward函數內部,按照左值進行的。但是傳遞給process函數時,強行又轉化爲T類型,也就是右值引用的類型。


性能測試結果

測試性能的程序運行結果如下:

yngzmiao@yngzmiao-virtual-machine:~/test$ ./main 
construction : 6595
8MyString--
CCtor = 0 MCtor = 26777215 CAsgn = 0 MAsgn = 0 Dtor = 26777215 Ctor = 10000000 DCtor = 0
construction : 6866
14MyStringNoMove--
CCtor = 26777215 MCtor = 0 CAsgn = 0 MAsgn = 0 Dtor = 26777215 Ctor = 10000000 DCtor = 0

可以看出,MyString調用的是移動構造函數,MyStringNoMove調用的是拷貝構造函數。兩者之間有差距,但其實也不是特別大。至於爲什麼數量是26777215超過了10000000,因爲vector的擴容複製操作

但如果對vector容器進行拷貝構造函數和移動構造函數的性能測試,在test_moveable添加如下代碼:

template<typename T>
void test_moveable(T t, long value) {
  ...

  T t1(t);
  std::chrono::milliseconds time3 = 
    std::chrono::duration_cast<std::chrono::milliseconds>(
    std::chrono::system_clock::now().time_since_epoch());
  std::cout << "copy : " << (time3 - time2).count() << std::endl;
  T t2(std::move(t));
  std::chrono::milliseconds time4 = 
    std::chrono::duration_cast<std::chrono::milliseconds>(
    std::chrono::system_clock::now().time_since_epoch());
  std::cout << "move : " << (time4 - time3).count() << std::endl;
}

編譯並運行後的打印結果爲:

copy : 1364
move : 0

可以看出,移動操作比拷貝操作快得多!

本文最後也提一下:某些容器類型的移動操作未必比拷貝操作更快。如:

  • 標準庫大部分容器類(如vector),內部是將其元素存放在堆上,然後用指針指向該堆內存。在進行移動操作時,只是進行指針的拷貝。整個容器內容在常數時間內便可移動完成
  • std::array對象缺少這樣的一根指針,因爲其內容數據是直接存儲對象上的。雖然std::array提供移動操作,但其移動和拷貝的速度哪個更快,取決於元素的移動和拷貝速度的比較。同時std::array移動時需要對每一個元素進行移動,總是需要線性時間;
  • 許多std::string類型的實現採用了小型字符串優化(SSO)。當使用SSO後,“小型”字符串(如不超過15個字符)會存儲在std::string對象內的某個緩衝區內,即內容直接存儲在對象上(而不是堆上)。因此,此時是整個對象的移動,速度並比拷貝更快。

相關閱讀

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