目錄
- 淺拷貝、深拷貝
- 左值、右值
- 右值參考型別
- 強轉右值 std::move
- 重新審視右值、右值參考
- 右值參考型別和右值的關系
- 左值、右值、純右值、將亡值
- 函式引數傳遞
- 函式返還值傳遞
- 萬能參考
- 參考折疊
- 完美轉發 std::forward
- 參考
C++11出現的右值相關語法可謂是很多C++程式員難以理解的新特性,不少人知其然而不知其所以然,面試被問到時大概就只知道可以減少開銷,但是為什么減少開銷、減少了多少開銷、什么時候用...這些問題也不一定知道,于是我寫下了這篇夾帶自己理解的博文,希望它對你有所幫助,
淺拷貝、深拷貝
在介紹右值參考等概念之前,可以先來認識下淺拷貝(shallow copy)和深拷貝(deep copy),
這里舉個例子:
class Vector{
int num;
int* a;
public:
void ShallowCopy(Vector& v);
void DeepCopy(Vector& v);
};
- 淺拷貝:按位拷貝物件,創建的新物件有著原始物件屬性值的一份精確拷貝(但不包括指標指向的記憶體),
//淺拷貝
void Vector::ShallowCopy(Vector& v){
this.num = v.num;
this.a = v.a;
}
- 深拷貝:拷貝所有的屬性(包括屬性指向的動態分配的記憶體),換句話說,當物件和它所參考的物件一起拷貝時即發生深拷貝,
//深拷貝
void Vector::DeepCopy(Vector& v){
this.num = v.num;
this.a = new int[num];
for(int i=0;i<num;++i){a[i]=v.a[i]}
}
可以看到,深拷貝的開銷往往比淺拷貝大(除非沒有指向動態分配記憶體的屬性),所以我們就傾向盡可能使用淺拷貝,
但是淺拷貝的有一個問題:當有指向動態分配記憶體的屬性時,會造成多個物件共用這塊動態分配記憶體,從而可能導致沖突,一個可行的辦法是:每次做淺拷貝后,必須保證原始物件不再訪問這塊記憶體(即轉移所有權給新物件),這樣就保證這塊記憶體永遠只被一個物件使用,
那有什么物件在被拷貝后可以保證不再訪問這塊記憶體呢?相信大家心里都有答案:臨時物件,
左值、右值
C++在C++98時便遵循C模型,引入了左值、右值的概念,
- 左值(lvalue) :運算式結束后依然存在的持久物件,
- 右值(rvalue) :運算式結束后就不再存在的臨時物件,
之所以取名左值右值,是因為在等式左邊的值往往是持久存在的左值型別,在等式右邊的運算式值往往是臨時物件,
a = 152;
a = ++b;
a = b+c*2;
a = func();
字面量(字符字面量除外)、臨時的運算式值、臨時的函式返還值這些短暫存在的值都是右值,更直觀的理解是:有變數名的物件都是左值,沒有變數名的都是右值,(因為有無變數名意味著這個物件是否在下一行代碼時依然存在)
值得注意的是,字符字面量是唯一不可算入右值的字面量,因為它實際存盤在靜態記憶體區,是持久存在的,
右值參考型別
有了左值、右值的概念,我們就很清楚認識到右值都是些短暫存在的臨時物件,
于是,C++11 為了匹配這些左右值,引入了右值參考型別 && ,
右值參考型別負責匹配右值,左值參考則負責匹配左值,
因此剛剛的淺拷貝、深拷貝例子,我們可以無需顯式呼叫淺拷貝或深拷貝函式,而是呼叫多載函式:
//左值參考形參=>匹配左值
void Vector::Copy(Vector& v){
this.num = v.num;
this.a = new int[num];
for(int i=0;i<num;++i){a[i]=v.a[i]}
}
//右值參考形參=>匹配右值
void Vector::Copy(Vector&& temp){
this.num = temp.num;
this.a = temp.a;
}
當然,最標準還是撰寫成各種建構式(拷貝構造、移動構造、賦值構造、移動賦值構造):
移動的意思是轉移所有權,由于右值都是臨時的值(右值其實也不一定是臨時值,后文會說到),臨時值釋放后也就不再持有屬性的所有權,因此這相當于轉移資源所有權的行為,
//拷貝建構式:這意味著深拷貝
Vector::Vector(Vector& v){
this.num = v.num;
this.a = new int[num];
for(int i=0;i<num;++i){a[i]=v.a[i]}
}
//移動建構式:這意味著淺拷貝
Vector::Vector(Vector&& temp){
this.num = temp.num;
this.a = temp.a;
temp.a = nullptr; //實際上Vector一般都會在解構式來釋放指向的記憶體,所以需賦值空地址避免釋放
}
雖然從優雅的實作深、淺拷貝這個目的開始出發,C++11的移動語意可以不止用于淺拷貝,得益于轉移所有權的特性,我們還可以做其它事情,例如在右值所占有的空間臨時存放一些東西,
強轉右值 std::move
除了上面說的臨時值,有些左值其實也很適合轉移所有權:
void func(){
Vector result;
//...DoSomehing with result
if(xxx){ans = result;} //現在我希望把結果提取到外部的變數a上,
return;
}
可以看到result賦值給ans后就不再被使用,我們期望它呼叫的是移動賦值建構式,
但是result是一個有變數名的左值型別,因此ans = result 呼叫的是賦值建構式而非移動賦值建構式,
為了將某些左值當成右值使用,C++11 提供了 std::move 函式以用于將某些左值轉成右值,以匹配右值參考型別,
void func(){
Vector result;
//...DoSomehing with result
if(xxx){ans = std::move(result);} //呼叫的是移動賦值建構式
return;
}
重新審視右值、右值參考
右值參考型別和右值的關系
有了上面的知識后,我們來重新審視一下右值參考型別,
先看看如下代碼:
void test(Vector& o) {std::cout << "為左值," << std::endl;}
void test(Vector&& temp) {std::cout << "為右值," << std::endl;}
int main(){
Vector a;
Vector&& b = Vector();
//請分別回答:a、std::move(a)、b 分別是左值還是右值?
test(a);
test(std::move(a));
test(b);
}
答:a是左值,std::move(a)是右值,但b卻是左值,
在這里b雖然是 Vector&& 型別,但卻因為有變數名(即可持久存在),被編譯器認為是左值,
結論:右值參考型別只是用于匹配右值,而并非表示一個右值,因此,盡量不要宣告右值參考型別的變數,而只在函式形參使用它以匹配右值,
左值、右值、純右值、將亡值
前面對移動語意的認識我們都是基于C++98時左值、右值概念,而C++11對左值、右值類別被重新進行了定義,因此現在我們重新認識一下新的類別,
C++11使用下面兩種獨立的性質來區別類別:
- 擁有身份:指代某個非臨時物件,
- 可被移動:可被右值參考型別匹配,
每個C++運算式只屬于三種基本值類別中的一種:左值 (lvalue)、純右值 (prvalue)、將亡值 (xvalue)
- 擁有身份且不可被移動的運算式被稱作 左值 (lvalue) 運算式,指持久存在的物件或型別為左值參考型別的返還值,
- 擁有身份且可被移動的運算式被稱作 將亡值 (xvalue) 運算式,一般是指型別為右值參考型別的返還值,
- 不擁有身份且可被移動的運算式被稱作 純右值 (prvalue) 運算式,也就是指純粹的臨時值(即使指代的物件是持久存在的),
- 不擁有身份且不可被移動的運算式無法使用,
如此分類是因為移動語意的出現,需要對類別重新規范說明,例如不能簡單定義說右值就是臨時值(因為也可能是std::move過的物件,該代指物件并不一定是臨時值),
Vector& func1();
Vector&& func2();
Vector func3();
int main(){
Vector a;
a; //左值運算式
func1(); //左值運算式,返還值是臨時的,返還型別是左值參考,因此被認為不可移動,
func2(); //將亡值運算式,返還值是臨時的,返還型別是右值參考,因此指代的物件即使非臨時也會被認為可移動,
func3(); //純右值運算式,返還值為臨時值,
std::move(a); //將亡值運算式,std::move本質也是個函式,同上,
Vector(); //純右值運算式
}
而現在我們可以歸納為:
- 左值(lvalue) 指持久存在(有變數名)的物件或返還值型別為左值參考的返還值,是不可移動的,
- 右值(rvalue) 包含了 將亡值、純右值,是可移動(可被右值參考型別匹配)的值,
實際上C++ std::move函式的實作原理就是的強轉成右值參考型別并返還之,因此該返還值會被判斷為將亡值,更寬泛的說是被判定為右值,
函式引數傳遞
void func1(Vector v) {return;}
void func2(Vector && v) {return;}
int main() {
Vector a;
Vector &b = a;
Vector c;
Vector d;
//請回答:不開優化的版本下,呼叫以下函式分別有多少Copy Consturct、Move Construct的開銷?
func1(a);
func1(b);
func1(std::move(c));
func2(std::move(d));
}
實際上在不開優化的版本下,如果實參為右值,呼叫func1的開銷只比func2多了一次移動建構式和解構式,
實參傳遞給形參,即形參會根據實參來構造,其結果是呼叫了移動建構式;函式結束時則釋放形參,
倘若說物件的移動建構式開銷較低(例如內部僅一個指標屬性),那么使用無參考型別的形參函式是更優雅的選擇,而且還能接受左值參考型別或無參考的實參(盡管這兩種實參都會導致一次Copy Consturct),可以說,這種情況下,只提供非參考型別的版本,也是可以接受的,
那我們在寫一般函式形參的時候,若引數有支持移動構造(或移動賦值)的型別,是否有必要每個函式都提供關于&&形參的多載版本嗎?
回答:從極致的優化角度來看是有必要的,應該提供右值參考型別的多載版本,更準確說應該同時提供左值參考(匹配左值)和右值參考(匹配右值)兩種多載版本,
這里糾正了以前的說法,
函式返還值傳遞
Vector func1() {
Vector a;
return a;
}
Vector func2() {
Vector a;
return std::move(a);
}
Vector&& func3() {
Vector a;
return std::move(a);
}
int main() {
//請回答:不開優化的版本下,執行以下3行代碼分別有多少Copy Consturct、Move Construct的開銷?
Vector test1 = func1();
Vector test2 = func2();
Vector test3 = func3();
}
同樣的道理,執行這3行代碼實際上都沒有任何Copy Construct的開銷(這其中也有NRV技術的功勞),都是只有一次Move Construct的開銷,
此外一提,func3是危險的,因為區域變數釋放后,函式返還值仍持有它的右值參考,
因此,這里也不建議函式返還右值參考型別,同前面傳遞引數類似的,移動構造開銷不大的時候,直接返還非參考型別就足夠了(在某些特殊場合有特別作用,準確來說一般用于表示返還成一個右值,如std::move的實作),
結論:
1. 我們應該首先把撰寫右值參考型別相關的任務重點放在物件的構造、賦值函式上,從源頭上出發,在撰寫其它代碼時會自然而然享受到了移動構造、移動賦值的優化效果,
2. 形參:從優化的角度上看,若引數有支持移動構造(或移動賦值)的型別,應提供左值參考和右值參考的多載版本,移動開銷很低時,只提供一個非參考型別的版本也是可以接受的,
3. 返還值:不要且沒必要撰寫返還右值參考型別的函式,除非有特殊用途,
萬能參考
接下來的內容都是屬于模板的部分了:萬能參考、參考折疊、完美轉發,這部分更加難以理解,不撰寫模板代碼的話可以繞道了,
萬能參考(Universal Reference):
- 發生型別推導(例如模板、auto)的時候,使用T&&型別表示為萬能參考,否則表示右值參考,
- 萬能參考型別的形參既能匹配任意參考型別的左值、右值,
也就是說撰寫模板函式時,只提供萬能參考形參一個版本就可以匹配左值、右值,不必撰寫多個多載版本,
template<class T>
void func(T&& t){
return;
}
int main() {
Vector a,b;
func(a); //OK
func(std::move(b)); //OK
}
此外需要注意的是,使用萬能參考引數的函式是最貪婪的函式,容易讓需要隱式轉換的實參匹配到不希望的轉發參考函式,例如下面代碼:
template<class T>
void f(T&& value);
void f(int a);
//當呼叫f(long型別的引數)或者f(short型別的引數),則不會匹配int版本而是匹配到萬能參考的版本
參考折疊
使用萬能參考遇到的第一個問題是推導型別會出現不正確的參考型別:例如當模板引數T為Vector&或Vector&&,模板函式形參為T&&時,展開后變成Vector& &&或者Vector&& &&,
template<class T>
void func(T&& t){
return;
}
int main(){
func(Vector()); //模板引數T被推導為Vector&&
}
但顯然C++中是不允許對參考再進行參考的,于是為了讓模板引數正確傳遞參考性質,C++定義了一套用于推導型別的參考折疊(Reference Collapse)規則:
所有的折疊參考最終都代表一個參考,要么是左值參考,要么是右值參考,
| 參考折疊 | & | && |
|---|---|---|
| & | & | & |
| && | & | && |
Example1:
func(Vector());
模板函式func的T被推導為Vector&&,形參object為T&&即展開后為Vector&& &&,由于折疊規則的存在,形參object最終被折疊推導為Vector&&型別,
Example2:
func(a);
模板函式func的T在這里被推導為Vector&,形參object為T&&即展開后為Vector& &&,由于折疊規則的存在,形參object最終被推導為Vector&型別,
完美轉發 std::forward<T>
當我們使用了萬能參考時,即使可以同時匹配左值、右值,但需要轉發引數給其他函式時,會丟失參考性質(形參是個左值,從而無法判斷到底匹配的是個左值還是右值),
//當然我們也可以寫成如下多載代碼,但是這已經違背了使用萬能參考的初衷(僅撰寫一個模板函式就可以匹配左值、右值)
template<class T>
void func(T& t){
doSomething(t);
}
template<class T>
void func(T&& t){
doSomething(std::move(t));
}
完美轉發(Perfect Forwarding):C++11提供了完美轉發函式 std:forward<T> ,它可以在模板函式內給另一個函式傳遞引數時,將引數型別保持原本狀態傳入(如果形參推匯出是右值參考則作為右值傳入,如果是左值參考則作為左值傳入),
于是現在我們可以這樣做了:
template<class T>
void func(T&& object){
doSomething(std::forward<T>(object));
}
不借助std::forward<T>間接傳入引數的話,無論object是左值參考型別,還是右值參考型別,都會被視為左值,
std::forward<T>()的實作主要就一句return static_cast<T&&>(形參),實際上也是利用了折疊規則,從而接受右值參考型別時,將右值參考型別的值返還(返還值為右值),接受左值參考型別時,將左值參考型別的值返還(返還值為左值),
而std::move<T>()的實作還需要先移除形參的所有參考性質得到無參考性質的型別(假設為T2),然后再return static_cast<T2&&>(形參),從而保證不會發生參考折疊,而是直接作為右值參考型別的值返還(返還值為右值),
參考
[1] value_category | cppreference.com
[2] move_constructor | cppreference.com
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/30642.html
標籤:C++
上一篇:STL之list
下一篇:二叉排序樹
