【 C++11 】右值引用和移动语义
目錄
1、基本概念
????????左值 vs 左值引用
????????右值 vs 右值引用
????????左值引用 vs 右值引用
2、右值引用使用場景和意義
????????左值引用的使用場景
????????左值引用的短板
????????右值引用和移動語義
????????????①、移動構造
????????????②、移動賦值
????????????③、編譯器做的優化
????????????④、總結
????????右值引用引用左值
????????右值引用的其它場景(插入接口)
3、完美轉發
????????萬能引用&&
????????forward完美轉發在傳參的過程中保留對象原生類型屬性
????????完美轉發的使用場景
1、基本概念
傳統的C++語法中就有引用的語法,而C++11中新增了的右值引用語法特性,所以從現在開始我們之前學習的引用就叫做左值引用。無論左值引用還是右值引用,都是給對象取別名。
左值 vs 左值引用
左值:
左值是一個表示數據的表達式(如變量名或解引用的指針),有如下特性:
左值引用:
- 左值引用就是給左值的引用,給左值取別名。
右值 vs 右值引用
右值:
右值也是一個表示數據的表達式,如臨時變量:字面常量、表達式返回值,函數返回值(這個不能是左值引用返回,要是傳值返回)等等,有如下特性:
- 綜上左值和右值最大區別在于左值可以取地址,右值不可以取地址(因為右值是臨時變量,沒有實際被存儲起來。
補充:
C++里又把右值分為兩類(純右值和將亡值):
右值引用:
- 右值引用就是對右值的引用,給右值取別名。
注意:
- 右值是不能取地址的,但是給右值取別名后,會導致右值被存儲到特定位置,且可以取到該位置的地址,也就是說例如:不能取字面量10的地址,但是rr1引用后,可以對rr1取地址,也可以修改rr1。如果不想rr1被修改,可以用const int&& rr1 去引用,是不是感覺很神奇,這個了解一下實際中右值引用的使用場景并不在于此,這個特性也不重要。
左值引用 vs 右值引用
左值引用總結:
右值引用總結:
再次強調這四句話:
右值引用是通過移動構造和移動賦值來極大提高深拷貝的效率,詳情見下文:
2、右值引用使用場景和意義
前面我們可以看到和const左值引用既可以引用左值又可以引用右值,那為什么C++11還要提出右值引用呢?是不是化蛇添足呢?下面我們來看看左值引用的使用場景與短板(深拷貝的問題),以及右值引用是如何補齊這個短板的!
- 為了把整個過程說的通俗易懂,需要一個深拷貝的類,我們以先前模擬實現的string類來作為示例:簡易版string類的模擬實現
左值引用的使用場景
左值引用解決的是拷貝構造引發的深拷貝而帶來的開銷過大、效率低的問題:
- 左值引用做參數,防止傳值傳參引發的拷貝構造問題(導致效率低)
- 左值引用做返回值,防止返回對象發生拷貝構造的操作(導致效率低)
總結:
- 我們都清楚string類的+=運算符是左值引用作為返回值,這樣做避免了傳值返回引發的拷貝構造,而這樣做的原因在于string類的拷貝構造為深拷貝,要經歷開空間等操作,開銷太大了,導致效率低,傳值傳參同樣也是會發生拷貝構造(深拷貝)這個問題,為了避免如此之大的開銷,使用左值引用可以很好的解決此問題,因為左值引用就是取別名,無開銷,提高了效率。
左值引用的短板
左值引用可以避免一些不必要的拷貝構造操作,但是并不是所有情況都是可以避免的:
- 左值引用做參數,能夠完全避免傳參時不必要的拷貝操作
- 左值引用做返回值,并不能完全避免函數返回對象時不必要的拷貝操作
當函數返回的是一個臨時對象時,不能使用引用返回,因為臨時對象出了函數作用域就銷毀了,只能使用傳值返回,而傳值返回難免會引發拷貝構造帶來的深拷貝問題,但是無法避免,這就是左值引用的短板,示例:
namesapce cpp {cpp::string to_string(int value){bool flag = true;if (value < 0){flag = false;value = 0 - value;}cpp::string str;while (value > 0){int x = value % 10;value /= 10;str += ('0' + x);}if (flag == false){str += '-';}std::reverse(str.begin(), str.end());return str;} }因為這里的to_string是傳值返回,所以在調用to_string的時候一定會調用拷貝構造,而拷貝構造實現的又是一個深拷貝,效率低:
int main() {cpp::string ret = cpp::to_string(1234);//string(const string& s) -- 深拷貝return 0; }解釋此情況不能用左值引用返回的原因:
- 如果強硬的把上面的to_string實現成左值引用返回,那么又會出現一個問題,我str是臨時對象,因為是左值引用返回,所以返回的是str的別名,把別名作為返回值再區拷貝構造ret對象,但是臨時對象str出了作用域就調用析構函數銷毀了,即使能夠訪問對象的值,但是空間已經不存在了,此時就發生了內存錯誤。
綜上所述,為了解決左值引用的短板,C++11引出了右值引用,但并不是簡單的把右值引用作為返回值,要對string進行改造,詳情見下文:
右值引用和移動語義
移動構造:
- string拷貝構造的const左值引用會接收左值和右值,但是編譯器遵循最匹配原則,如果我們單獨增加一個右值引用版本的拷貝構造函數,使其只能接收右值,根據最匹配原則,遇到右值,傳入右值引用版本的拷貝構造函數,遇到左值傳入左值引用版本的拷貝構造函數,這樣就能解決了左值引用帶來的弊端,而上述單獨增加的函數就是我們的移動構造!!!
移動賦值:
- operator=函數采用的是const左值引用接收參數,因此無論賦值時傳入的是左值還是右值,都會調用原有的operator=函數。增加移動賦值之后,由于移動賦值采用的是右值引用接收參數,因此如果賦值時傳入的是右值,那么就會調用移動賦值函數(最匹配原則)。string原有的operator=函數做的是深拷貝,而移動賦值函數中只需要調用swap函數進行資源的轉移,因此調用移動賦值的代價比調用原有operator=的代價小。
①、移動構造
為了解決左值引用的短板,我們需要在cpp::string中增加移動構造,移動構造的本質是將參數右值(將亡值)的資源竊取過來,占位已有,那么就不用做深拷貝了,所以它叫做移動構造,就是竊取別人的資源來構造自己。因為將亡值的特點就是很快就要被銷毀了,在你銷毀之前還不如把你的資源通過移動構造傳給別人,也算是物盡其用,積善德了。
- 該移動構造函數要做的就是調用swap函數將傳入右值的資源竊取過來,為了能夠更好的得知移動構造函數是否被調用,可以在該函數當中打印一條提示語句。
測試代碼如下:
int main() {cpp::string ret = cpp::to_string(1234);//轉移將亡值的資源cpp::string s1("hello");cpp::string s2(s1);//深拷貝,左值拷貝時不會被資源轉移cpp::string s3(move(s1));//轉移將亡值的資源return 0; }再來區分下移動構造和拷貝構造:
②、移動賦值
移動賦值是一個賦值運算符重載函數,該函數的參數是右值引用類型的,移動賦值也是將傳入右值的資源竊取過來,占為己有,這樣就避免了深拷貝,所以它叫移動賦值,就是竊取別人的資源來賦值給自己的意思。
- 在當前的string類中增加一個移動賦值函數,該函數要做的就是調用swap函數將傳入右值的資源竊取過來,為了能夠更好的得知移動賦值函數是否被調用,可以在該函數中打印一條提示語句。
來區分下移動賦值和operator=:
測試代碼如下:
int main() {cpp::string ret;//string(string&& s) -- 移動構造,資源轉移ret = cpp::to_string(1234);//string& operator=(string&& s) -- 移動賦值,資源轉換return 0; }總結:
- 這里運行后,我們看到調用了一次移動構造和一次移動賦值。因為如果是用一個已經存在的對象接收,編譯器就沒辦法優化了。cpp::to_string函數中會先用str生成構造生成一個臨時對象,但是我們可以看到,編譯器很聰明的在這里把str識別成了右值,調用了移動構造。然后在把這個臨時對象做為cpp::to_string函數調用的返回值賦值給ret1,這里調用的移動賦值。
- 這里雖然調用兩次函數,但都只是資源的移動,不需要進行深拷貝,大大提高了效率。
③、編譯器做的優化
以如下代碼為測試案例:
int main() {cpp::string s = cpp::to_string(1234);return 0; }3.1、先來看下沒有移動構造編譯器做的優化:
不優化:
- 如果沒有移動構造,那我們先前實現的to_string只能夠傳值返回,傳值返回會先拷貝構造出一個臨時對象,再用這個臨時對象再拷貝構造我們接收返回值的對象。如圖所示:
優化:
- C++11標準出來之前,也就是C++98的情況,本來應該是兩次拷貝構造,但是編譯器對其進行了優化,連續兩次的拷貝構造函數最終被優化成一次,直接拿str拷貝構造s。
3.2、再來看看有移動構造編譯器做的優化:
不優化:
- C++11出來后,我們假設它不優化,根據先前的了解,不優化的話,左值str會拷貝構造給一個臨時對象,這個臨時對象就是一個右值(將亡值),隨后進行移動構造,也就是先拷貝構造再移動構造:
優化:?
- C++11這里編譯器進行優化后,左值str會被優化成右值(通過move把左值變為右值),再移動構造給一個臨時對象,此臨時對象再移動構造給s,但是編譯器還會再進行一次優化,把左值str識別出右值后直接移動構造給s。也就是只進行一次移動構造:
3.3、來看看編譯器對移動賦值的處理:
- 當我們不是用函數的返回值來構造一個對象,而是用一個之前已經定義出來的對象來接收函數的返回值,測試代碼如下:
此時編譯器會把左值str會被優化成右值(通過move把左值變為右值),再移動構造給一個臨時對象,此臨時對象再通過移動賦值傳給之前已經定義出來的對象。
這里編譯器并沒有對這種情況進行優化,因為如果是用一個已經存在的對象接收,編譯器就沒辦法優化了。cpp::to_string函數中會先用str生成構造生成一個臨時對象,但是我們可以看到,編譯器很聰明的在這里把str識別成了右值,調用了移動構造。然后在把這個臨時對象做為cpp::to_string函數調用的返回值賦值給ret1,這里調用的移動賦值。
④、總結
C++11后STL中的容器都是增加了移動構造和移動賦值:
- https://cplusplus.com/reference/string/string/string/
- http://www.cplusplus.com/reference/vector/vector/vector/
右值引用引用左值
這部分內容我在一開始簡要提了一下,下面來正式了解下move函數:
- 按照語法,右值引用只能引用右值,但右值引用一定不能引用左值嗎?因為:有些場景下,可能真的需要用右值去引用左值實現移動語義。當需要用右值引用引用一個左值時,可以通過move函數將左值轉化為右值。C++11中,std::move()函數位于<utility>頭文件中,該函數名字具有迷惑性,它并不搬移任何東西,唯一的功能就是將一個左值強制轉化為右值引用,然后實現移動語義。
來看下move函數的定義:
template<class _Ty> inline typename remove_reference<_Ty>::type&& move(_Ty&& _Arg) _NOEXCEPT {// forward _Arg as movablereturn ((typename remove_reference<_Ty>::type&&)_Arg); }注意:
- move函數中_Arg參數的類型不是右值引用,而是萬能引用。萬能引用跟右值引用的形式一樣,但是右值引用需要是確定的類型。
- 一個左值被move以后,它的資源可能就被轉移給別人了,因此要慎用一個被move后的左值。
測試如下:
int main() {cpp::string s1("hello world");// 這里s1是左值,調用的是拷貝構造cpp::string s2(s1);//string(const string& s) -- 深拷貝// 這里我們把s1 move處理以后, 會被當成右值,調用移動構造// 但是這里要注意,一般是不要這樣用的,因為我們會發現s1的// 資源被轉移給了s3,s1被置空了。cpp::string s3(std::move(s1));//string(string&& s) -- 移動構造return 0; }右值引用的其它場景(插入接口)
C++11后STL容器中的插入接口函數也增加了右值引用的版本:
注意:
- C++98的時候,push_back函數只有const左值引用版本,所以這就會導致無論是左值還是右值都會被傳入這個左值引用版本的push_back,勢必會引發后續的深拷貝而帶來的開銷過大等問題。
- C++11出來后,push_back函數增加了右值引用版本,如果傳入push_back函數的是一個右值,那么在push_back函數構造節點時,這個右值就可以匹配到容器的移動構造函數進行資源的轉移,這樣就避免了深拷貝,提高了效率。
示例:
int main() {list<cpp::string> lt;cpp::string s1("1111");// 這里調用的是拷貝構造lt.push_back(s1);//string(const string& s) -- 深拷貝// 下面調用都是移動構造5lt.push_back("2222");//string(string&& s) -- 移動構造lt.push_back(std::move(s1));//string(string&& s) -- 移動構造return 0; }上述代碼中的插入第一個元素s1就會匹配到push_back的左值引用版本,在push_back函數內部就會調用string的拷貝構造函數進行深拷貝,而后面插入的兩個元素時由于傳入的是右值,因此會匹配到push_back的右值引用版本,此時在push_back函數內部就會調用string的移動構造函數進行資源的轉移。
3、完美轉發
萬能引用&&
&&應用在模板中時,不代表右值引用,而是萬能引用,萬能引用既能接收左值,也能接收右值。
template<typename T> void PerfectForward(T&& t)//萬能引用 {//…… }萬能引用的作用:
示例:
void Fun(int& x) { cout << "左值引用" << endl; } void Fun(const int& x) { cout << "const 左值引用" << endl; } void Fun(int&& x) { cout << "右值引用" << endl; } void Fun(const int&& x) { cout << "const 右值引用" << endl; } template<typename T> void PerfectForward(T&& t) {Fun(t); } int main() {PerfectForward(10);//右值int a;PerfectForward(a);//左值PerfectForward(std::move(a));//右值const int b = 8;PerfectForward(b);//const左值PerfectForward(std::move(b));//const右值return 0; }注意看上面的Fun函數我寫了四個,分別是左值引用、const左值引用、右值引用、const右值引用。main函數中我把左值、右值、const左值、const右值均作為參數傳入了函數模板PerfectForward里頭,因為其參數類型是萬能引用&&,所以既可以接收左值也可以接收右值,可是最終的測試結果令我們大跌眼鏡:
- 實際傳入PerfectForward函數模板的左值和右值均匹配到了左值引用版本的Fun函數,而傳入PerfectForward函數模板的const左值和const右值均匹配到了const左值引用版本的Fun函數。
- 造成此現象的根本原因在于右值被引用后會導致右值被存儲到特定位置,這時這個右值可以被取到地址,并且可以被修改,所以在PerfectForward函數中調用Func函數時會將t識別成左值。
這也就是我們上文所提到的萬能引用限制了接收的類型,在后續使用中均退化成了左值,但是我們希望能夠在傳遞過程中保持它的左值或者右值的屬性, 就需要用我們下面學習的完美轉發。
forward完美轉發在傳參的過程中保留對象原生類型屬性
我們想要在傳參的過程中保留對象的原生類型屬性,就需要用到forward函數:
template<typename T> void PerfectForward(T&& t) {//完美轉發Fun(std::forward<T>(t));//std::forward<T>(t)在傳參的過程中保持了t的原生類型屬性。 }測試結果如下:
完美轉發后,左值、右值、左值引用、右值引用就可以被傳入到理想狀態下的函數接口了。
完美轉發的使用場景
這里我們把先前模擬實現的list拖過來做測試案例,鏈接:list的模擬實現
- 先前實現的list是沒有對push_back函數和insert函數寫一個右值引用版本的,所以這就會導致無論數據是左值還是右值都會傳入左值引用的版本,勢必在構建節點的時候引發深拷貝,測試代碼如下:
為了避免深拷貝帶來的開銷過大,我們對push_back和insert函數單獨寫一個右值引用的版本,同樣也要對構造函數寫一個右值引用的版本,因為創建節點需要用到節點類的構造函數:
//節點類 template<class T> struct list_node {//……//右值引用節點類構造函數list_node(T&& val):_next(nullptr), _prev(nullptr), _data(val){} }; template<class T> class list { public://……//右值引用版本的push_backvoid push_back(T&& xx){insert(end(), xx);}//右值引用版本的insertiterator insert(iterator pos, T&& xx){Node* newnode = new Node(xx);//創建新的結點Node* cur = pos._node; //迭代器pos處的結點指針Node* prev = cur->_prev;//prev newnode cur//鏈接prev和newnodeprev->_next = newnode;newnode->_prev = prev;//鏈接newnode和curnewnode->_next = cur;cur->_prev = newnode;//返回新插入元素的迭代器位置return iterator(newnode);} private:Node* _head; }雖然這里實現了右值引用版本,但是實際的運行結果依然是深拷貝的,和沒寫之前的運行結果一模一樣,原因如下:
- 根據先前的了解我們得知:&&應用在模板中時,不代表右值引用,而是萬能引用,萬能引用既能接收左值,也能接收右值。但是在后續的使用中,會把接收的類型全部退化成左值,既然退化成左值,那么自然會進入后續的深拷貝。
此情況就是典型的完美轉發的使用場景,解決辦法如下:
- 我們需要在傳參的過程中保留對象的原生類型屬性,就需要用到forward函數:
修改好后再次運行,結果就是我們想要的啦:
總結
以上是生活随笔為你收集整理的【 C++11 】右值引用和移动语义的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: memset的使用方法
- 下一篇: C++11特性——右值引用