一篇文章弄懂C++左值引用和右值引用
篇幅較長,算是從0開始介紹的,請耐心看~
該篇介紹瞭左值和右值的區別、左值引用的概念、右值引用的概念、std::move()的本質、移動構造函數、移動復制運算符和RVO。
1. 左值和右值
首先來介紹一下左值和右值的區別,內容參考於《C++ primer 5th》4.1。
當一個對象被用作右值的時候,用的是對象的值(內容);當對象被用作左值的時候,用的是對象的身份(在內存中的位置)。(受對象用途影響)
原則:在需要右值的地方可以用左值代替,但是不能把右值當成左值使用(對象移動除外)。當一個左值代替右值使用時,實際使用的是它的內容(值)。
在C++中,不能單純的說,左值可以位於賦值語句的左側,但右值不可以。如:以常量對象為代表的某些左值並不能作為賦值語句的左側運算對象。例:
const int MAX_LEN = 10; // MAX_LEN是左值 MAX_LEN = 5; // 錯誤:試圖向const對象賦值。 左值不能位於賦值語句的左側。
網絡上有一種說法,左值位於賦值語句的左側,右值位於右側(這句話不是相對於變量說的)。例:
int a = 1; // a是左值(在內存中的位置), 1是右值(內容),不能給1賦值 int y = a; // 用左值代替右值,把內容賦值給y。a依然是左值,可以取地址。但是在該表達式中,左值代替右值使用,所以賦值語句右邊也可以說是右值,隻不過是a的內容。
用到左值的運算符:
- 賦值運算符需要一個(非常量)左值作為其運算對象,得到的結果也仍然是一個左值。
- 取地址符作用於一個左值運算對象,返回一個指向該運算對象的指針,這個指針是一個右值(無法放到賦值語句的左側,進行賦值)。
- 內置解引用運算符、下標運算符、迭代器解引用運算符、string和vertor的下標運算符。
- 內置類型和迭代器的遞增遞減運算符作用於左值運算對象,其前置版本所得的結果也是左值。
總結:常量、有地址的變量一定是左值,臨時值是右值。左值可以當成右值用。
2. 左值引用
左值引用:引用是變量的別名,指向左值。但const左值引用除外,由於const的不可變性,所以const引用可以指向右值,我們經常使用const引用作為函數參數傳遞。例:
int a = 1; int &b = a; // 正確 int &c = 10; // 錯誤:10是右值。 const int &d = 10; // 正確
關於左值引用的更多內容,可以參考我的另一篇文章(建議看完左值引用再來看這篇):深入理解左值引用 https://zhuanlan.zhihu.com/p/390611356
3. 右值引用
內容參考於《C++ primer 5th》13.6。
3.1 出現
在重新分配內存的過程中,從舊元素將元素拷貝到新內存是不必要的,更好的方式是移動元素。還有一些可以移動但不能拷貝的類,如:IO類和unique_ptr類。索所以,為瞭支持移動操作,新標準引入瞭一種新的引用類型——右值引用。
3.2 概念
右值引用:必須綁定到右值的引用,且隻能綁定到一個將要銷毀的對象。所以,可以自由地將一個右值引用地資源“移動”到另一個對象中。通過&&獲得右值引用(也可以說,接管對象的控制權)。例:
int a = 1; int &b = a; // 正確:左值引用,a是左值 int &&c = a; // 錯誤:右值引用,不能綁定到一個左值上 int &d = a*3; // 錯誤:左值引用,a*3是右值 const int &e = a*3; // 正確:左值引用,const引用可以綁定到一個右值上 int &&f = a*3; // 正確:右值引用,a*3是右值 int &&g = 10; // 正確:右值引用,10是右值
變量表達式依然是左值,例:
int &&a = 10; // 正確:10是右值 int &&b = a; // 錯誤:即使a是右值引用,但a依然是左值,a不是臨時對象
從上面的例子,可以看到:左值引用有持久的狀態;右值要麼是字面常量,要麼是在表達式求值過程中創建的臨時對象。
由於右值引用隻能綁定到臨時對象(不管編譯器怎麼做,但這個我們需要遵守),所以:
所引用的對象將要被銷毀
該對象沒有其他用戶(保證安全,在使用的時候一定要特別確定這一點)
而且,使用右值的代碼可以自由地接管所引用的對象的資源。
在移動之後,要謹慎操作原對象,一般不操作,因為我們不確定移動操作做瞭哪些內容,原對象也是處於一種不確定的狀態。
我們一般不會使用const右值引用,當然,編譯器也不會報錯。(和右值引用的目的沖突)
3.3 應用
3.3.1 右值引用綁定到左值上
在左值與右值的區別中,我們知道左值是可以代替右值的。那麼右值引用是不是可以引用到“左值”上呢?答案是可以的,新版標準庫給我們提供瞭一個函數——move(),該函數的含義是:告訴編譯器,雖然我們有一個左值,但是我們希望可以像右值一樣處理。例:
#include <utility> int a = 1; int &&c = a; // 錯誤:右值引用,不能綁定到一個左值上 int &&h = std::move(a); // 正確:使用std::move()把a當成右值處理。
3.3.2 std::move()本質
首先,我們來看一下std::move源碼:
// xtr1common文件 // STRUCT TEMPLATE remove_reference template<class _Ty> struct remove_reference { // remove reference using type = _Ty; }; // xtr1common文件 template<class _Ty> using remove_reference_t = typename remove_reference<_Ty>::type; // type_traits文件 // FUNCTION TEMPLATE move template<class _Ty> _NODISCARD constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable return (static_cast<remove_reference_t<_Ty>&&>(_Arg)); }
從源碼中,可以得知:std::move既可以傳入一個左值也可以傳入一個右值,如果是左值(這裡,傳入的左值的類型是T&而不是T),則將一個左值轉換成右值(_Ty& &&會被折疊成_Ty&, type是T)。其實std::move的作用僅僅是將左值轉換成右值,也就是一次類型轉換:static_cast<_Ty&&>(_Arg)。也就是說,std::move其實不“移動”,隻是轉換成右值引用。例:
int v = 5; // 正確:v是左值 int &&r_ref = 8; // 正確:re_ref引用右值 int &&r_ref_move = std::move(v); // 正確:r_ref_move=5, v是左值,std::move做瞭一次類型轉換。_Ty& &&會被折疊成_Ty&。(賦值之後,v的值是不確定的,這個受移動賦值運算符裡的內容影響) int &&r_ref_move2 = std::move(r_ref_move); // 正確:r_ref_move2=5, r_ref_move是左值,std::move做瞭一次類型轉換 int &&r_ref_move3 = std::move("hello"); // 正確:可以給一個std::move傳遞一個右值 v = 9; // 正確:v、r_ref_move、r_ref_move2=9 r_ref_move = 10; // 正確:v、r_ref_move、r_ref_move2=10 r_ref_move2 = 11; // 正確:v、r_ref_move、r_ref_move2=11
3.3.3 移動構造函數和移動賦值運算符
接下來,介紹一下移動構造函數和移動賦值運算符,這兩個是右值引用的典型例子。
移動拷貝構造函數
移動構造函數中的第一個參數是該類類型的一個右值引用,本質是在轉移對象的控制權。所以我們需要先更新新對象的指針,然後把原對象中的指針置為nullptr。
下面看一個例子:
// 不考慮規范,僅僅是一個例子class MyClass{ // 移動構造函數 // noexcept不拋出異常 MyClass(MyClass &&c) noexcept; // ...private: std::string *p;};// 接管c中的內存,不分配任何新內存(與拷貝構造函數不同)MyClass::MyClass(MyClass &&c) noexcept : p(c.p){ // 對c運行析構函數是安全的(確保原對象進入可析構的狀態) c.p = nullptr;}
移動賦值運算符
移動賦值運算符寫法如下:
// 不考慮規范,僅僅是一個例子class MyClass{ // 移動賦值運算符 MyClass& operator=(MyClass &&c) noexcept; // ...private: std::string *p;};MyClass& MyClass::operator=(MyClass &&c) noexcept{ // 檢查自賦值:不能在使用右側運算對象的資源之前舊釋放左側運算對象的資源(可能是相同的資源) if (this != &c) { free(); //釋放已有元素 p = c.p; c.p = nullptr; } return *this;}
關於異常
不拋出異常的移動構造函數和移動賦值運算符必須標記為noexcept。
但是,移動構造函數也可能出現異常,這個時候就不能聲明為noexcept。比如:vector的增長,可能會導致內存的重新分配。使用移動構造函數和拷貝構造函數的結果會不同:
- 如果使用移動構造函數,很有可能移動瞭部分元素後出現異常,這樣會導致——舊空間中的元素已經被改變,新空間中未構造的元素尚不存在。
- 如果使用拷貝構造函數出現異常,則很容易處理。當在新內存中構造元素時,舊元素保持不變。如果此時發生異常,vector可以釋放新分配的內存並返回,vector原有的元素不變。
- 在重新分配內存的過程中,必須使用拷貝構造函數而不是移動構造函數。(這就是noexcept的作用,讓編譯器決定是否調用移動構造函數)
合成的移動操作
我們需要註意:如果一個類沒有移動操作,類會使用對應的拷貝操作來代替移動操作。編譯器可以將一個T&&轉換成const T&,然後調用拷貝構造函數。所以,並不是使用瞭移動就一定可以提升性能。當然,我們可以在自定義類中自己聲明定義移動操作。
那麼如果我們沒有聲明定義移動操作,編譯器什麼時候合成默認的移動函數呢?答案是:一個類沒有定義任何自己版本的拷貝控制成員,且類的每個非static數據成員都可以移動時,合成。具體要求如下(忘記出處瞭,好像是某個翻譯過來的…):
- 如果發生以下情況,編譯器將生成移動構造函數(move constructor)
- 用戶未聲明拷貝構造函數(copy constructor)
- 用戶未聲明拷貝賦值運算符(copy assignment operator)
- 用戶未聲明移動賦值運算符(move assignment operator)
- 用戶未聲明析構函數(destructor)
- 該類未被標記為已刪除(delete)
- 所有非static成員均為可移動的(moveable)
- 如果發生以下情況,編譯器將生成移動賦值運算符(move assignment operator)
- 用戶未聲明拷貝構造函數(copy constructor)
- 用戶未聲明拷貝賦值運算符(copy assignment operator)
- 用戶未聲明移動構造函數(move constructor)
- 用戶未聲明析構函數(destructor)
- 該類未被標記為已刪除(delete)
- 所有非static成員均為可移動的(moveable)
而且,移動操作永遠不會隱式定義為刪除的函數。但是,我們如果我們使用=default顯示地要求編譯器生成默認移動操作,且編譯器不能移動所有成員,編譯器會將移動操作定義為刪除的函數(安全)。
需要註意的幾點:
- 如果有類成員的移動構造函數或移動賦值運算符被定義為刪除的或是不可訪問的,則類的移動構造函數或移動賦值運算符被定義為刪除的。
- 如果有類的析構函數被定義為刪除的或是不可訪問的,則類的移動構造函數被定義為刪除的。
- 如果有類的成員是const的或是引用的,則類的移動賦值運算符被定義為刪除的。
定義瞭一個移動構造函數或移動賦值運算符的類必須也定義自己的拷貝操作。否則,這些成員默認地被定義為刪除的。
三/五原則:定義一個類時,建議定義拷貝構造函數、拷貝賦值運算符、析構函數,當需要拷貝資源時,建議也定義移動構造函數、移動賦值運算符。C++並不要求我們定義所有的操作,但是這些操作通常被看成一個整體。
3.3.4 std::move()的一個例子
來源《C++程序設計語言》
來看一下交換函數:
// 一種比較常規的寫法template<class T>void swap(T&a, T&b){ T tmp{a}; a = b; b = tmp;}// 當遇到string、vector這類類型的交換,第一種方法的拷貝將會造成很大的花費,所以出現下面的一種寫法:template<class T>void swap(T&a, T&b){ T tmp{static_cast<T&&>(a)}; a = static_cast<T&&>(b); b = static_cast<T&&>(tmp);}// 由於move函數的本質是static_cast<T&&>,所以對上面的函數還可以優化一下寫法template<class T>void swap(T&a, T&b){ T tmp{std::move(a)}; a = std::move(b); b = std::move(tmp);}
在這個例子中,如果類型T存在移動賦值運算符,那麼運算性可能會提高。
4. 補充—協助完成返回值優化(RVO)
來源:《More Effective C++》條款20、《Effective C++》條款21、《C++標準庫》3.1.5
例1:
X foo(){ X x; ... return x;}
對於例1:
- 如果X有一個可取用的copy或move構造函數,編譯器可以選擇略去其中的copy版本,即RVO。(平常簡單的返回std::move()可能會出錯,這要看優化方式以及編譯器怎麼處理瞭)
- 否則,如果X有一個move構造函數,X就被moved(搬移)。
- 否則,如果X有一個copy構造函數,X就被copied(復制)。
- 否則,報出一個編譯器錯誤。
例2:
X&& foo(){ X x; ... return std::move(x);}
對於例2,該函數返回的是一個local nonstatic對象,返回右值引用是有風險的。具體看編譯器優化。(當然,最好不這樣使用。)
例3:
// 對於返回一個對象的函數進行優化。// Rational為分數類,numerator是分子,denominator是分母。// plan 1:返回指針,但是寫法很難看(Rational c = *(a*b)),而且可能會導致資源泄露(忘記刪除函數返回的指針)。const Rational* operator*(const Rational& lhs, const Rational& rhs);// plan 2:必須付出一個構造函數調用的代價,且可能會導致資源泄露const Rational& operator*(const Rational& lhs, const Rational& rhs){ Rational* result = new Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()) return *result;}// plan 3:返回引用,在函數退出前,result已經被銷毀。所以,引用指向一個不再存活的對象,會很危險且不正確。const Rational& operator*(const Rational& lhs, const Rational& rhs){ Rational result(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()) return result; // 局部非靜態對象}// 所以,如果函數一定得以值方式返回對象,是無法消除的。所以隻能盡可能地降低對象返回的成本,而不是想盡辦法消除對象本身。// plan 4:有效率且正確的方法。雖然我們構造瞭臨時對象,但是C++允許編譯器將臨時對象優化,使它們不存在。編譯器優化後,調用operator*時沒有任何臨時對象被調用出來。隻需要一個constructor(用以產生c的代價)。const Rational operator*(const Rational& lhs, const Rational& rhs){ return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator())}// plan 5:最有效率的做法。使用inline消除調用operator*的函數開銷。inline const Rational operator*(const Rational& lhs, const Rational& rhs){ return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator())}Rational a = 10;Rational b(1,2);Rational c = a*b;
5. 總結
移動並不移動,隻是轉移控制權。
std::move()隻是做瞭一次類型轉換,轉換成一個右值引用,然後方便後續操作,比如:構造、賦值等。真正的內存管理,是交由移動構造、移動賦值等移動操作處理的。有沒有性能優化,要看有沒有移動操作以及移動操作的處理。
到此這篇關於C++左值引用和右值引用的文章就介紹到這瞭,更多相關C++左值引用右值引用內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!