1. 左值和右值
- 左值(L-value):能用“取地址&”運算符獲得對象的內存地址,表達式結束后依然存在的持久化對象。左值可以出現在等號左邊也能夠出現在等號右邊。
- 右值(R-value):不能用“取地址&”運算符獲得對象的內存地址,表達式結束后就不再存在的臨時對象。只能出現在等號右邊。
- 可以做出以下三點理解:
1)當一個對象被用作右值的時候,用的是對象的值(內容);而被用作左值的時候,用的是對象的身份(在內存中的位置)。總之:左值看地址,右值看內容。
2)所有的具名變量或者對象都是左值,而右值不具名,如常見的右值有非引用返回的臨時變量、運算表達式產生的臨時變量、原始字面量和lambda表達式等。
很難得到左值和右值的真正定義,但是有一個可以區分左值和右值的便捷方法:看能不能對表達式取地址,如果能,則為左值,否則為右值。
3)右值要么是字面常量,要么是在表達式求值過程中創建的對象。
特例:因為可以用&取得字符串字面值常量的地址,雖然它不能被賦值,但它是一個左值。
int main() { char *p = "1234"; printf("%d\n", p); printf("%d\n", &"1234"); }
- 為什么右值不能用&取地址呢?
1)對於臨時對象,它可以存儲於寄存器中,所以沒辦法用“取地址&”運算符;
2)對於(非字符串)常量,它可能被編碼到機器指令的“立即數”中,所以沒辦法用“取地址&”運算符。
2. 左值引用和右值引用
使用引用的目的就在於減少不必要的拷貝。
- 左值引用:對左值的引用,就是給左值取別名。其基本語法如下:
Type &引用名 = 左值表達式;
- 變量名實質上是一段連續存儲空間的別名,是一個標號(門牌號),通過變量的名字可以使用存儲空間。
- 對一段連續的內存空間只能取一個別名嗎?
在C++中新增加了引用的概念,引用可以看作一個已定義變量的別名,於是我們就可以通過引用為一個內存空間取多個別名。
int main() { int a = 0; int &b = a; b = 11; return 0; }
- 普通引用在聲明時必須用其它的變量進行初始化,引用作為函數參數聲明時不進行初始化。
struct Teacher { char name[64]; int age; }; void printfT(Teacher *pT) { cout << pT->age << endl; } /* * pT是t1的別名, 相當於修改了t1 */ void printfT2(Teacher &pT) { pT.age = 33; } /* * pT和t1的是兩個不同的變量,t1 copy一份數據給pT, 只會修改pT變量 ,不會修改t1變量 */ void printfT3(Teacher pT) { pT.age = 45; } int main() { Teacher t1; t1.age = 35; printfT(&t1); printfT2(t1); printf("t1.age:%d\n", t1.age) // 33 printfT3(t1); printf("t1.age:%d\n", t1.age); //35 return 0; }
- 對於引用語法,C++編譯器背后做了什么工作呢?
首先我們知道引用單獨定義時,必須初始化,說明它很像一個常量。又因為引用是一個內存空間的別名所以它可以取地址。
故我們可以得到引用的本質:
1)引用在C++中的內部實現是一個常指針。Type& name <=> Type* const name
2) C++編譯器在編譯過程中使用常指針作為引用的內部實現,因此引用所占用的空間大小與指針相同。
3) 從使用的角度,引用會讓人誤會其只是一個別名,沒有自己的存儲空間。這是C++為了實用性而做出的細節隱藏。
- 函數返回值是引用(引用當左值)
當函數返回值為引用時,若返回棧變量,不能成為其它引用的初始值,不能作為左值使用。若返回靜態變量或全局變量,
可以成為其他引用的初始值,即可作為右值使用,也可作為左值使用。
對於引用的理解可以直接看成指針,因為棧變量在函數結束后,內存空間就被釋放了,所以這個指針指向的內容就不對了。
- 對指針的引用
struct Teacher { char name[64]; int age; }; // 指針的引用 int getTe(Teacher* &myp) { myp = (Teacher *)malloc(sizeof(Teacher)); myp->age = 34; return 0; } int main() { Teacher *p = NULL; getTe(p); printf("age:%d\n", p->age); return 0; }
- 常引用(const T &)
int main() { int a = 10; int &b = a; //普通引用 const int &c = a; //常量引用:只能通過c讀取a的內存空間 // 常量引用初始化分為兩種 // 1. 變量 初始化 常量引用 int x = 20; const int& y = x; printf("y:%d\n", y); // 2. 常量 初始化 常量引用 // int &m = 10; // 引用是內存空間的別名 字面量10沒有內存空間 沒有方法做引用 const int &m = 10; return 0; }
const引用結論
1)Const & int e 相當於 const int * const e
2)普通引用相當於 int *const e
3)當使用常量(字面量)這類右值對const引用進行初始化時,C++編譯器會為常量值分配空間,並將引用名作為這段空間的別名。
初始化后,將生成一個只讀變量。只有常引用才可以用右值表達式初始化,這一點很重要,因為如果不加const,那么這個
臨時的對象是無法進行傳遞給左值引用的,比如
MyString s = MyString("hello") // 這個臨時對象本身就存在於內存空間,所以無需為這個右值分配空間
因為MyString("hello")是一個臨時對象,即右值,所以MyString實現的拷貝構造函數參數不加const就會報錯。
- 右值引用:對右值的引用,就是給右值取別名。其基本語法如下:
Type &&引用名 = 右值表達式; // 如果是左值表達式,綁定就會出錯。這里雖然是個右值引用,但左側的具名變量本身是個左值
- 開始介紹右值引用之前,先得了解到底啥是臨時對象?
在C++中創建對象是一個費時、廢空間的一個操作,有些固然必不可少,但還有一些對象卻在我們不知道的情況下創建了。
1)以值的方式給函數傳參
給函數傳參有兩種方式----按值傳遞和按引用傳遞。按值傳遞時,首先將需要傳給函數的參數,調用拷貝構造函數創建
一個副本,所有在函數里的操作都是針對這個副本的,也正是因為這個原因,在函數體里對該副本進行任何操作,都不會影響原參數。
class Test { public: int a, b; public: Test(Test& t) : a(t.a), b(t.b) { printf("Copy function!\n"); } Test(int m = 0,int n = 0) : a(m), b(n) { printf("Construct function!\n"); } virtual ~Test() {} public: int GetSum(Test ts) { int tmp = ts.a + ts.b; ts.a = 1000; //此時修改的是tm的一個副本 return tmp; } }; int main() { Test tm(10,20); printf("Sum = %d \n",tm.GetSum(tm)); printf("tm.a = %d \n",tm.a); return 0; }
當函數執行結束后,這個臨時的對象就會被銷毀了。可以將 int GetSum(Test ts)改成 int GetSum(Test &ts) 來避免產生這個拷貝了。
2)類型轉換生成的臨時對象
int main() { Test tm(10,20), sum; sum = 1000; // 調用 Test(int m = 0,int n = 0) 構造函數,還會調用一次賦值運算符 printf("Sum = %d \n",tm.GetSum(sum)); }
3)函數返回一個對象
當函數需要返回一個對象,他會在棧中創建一個臨時對象或也叫匿名對象(如果是類對象,則會調用拷貝構造函數),存儲函數的返回值。
這個臨時對象在表達式 sum = Double(tm) 結束后就自動銷毀了,這個臨時對象就是右值。
按理說下面這個例子中Double函數返回時會觸發拷貝構造函數,但實際運行后卻沒有,猜想是被編譯器優化了,可以在編譯時設置編譯
選項-fno-elide-constructors用來關閉返回值優化效果。
class Test { public: int a; public: Test(Test& t) : a(t.a) { printf("Copy Construct!\n"); } Test(int m = 0) : a(m) { printf("Construct!\n"); } virtual ~Test() {}; public: Test& operator=(const Test& t) { a = t.a; printf("Assignment Operator!\n"); return *this; } }; Test Double(Test& ts) { Test tmp; tmp.a = ts.a * 2; return tmp; } int main() { Test tm(10), sum; sum = Double(tm); printf("sum.a = %d\n",sum.a); return 0; }
- 引入右值引用的目的:右值引用是C++11中新增加的一個很重要的特性,它主要用來解決以下問題。
1)函數返回臨時對象造成不必要的拷貝操作:通過使用右值引用,右值不會在表達式結束之后就銷毀了,而是會被“續命”,
的生命周期將會通過右值引用得以延續,和變量的聲明周期一樣長。
int g_constructCount = 0; int g_copyConstructCount = 0; int g_destructCount = 0; class Test { public: Test() { cout << "construct: " << ++g_constructCount << endl; } Test(const Test& a) { cout << "copy construct: " << ++g_copyConstructCount << endl; } ~Test() { cout << "destruct: " << ++g_destructCount << endl; } }; Test GetTestObj() { return Test(); } int main() { Test a = GetTestObj(); return 0; } // 上面代碼關掉返回值優化后輸出: construct: 1 // return Test() copy construct: 1 // 臨時對象構造 destruct: 1 // return Test()對象銷毀 copy construct: 2 // a對象構造 destruct: 2 // 臨時對象銷毀 destruct: 3 // a對象銷毀 //------------------------------------------------------------------------------------------------- // 但是如果使用右值引用來接收返回值呢? int main() { Test &&a = GetTestObj(); return 0; } // 輸出如下 construct: 1 // return Test() copy construct: 1 // 臨時對象構造 destruct: 1 // return Test()對象銷毀 destruct: 2 // a這個對象其實就是那個臨時對象了,main結束后才銷毀
通過右值引用,比之前少了一次拷貝構造和一次析構,原因在於右值引用綁定了右值,讓臨時右值的生命周期延長了。
我們可以利用這個特點做一些性能優化,即避免臨時對象的拷貝構造和析構。
2)通過右值引用傳遞臨時參數:使用字面值(如1、3.15f、true),或者表達式等臨時變量作為函數實參傳遞時,按左值引用傳遞參數會被編譯器阻止。
而進行值傳遞時,將產生一個和參數同等大小的副本。C++11提供了右值引用傳遞參數,不申請局部變量,也不會產生參數副本。
static float global = 1.111f; void offset(float &&f) { global += f; } // 通過右值引用傳遞參數 void offset(float& f) { global -= f; } // 重載了offset函數,而且是左值傳遞 float getFloat() { return 4.444f; } int main() { float u = 10.000f; cout << "global:" << global << endl; offset(3.333f); // 這里會調用右值引用參數的函數 cout << "global:" << global << endl; offset(getFloat() + 2.222); cout << "global:" << global << endl; offset(u); // 執行的是按左值引用的offset函數,右值引用無法初始化為左值. cout << "global:" << global << endl; return 0; }
對於非模板函數,函數參數有確定的類型,右值引用只能與右值綁定,只接收右值實參,可以將它看作是臨時變量的別名,不會將臨時
變量再復制1次,和按值傳遞相比提高了效率。這一點同3)進行區別。
3)模板函數中如何按照參數的實際類型進行轉發:當右值引用和模板結合的時候,就復雜了。T&&
並不一定表示右值引用,它可能是個左值
引用又可能是個右值引用。如果函數模板表示的是右值引用的話,肯定是不能傳遞左值的,但是事實卻是可以。這里的&&
是一個未定義的引用類型,
稱為universal references
,它必須被初始化,它是左值引用還是右值引用卻決於它的初始化,如果它被一個左值初始化,它就是一個左值引用;
如果被一個右值初始化,它就是一個右值引用。
注意:只有當發生自動類型推斷時(如函數模板的類型自動推導,或auto關鍵字),&&
才是一個universal references
。
// Test是一個特定的類型,不需要類型推導,所以&&表示右值引用 template<typename T> class Test { Test(Test&& rhs); }; // 右值引用 void f1(Test&& param); // 在調用這個f之前,這個vector<T>中的推斷類型已經確定了,所以調用f函數的時候沒有類型推斷了,所以是右值引用 template<typename T> void f2(std::vector<T>&& param); // universal references僅僅發生在 T&& 下面,任何一點附加條件都會使之失效, 所以是右值引用 template<typename T> void f3(const T&& param); // 這里T的類型需要推導,所以 && 是一個 universal references template<typename T> void f(T&& param); int main() { int x = 1; int && a = 2; string str = "hello"; f(1); // 參數是右值 T 推導成了int, 所以是int&& param, 右值引用 f(x); // 參數是左值 T 推導成了int&, 所以是int&&& param, 折疊成 int&, 左值引用 f(a); // 雖然 a 是右值引用,但它還是一個左值,T推導成了int& f(str); // 參數是左值, T 推導成了string& f(string("hello")); // 參數是右值, T 推導成了string f(std::move(str)); // 參數是右值, T 推導成了string }
所以最終還是要看T
被推導成什么類型,如果T
被推導成了string
,那么T&&
就是string&&
,是個右值引用,如果T
被推導為string&
,
就會發生類似string& &&
的情況,對於這種情況,c++11
增加了引用折疊的規則,本質如下:
所有的引用折疊最終都代表一個引用,要么是左值引用,要么是右值引用。規則就是:
如果任一引用為左值引用,則結果為左值引用。否則(即兩個都是右值引用),結果為右值引用。
引用折疊存在四種情形,根據上面的規則我們可以知道:
1)左值-左值 T& & <=>
int &
2)左值-右值 T& && <=>
int &
3)右值-左值 T&& & <=> int &
4)右值-右值 T&& && <=> int &&
因為1,2,3中都存在一個左值引用。