目录
1. 引用
C++11中新增了一种引用:右值引用(rvalue reference),主要用于内置类。严格来说,当我们使用术语“引用(reference)”时,指的其实是“左值引用(lvalue reference)”。
1.1 引用的概念
引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字(起别名),所以引用不额外开辟内存空间,与所绑定的对象共用同一块内存空间。定义了一个引用之后,对其进行的所有操作都是在与之绑定的对象上进行的。因为引用本身不是一个对象,所以不能定义引用的引用。
关于“引用的引用”详见
int a = 10;
int& ra = a; // ra是a的引用,即将ra绑定到a上
// ra的地址和a的地址相同
1.2 引用的特点
一般在初始化变量时,初始值会被拷贝到新建的对象中。然而定义引用时,程序把引用和它的初始值绑定在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定到另外一个对象,因此引用必须初始化。
- 引用一旦初始化就不能再改变
- 引用在定义时必须初始化
- 一个变量可以有多个引用
- 非常量引用不能绑定右值
- 非常量引用的类型要和所绑定的对象的类型严格匹配
int a = 10;
int b = 20;
int& ra = a; // ok
int& ra = b; // err 引用一旦初始化就不能再改变
int& ra; // err 引用在定义时必须初始化
int& c= a; // ok 一个变量可以有多个引用
int& d = 8; // err 非常量引用不能绑定右值
double pi = 3.14;
int& rpi = pi; // err 非常量引用的类型要和所绑定的对象的类型严格匹配
1.3 引用的使用场景
1.3.1 作为参数
交换函数:
C语言版:修改变量就要传变量的地址,再通过解引用,对变量进行操作。
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
C++版:修改变量可以传变量的引用,对引用进行操作,就是对变量进行操作。
void Swap(int& r1, int& r2)
{
int tmp = r1;
r1 = r2;
r2 = tmp;
}
1.3.2 作为返回值
返回一个值(非引用):存在拷贝
返回引用:
- 减少拷贝
- 调用者可以修改返回对象
// 静态顺序表
#define N 10
typedef struct Array
{
int a[N];
int size;
}AY;
int& PosAt(AY& ay, int i)
{
assert(i < N);
return ay.a[i];
}
int main()
{
AY ay;
for (int i = 0; i < N; ++i)
{
PosAt(ay, i) = i * 10; // 修改返回对象
}
for (int i = 0; i < N; ++i)
{
cout << PosAt(ay, i) << " ";
}
cout << endl;
return 0;
}
如果出了函数作用域,对象还存在,则可以返回引用。
引用作参数和作返回值都可以提高效率。以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
1.4 常量引用
const修饰的引用,称为常量引用或常引用。不能通过常量引用改变对应的对象的值。
const int ci = 10;
const int &r1 = ci; // ok r1是常量引用,对应的对象ci也是常量
r1 = 20; // err 不能通过常量引用改变对应的对象的值
int &r2 = ci; // err ci不能改变,当然也就不能通过引用去改变ci
// 假设合法,则可以通过r2来改变ci,这是不合法的
在初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。
int i = 50;
const int& r1 = i; // ok 将int常量引用绑定到int变量上
int& r2 = i; // ok 将int引用绑定到int变量上
const int& r3 = 10; // ok 将int常量引用绑定到int右值上
int& r4 = 10; // err 非常量引用不能绑定右值
const int& r5 = 66.6f; // ok 将int常量引用绑定到float右值上
int& r6 = 66.6f; // err 非常量引用不能绑定右值
double pi = 3.14;
const int& rpi = pi; // ok 将int常量引用绑定到double变量上
int& rpi = pi; // err 非常量引用的类型要和所绑定的对象的类型严格匹配
我们要清楚当一个常量引用被绑定到另外一种类型上时到底发生了什么:
double pi = 3.14;
const int& rpi = pi; // ok 将const int&绑定到一个普通double对象上
为什么rpi能够绑定pi?为了让rpi绑定一个整型对象,编译器把代码处理为:
const int temp = pi; // 隐式类型转换 double->const int
const int& rpi = temp; // 让rpi绑定这个临时量
所以当一个常量引用被绑定到另外一种类型上时,常量引用绑定的其实是相同类型的临时量。
如果函数的返回值是一个值而非引用,那么返回的是一个临时变量,且具有常量性。如果要用引用接收该值,只能用常量引用。
int Count()
{
static int n = 0;
n++;
return n;
}
int& ret = Count(); // err
const int& ret = Count(); // ok
1.5 引用和指针的区别
- 引用是变量的别名,不额外开辟内存空间,与所绑定的对象共用同一块内存空间;指针存储变量的地址,有自己的内存空间。
- 引用一旦初始化就不能再改变;指针可以随时改变指向。
- 引用在定义时必须初始化;指针可以不初始化。
- 引用不能为空;指针可以为空。
- sizeof引用=引用所绑定的对象的大小;sizeof指针=4/8。
- 引用++是引用所绑定的对象+1;指针++是指针向后偏移一个类型的大小。
- 引用只有一级;指针可以有多级。
- 引用直接访问变量;指针通过解引用访问变量。
- 由于引用一旦初始化就不能再改变,且不存在空引用,所以引用是安全的;由于指针可以随时改变指向,且存在空指针和野指针,所以指针是不安全的。
- 引用作为函数参数,对引用的操作就是直接对所绑定的对象的操作;指针作为函数参数,形参是实参的一份临时拷贝,两者存储的地址相同,但不是同一个变量,对形参的修改不影响实参。
2. 左值和右值
C++的表达式要不然是右值(rvalue,读作"are-value"),要不然就是左值(lvalue,读作"ell-value")。这两个名词是从C语言继承过来的,原本是为了帮助记忆:左值可以位于赋值语句的左侧,右值则不能。
在C++语言中,二者的区别就没那么简单了。一个左值表达式的求值结果是一个对象或者一个函数,然而以常量对象为代表的某些左值实际上不能作为赋值语句的左侧运算对象。此外,虽然某些表达式的求值结果是对象,但它们是右值而非左值。可以做一个简单的归纳:当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。
不同的运算符对运算对象的要求各不相同,有的需要左值运算对象、有的需要右值运算对象;返回值也有差异,有的得到左值结果、有的得到右值结果。一个重要的原则(除右值引用)是在需要右值的地方可以用左值来代替,但是不能把右值当成左值(也就是位置)使用。当一个左值被当成右值使用时,实际使用的是它的内容(值)。到目前为止,已经有几种我们熟悉的运算符是要用到左值的。
- 赋值运算符需要一个(非常量)左值作为其左侧运算对象,得到的结果也仍然是一个左值。
- 取地址符作用于一个左值运算对象,返回一个指向该运算对象的指针,这个指针是一个右值。
- 内置解引用运算符、下标运算符、迭代器解引用运算符、string和vector的下标运算符的求值结果都是左值。
- 内置类型和迭代器的递增递减运算符作用于左值运算对象,其前置版本所得的结果也是左值。
左值和右值的一个重要区别是:左值可以取地址,右值不可以取地址。
3. 右值引用
3.1 右值引用的概念
为了支持移动操作,新标准引入了一种新的引用类型——右值引用(rvalue reference)。所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用。如我们将要看到的,右值引用有一个重要的性质——只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。
左值和右值是表达式的属性。一些表达式生成或要求左值,而另外一些则生成或要求右值。一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。
类似任何引用,一个右值引用也不过是某个对象的另一个名字而已。如我们所知,对于常规引用(为了与右值引用区分开来,我们可以称之为左值引用(lvalue reference)),我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:
int i = 42;
int& r = i; // ok r引用i
int&& rr = i; // err 不能将一个右值引用绑定到一个左值上
int& r2 = i * 42; // err i*42是一个右值
const int& r3 = i * 42; // ok 我们可以将一个const的引用绑定到一个右值上
int&& rr2 = i * 42; // ok 将rr2绑定到乘法结果上
返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。我们可以将一个左值引用绑定到这类表达式的结果上。
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。
3.2 左值持久;右值短暂
考察左值和右值表达式的列表,两者相互区别之处就很明显了:左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。
由于右值引用只能绑定到临时对象,我们得知
- 所引用的对象将要被销毁
- 该对象没有其他用户
这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。
右值引用指向将要被销毁的对象。因此,我们可以从绑定到右值引用的对象“窃取”状态。
3.3 变量是左值
变量可以看作只有一个运算对象而没有运算符的表达式,虽然我们很少这样看待变量。类似其他任何表达式,变量表达式也有左值/右值属性。变量表达式都是左值。带来的结果就是,我们不能将一个右值引用绑定到一个右值引用类型的变量上,这有些令人惊讶:
int&& rr1 = 42; // ok 字面常量是右值
int&& rr2 = rr1; // err 表达式rr1是左值
其实有了右值表示临时对象这一观察结果,变量是左值这一特性并不令人惊讶。毕竟,变量是持久的,直至离开作用域时才被销毁。
变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。
3.4 标准库move函数
虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility中。move函数返回给定对象的右值引用。
int&& rr3 = std::move(rr1); // ok
move调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用move就意味着承诺:除了对rr1赋值或销毁它外,我们将不再使用它。在调用move之后,我们不能对移后源对象的值做任何假设。
我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
如前所述,与大多数标准库名字的使用不同,对move我们不提供using声明。我们直接调用std::move而不是move。
使用move的代码应该使用std::move而不是move。这样做可以避免潜在的名字冲突。
3.5 左值引用和右值引用的比较
左值引用只能引用左值,不能引用右值,但是const左值引用既可引用左值,也可引用右值。
int a = 10;
int& ra1 = a; // ok
int& ra2 = 10; // err
const int& ra3 = a; // ok
const int& ra4 = 10; // ok
右值引用只能引用右值,不能引用左值,但是右值引用可以引用move以后的左值。
int a = 10;
int&& ra1 = 10; // ok
int&& ra2 = a; // err
int&& ra3 = std::move(a); // ok
3.6 移动语义
3.6.1 对象移动
新标准的一个最主要的特性是可以移动而非拷贝对象的能力。很多情况下都会发生对象拷贝,在其中某些情况下,对象拷贝后就立即被销毁了。在这些情况下,移动而非拷贝对象会大幅度提升性能。
在旧C++标准中,没有直接的方法移动对象。因此,即使不必拷贝对象的情况下,我们也不得不拷贝。如果对象较大,或者是对象本身要求分配内存空间(如string),进行不必要的拷贝代价非常高。类似的,在旧版本的标准库中,容器中所保存的类必须是可拷贝的。但在新标准中,我们可以用容器保存不可拷贝的类型,只要它们能被移动即可。
3.6.2 移动构造函数和移动赋值运算符
类似string类(及其他标准库类),如果我们自己的类也同时支持移动和拷贝,那么也能从中受益。为了让我们自己的类型支持移动操作,需要为其定义移动构造函数和移动赋值运算符。这两个成员类似对应的拷贝操作,但它们从给定对象“窃取”资源而不是拷贝资源。
类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
3.7 完美转发
模板中的&&不代表右值引用,而是万能引用,其既能接收左值又能接收右值。模板的万能引用只是提供了能够接收同时接收左值引用和右值引用的能力,但是引用类型的唯一作用就是限制了接收的类型,后续使用中都退化成了左值。
#include <iostream>
using namespace std;
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);
PerfectForward(std::move(b));
return 0;
}
我们希望能够在传递过程中保持它的左值或者右值的属性,就需要用完美转发。std::forward完美转发在传参的过程中保留对象原生类型属性。
#include <iostream>
using namespace std;
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(std::forward<T>(t));
}
int main()
{
PerfectForward(10);
int a;
PerfectForward(a);
PerfectForward(std::move(a));
const int b = 8;
PerfectForward(b);
PerfectForward(std::move(b));
return 0;
}