上篇中,主要講解了右值引用和移動語義的具體定義和用法。在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對象內的某個緩衝區內,即內容直接存儲在對象上(而不是堆上)。因此,此時是整個對象的移動,速度並比拷貝更快。