左值和左值引用、右值和右值引用
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中都存在一个左值引用。