在C++98中有左值和右值的概念,不過這兩個概念對於很多程式設計師並不關心,因為不知道這兩個概念照樣可以寫出好程式。在C++11中對右值的概念進行了增強,我個人理解這部分內容是C++11引入的特性中最難以理解的了。該特性的引入至少可以解決C++98中的移動語義和完美轉發問題,若你還不清楚這兩個問題是什麼,請向下看。
溫馨提示,由於內容比較難懂,請仔細看。C++已經夠複雜了,C++11中引入的新特性令C++更加複雜了。在學習本文的時候一定要理解清楚左值、右值、左值引用和右值引用。
移動建構函式
首先看一個C++98中的關於函式返回類物件的例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
class MyString { public: MyString() { _data = nullptr; _len = 0; printf("Constructor is called!\n"); } MyString(const char* p) { _len = strlen (p); _init_data(p); cout << "Constructor is called! this->_data: " << (long)_data << endl; } MyString(const MyString& str) { _len = str._len; _init_data(str._data); cout << "Copy Constructor is called! src: " << (long)str._data << " dst: " << (long)_data << endl; } ~MyString() { if (_data) { cout << "DeConstructor is called! this->_data: " << (long)_data << endl; free(_data); } else { std::cout << "DeConstructor is called!" << std::endl; } } MyString& operator=(const MyString& str) { if (this != &str) { _len = str._len; _init_data(str._data); } cout << "Copy Assignment is called! src: " << (long)str._data << " dst" << (long)_data << endl; return *this; } operator const char *() const { return _data; } private: char *_data; size_t _len; void _init_data(const char *s) { _data = new char[_len+1]; memcpy(_data, s, _len); _data[_len] = ''; } }; MyString foo() { MyString middle("123"); return middle; } int main() { MyString a = foo(); return 1; } |
該例子在編譯器沒有進行優化的情況下會輸出以下內容,我在輸出的內容中做了註釋處理,如果連這個例子的輸出都看不懂,建議再看一下C++的語法了。我這裡使用的編譯器命令為g++ test.cpp -o main -g -fno-elide-constructors,之所以要加上-fno-elide-constructors選項時因為g++編譯器預設情況下會對函式返回類物件的情況作返回值優化處理,這不是我們討論的重點。
1 2 3 4 5 6 |
Constructor is called! this->_data: 29483024 // middle物件的建構函式 Copy Constructor is called! src: 29483024 dst: 29483056 // 臨時物件的構造,通過middle物件呼叫複製建構函式 DeConstructor is called! this->_data: 29483024 // middle物件的析構 Copy Constructor is called! src: 29483056 dst: 29483024 // a物件構造,通過臨時物件呼叫複製建構函式 DeConstructor is called! this->_data: 29483056 // 臨時物件析構 DeConstructor is called! this->_data: 29483024 // a物件析構 |
在上述例子中,臨時物件的構造、複製和析構操作所帶來的效率影響一直是C++中為人詬病的問題,臨時物件的構造和析構操作均對堆上的記憶體進行操作,而如果_data的記憶體過大,勢必會非常影響效率。從程式設計師的角度而言,該臨時物件是透明的。而這一問題正是C++11中需要解決的問題。
在C++11中解決該問題的思路為,引入了移動建構函式,移動建構函式的定義如下。
1 2 3 4 5 6 |
MyString(MyString &&str) { cout << "Move Constructor is called! src: " << (long)str._data << endl; _len = str._len; _data = str._data; str._data = nullptr; } |
在移動建構函式中我們竊取了str物件已經申請的記憶體,將其拿為己用,並將str申請的記憶體給賦值為nullptr。移動建構函式和複製建構函式的不同之處在於移動建構函式的引數使用&&,這就是下文要講解的右值引用符號。引數不再是const,因為在移動建構函式需要修改右值str的內容。
移動建構函式的呼叫時機為用來構造臨時變數和用臨時變數來構造物件的時候移動語義會被呼叫。可以通過下面的輸出結果看到,我們所使用的編譯引數為g++ test.cpp -o main -g -fno-elide-constructors –std=c++11。
1 2 3 4 5 6 |
Constructor is called! this->_data: 22872080 // middle物件構造 Move Constructor is called! src: 22872080 // 臨時物件通過移動建構函式構造,將middle申請的記憶體竊取 DeConstructor is called! // middle物件析構 Move Constructor is called! src: 22872080 // 物件a通過移動建構函式構造,將臨時物件的記憶體竊取 DeConstructor is called! // 臨時物件析構 DeConstructor is called! this->_data: 22872080 // 物件a析構 |
通過輸出結果可以看出,整個過程中僅申請了一塊記憶體,這也正好符合我們的要求了。
C++98中的左值和右值
我們先來看下C++98中的左值和右值的概念。左值和右值最直觀的理解就是一條語句等號左邊的為左值,等號右邊的為右值,而事實上該種理解是錯誤的。左值:可以取地址,有名字的值,是一個指向某記憶體空間的表示式,可以使用&操作符獲取記憶體地址。右值:不能取地址,即非左值的都是右值,沒有名字的值,是一個臨時值,表示式結束後右值就沒有意義了。我想通過下面的例子,讀者可以清楚的理解左值和右值了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// lvalues: // int i = 42; i = 43; // i是左值 int* p = &i; // i是左值 int& foo(); foo() = 42; // foo()返回引用型別是左值 int* p1 = &foo(); // foo()可以取地址是左值 // rvalues: // int foobar(); int j = 0; j = foobar(); // foobar()是右值 int* p2 = &foobar(); // 編譯錯誤,foobar()是右值不能取地址 j = 42; // 42是右值 |
C++11右值引用和移動語義
在C++98中有引用的概念,對於const int &m = 1,其中m為引用型別,可以對其取地址,故為左值。在C++11中,引入了右值引用的概念,使用&&來表示。在引入了右值引用後,在函式過載時可以根據是左值引用還是右值引用來區分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
void fun(MyString &str) { cout << "left reference" << endl; } void fun(MyString &&str) { cout << "right reference" << endl; } int main() { MyString a("456"); fun(a); // 左值引用,呼叫void fun(MyString &str) fun(foo()); // 右值引用,呼叫void fun(MyString &&str) return 1; } |
在絕大多數情況下,這種通過左值引用和右值引用過載函式的方式僅會在類的建構函式和賦值操作符中出現,被例子僅是為了方便採用函式的形式,該種形式的函式用到的比較少。上述程式碼中所使用的將資源從一個物件到另外一個物件之間的轉移就是移動語義。這裡提到的資源是指類中的在堆上申請的記憶體、檔案描述符等資源。
前面已經介紹過了移動建構函式的具體形式和使用情況,這裡對移動賦值操作符的定義再說明一下,並將main函式的內容也一起更改,將得到如下輸出結果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
MyString& operator=(MyString&& str) { cout << "Move Operator= is called! src: " << (long)str._data << endl; if (this != &str) { if (_data != nullptr) { free(_data); } _len = str._len; _data = str._data; str._len = 0; str._data = nullptr; } return *this; } int main() { MyString b; b = foo(); return 1; } // 輸出結果,整個過程僅申請了一個記憶體地址 Constructor is called! // 物件b建構函式呼叫 Constructor is called! this->_data: 14835728 // middle物件構造 Move Constructor is called! src: 14835728 // 臨時物件通過移動建構函式由middle物件構造 DeConstructor is called! // middle物件析構 Move Operator= is called! src: 14835728 // 物件b通過移動賦值操作符由臨時物件賦值 DeConstructor is called! // 臨時物件析構 DeConstructor is called! this->_data: 14835728 // 物件b解構函式呼叫 |
在C++中對一個變數可以通過const來修飾,而const和引用是對變數約束的兩種方式,為並行存在,相互獨立。因此,就可以劃分為了const左值引用、非const左值引用、const右值引用和非const右值引用四種型別。其中左值引用的繫結規則和C++98中是一致的。
非const左值引用只能繫結到非const左值,不能繫結到const右值、非const右值和const左值。這一點可以通過const關鍵字的語義來判斷。
const左值引用可以繫結到任何型別,包括const左值、非const左值、const右值和非const右值,屬於萬能引用型別。其中繫結const右值的規則比較少見,但是語法上是可行的,比如const int &a = 1,只是我們一般都會直接使用int &a = 1了。
非const右值引用不能繫結到任何左值和const右值,只能繫結非const右值。
const右值引用型別僅是為了語法的完整性而設計的, 比如可以使用const MyString &&right_ref = foo(),但是右值引用型別的引入主要是為了移動語義,而移動語義需要右值引用是可以被修改的,因此const右值引用型別沒有實際意義。
我們通過表格的形式對上文中提到的四種引用型別可以繫結的型別進行總結。
引用型別/是否繫結 | 非const左值 | const左值 | 非const右值 | const右值 | 備註 |
---|---|---|---|---|---|
非const左值引用 | 是 | 否 | 否 | 否 | 無 |
const左值引用 | 是 | 是 | 是 | 是 | 全能繫結型別,繫結到const右值的情況比較少見 |
非const右值引用 | 否 | 否 | 是 | 否 | C++11中引入的特性,用於移動語義和完美轉發 |
const值引用 | 是 | 否 | 否 | 否 | 沒有實際意義,為了語法完整性而存在 |
下面針對上述例子,我們看一下foo函式繫結引數的情況。
如果只實現了void foo(MyString &str),而沒有實現void fun(MyString &&str),則和之前一樣foo函式的實參只能是非const左值。
如果只實現了void foo(const MyString &str),而沒有實現void fun(MyString &&str),則和之前一樣foo函式的引數即可以是左值又可以是右值,因為const左值引用是萬能繫結型別。
如果只實現了void foo(MyString &&str),而沒有實現void fun(MyString &str),則foo函式的引數只能是非const右值。
強制移動語義std::move()
前文中我們通過右值引用給類增加移動建構函式和移動賦值操作符已經解決了函式返回類物件效率低下的問題。那麼還有什麼問題沒有解決呢?
在C++98中的swap函式的實現形式如下,在該函式中我們可以看到整個函式中的變數a、b、c均為左值,無法直接使用前面移動語義。
1 2 3 4 5 6 7 |
template <class T> void swap ( T& a, T& b ) { T c(a); a=b; b=c; } |
但是如果該函式中能夠使用移動語義是非常合適的,僅是為了交換兩個變數,卻要反覆申請和釋放資源。按照前面的知識變數c不可能為非const右值引用,因為變數a為非const左值,非const右值引用不能繫結到任何左值。
在C++11的標準庫中引入了std::move()函式來解決該問題,該函式的作用為將其引數轉換為右值。在C++11中的swap函式就可以更改為了:
1 2 3 4 5 6 7 |
template <class T> void swap (T& a, T& b) { T c(std::move(a)); a=std::move(b); b=std::move(c); } |
在使用了move語義以後,swap函式的效率會大大提升,我們更改main函式後測試如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
int main() { // move函式 MyString d("123"); MyString e("456"); swap(d, e); return 1; } // 輸出結果,通過輸出結果可以看出物件交換是成功的 Constructor is called! this->_data: 38469648 // 物件d構造 Constructor is called! this->_data: 38469680 // 物件e構造 Move Constructor is called! src: 38469648 // swap函式中的物件c通過移動建構函式構造 Move Operator= is called! src: 38469680 // swap函式中的物件a通過移動賦值操作符賦值 Move Operator= is called! src: 38469648 // swap函式中的物件b通過移動賦值操作符賦值 DeConstructor is called! // swap函式中的物件c析構 DeConstructor is called! this->_data: 38469648 // 物件e析構 DeConstructor is called! this->_data: 38469680 // 物件d析構 |
右值引用和右值的關係
這個問題就有點繞了,需要開動思考一下右值引用和右值是啥含義了。讀者會憑空的認為右值引用肯定是右值,其實不然。我們在之前的例子中新增如下程式碼,並將main函式進行修改如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
void test_rvalue_rref(MyString &&str) { cout << "tmp object construct start" << endl; MyString tmp = str; cout << "tmp object construct finish" << endl; } int main() { test_rvalue_rref(foo()); return 1; } // 輸出結果 Constructor is called! this->_data: 28913680 Move Constructor is called! src: 28913680 DeConstructor is called! tmp object construct start Copy Constructor is called! src: 28913680 dst: 28913712 // 可以看到這裡呼叫的是複製建構函式而不是移動建構函式 tmp object construct finish DeConstructor is called! this->_data: 28913712 DeConstructor is called! this->_data: 28913680 |
我想程式執行的結果肯定跟大多數人想到的不一樣,“Are you kidding me?不是應該呼叫移動建構函式嗎?為什麼呼叫了複製建構函式?”。關於右值引用和左右值之間的規則是:
如果右值引用有名字則為左值,如果右值引用沒有名字則為右值。
通過規則我們可以發現,在我們的例子中右值引用str是有名字的,因此為左值,tmp的構造會呼叫複製建構函式。之所以會這樣,是因為如果tmp構造的時候呼叫了移動建構函式,則呼叫完成後str的申請的記憶體自己已經不可用了,如果在該函式中該語句的後面在呼叫str變數會出現我們意想不到的問題。鑑於此,我們也就能夠理解為什麼有名字的右值引用是左值了。如果已經確定在tmp構造語句的後面不需要使用str變數了,可以使用std::move()函式將str變數從左值轉換為右值,這樣tmp變數的構造就可以使用移動建構函式了。
而如果我們呼叫的是MyString b = foo()語句,由於foo()函式返回的是臨時物件沒有名字屬於右值,因此b的構造會呼叫移動建構函式。
該規則非常的重要,要想能夠正確使用右值引用,該規則必須要掌握,否則寫出來的程式碼會有一個大坑。
完美轉發
前面已經介紹了本文的兩大主題之一的移動語義,還剩下完美轉發機制。完美轉發機制通常用於庫函式中,至少在我的工作中還是很少使用的。如果實在不想理解該問題,可以不用向下看了。在泛型程式設計中,經常會遇到的一個問題是怎樣將一組引數原封不動的轉發給另外一個函式。這裡的原封不動是指,如果函式是左值,那麼轉發給的那個函式也要接收一個左值;如果引數是右值,那麼轉發給的函式也要接收一個右值;如果引數是const的,轉發給的函式也要接收一個const引數;如果引數是非const的,轉發給的函式也要接收一個非const值。
該問題看上去非常簡單,其實不然。看一個例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
#include <iostream> using namespace std; void fun(int &) { cout << "lvalue ref" << endl; } void fun(int &&) { cout << "rvalue ref" << endl; } void fun(const int &) { cout << "const lvalue ref" << endl; } void fun(const int &&) { cout << "const rvalue ref" << endl; } template<typename T> void PerfectForward(T t) { fun(t); } int main() { PerfectForward(10); // rvalue ref int a; PerfectForward(a); // lvalue ref PerfectForward(std::move(a)); // rvalue ref const int b = 8; PerfectForward(b); // const lvalue ref PerfectForward(std::move(b)); // const rvalue ref return 0; } |
在上述例子中,我們想達到的目的是PerfectForward模板函式能夠完美轉發引數t到fun函式中。上述例子中的PerfectForward函式必然不能夠達到此目的,因為PerfectForward函式的引數為左值型別,呼叫的fun函式也必然為void fun(int &)。且呼叫PerfectForward之前就產生了一次引數的複製操作,因此這樣的轉發只能稱之為正確轉發,而不是完美轉發。要想達到完美轉發,需要做到像轉發函式不存在一樣的效率。
因此,我們考慮將PerfectForward函式的引數更改為引用型別,因為引用型別不會有額外的開銷。另外,還需要考慮轉發函式PerfectForward是否可以接收引用型別。如果轉發函式PerfectForward僅能接收左值引用或右值引用的一種,那麼也無法實現完美轉發。
我們考慮使用const T &t型別的引數,因為我們在前文中提到過,const左值引用型別可以繫結到任何型別。但是這樣目標函式就不一定能接收const左值引用型別的引數了。const左值引用屬於左值,非const左值引用和非const右值引用是無法繫結到const左值的。
如果將引數t更改為非const右值引用、const右值也是不可以實現完美轉發的。
在C++11中為了能夠解決完美轉發問題,引入了更為複雜的規則:引用摺疊規則和特殊模板引數推導規則。
引用摺疊推導規則
為了能夠理解清楚引用摺疊規則,還是通過以下例子來學習。
1 2 3 4 5 6 7 8 9 10 |
typedef int& TR; int main() { int a = 1; int &b = a; int & &c = a; // 編譯器報錯,不可以對引用再顯示新增引用 TR &d = a; // 通過typedef定義的型別隱式新增引用是可以的 return 1; } |
在C++中,不可以在程式中對引用再顯示新增引用型別,對於int & &c的宣告變數方式,編譯器會提示錯誤。但是如果在上下文中(包括使用模板例項化、typedef、auto型別推斷等)出現了對引用型別再新增引用的情況,編譯器是可以編譯通過的。具體的引用摺疊規則如下,可以看出一旦引用中定義了左值型別,摺疊規則總是將其摺疊為左值引用。這就是引用摺疊規則的全部內容了。另外摺疊規則跟變數的const特性是沒有關係的。
1 2 3 4 |
A& & => A& A& && => A& A&& & => A& A&& && => A&& |
特殊模板引數推導規則
下面我們再來學習特殊模板引數推導規則,考慮下面的模板函式,模板函式接收一個右值引用作為模板引數。
1 2 |
template<typename T> void foo(T&&); |
說白點,特殊模板引數推導規則其實就是引用摺疊規則在模板引數為右值引用時模板情況下的應用,是引用摺疊規則的一種情況。我們結合上文中的引用摺疊規則,
- 如果foo的實參是上文中的A型別的左值時,T的型別就為A&。根據引用摺疊規則,最後foo的引數型別為A&。
- 如果foo的實參是上文中的A型別的右值時,T的型別就為A&&。根據引用摺疊規則,最後foo的引數型別為A&&。
解決完美轉發問題
我們已經學習了模板引數為右值引用時的特殊模板引數推導規則,那麼我們利用剛學習的知識來解決本文中待解決的完美轉發的例子。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#include <iostream> using namespace std; void fun(int &) { cout << "lvalue ref" << endl; } void fun(int &&) { cout << "rvalue ref" << endl; } void fun(const int &) { cout << "const lvalue ref" << endl; } void fun(const int &&) { cout << "const rvalue ref" << endl; } //template<typename T> //void PerfectForward(T t) { fun(t); } // 利用引用摺疊規則代替了原有的不完美轉發機制 template<typename T> void PerfectForward(T &&t) { fun(static_cast<T &&>(t)); } int main() { PerfectForward(10); // rvalue ref,摺疊後t型別仍然為T && int a; PerfectForward(a); // lvalue ref,摺疊後t型別為T & PerfectForward(std::move(a)); // rvalue ref,摺疊後t型別為T && const int b = 8; PerfectForward(b); // const lvalue ref,摺疊後t型別為const T & PerfectForward(std::move(b)); // const rvalue ref,摺疊後t型別為const T && return 0; } |
例子中已經對完美轉發的各種情況進行了說明,這裡需要對PerfectForward模板函式中的static_cast進行說明。static_cast僅是對傳遞右值時起作用。我們看一下當引數為右值時的情況,這裡的右值包括了const右值和非const右值。
1 2 3 4 5 6 7 |
// 引數為右值,引用摺疊規則引用前 template<int && &&T> void PerfectForward(int && &&t) { fun(static_cast<int && &&>(t)); } // 引用摺疊規則應用後 template<int &&T> void PerfectForward(int &&t) { fun(static_cast<int &&>(t)); } |
可能讀者仍然沒有發現上述例子中的問題,“不用static_cast進行強制型別轉換不是也可以嗎?”。別忘記前文中仍然提到一個右值引用和右值之間關係的規則,如果右值引用有名字則為左值,如果右值引用沒有名字則為右值。。這裡的變數t雖然為右值引用,但是是左值。如果我們想繼續向fun函式中傳遞右值,就需要使用static_cast進行強制型別轉換了。
其實在C++11中已經為我們封裝了std::forward函式來替代我們上文中使用的static_cast型別轉換,該例子中使用std::forward函式的版本變為了:
1 2 |
template<typename T> void PerfectForward(T &&t) { fun(std::forward<T>(t)); } |
對於上文中std::move函式的實現也是使用了引用摺疊規則,實現方式跟std::forward一致。
引用
- 《深入理解C++11-C++11新特性解析與應用》
- C++11 標準新特性: 右值引用與轉移語義
- 如何評價 C++11 的右值引用(Rvalue reference)特性?
- C++11 完美轉發
- C++ Rvalue References Explained
- 詳解C++右值引用 (對C++ Rvalue References Explained的翻譯)