在 C++ 中,引用类型是一种复合类型,可以分为左值引用、常引用和右值引用
在了解什么是左值引用和右值引用之前,我们需要先了解什么是左值和右值。由于 C++ 本身没有给出左值和右值的标准定义,
- 可以取地址的,有名称的,非临时的是左值
- 不可取地址,匿名的,临时的是右值
左值引用
左值引用是我们平时在编程时最常使用到的。它的定义形式如下:
//基本类型 &变量名 = 待绑定对象
int i = 10;
int &r = i;
引用的作用相当于是给变量取了“别名”。引用本身没有自己的内存地址,因此在定义的过程当中必须进行初始化,而且一旦和变量进行了绑定,则无法解绑并重新绑定。另外,左值引用只能和左值进行绑定。例如
string &r = string("test"); // 错误,string("test") 是临时对象,属于右值
int &r = 10 + 1; // 错误,10 + 1 是表达式,属于右值
string &r = "test"; // "test" 是字符串字面值,属于右值
常引用
常引用是指利用 const 修饰的引用类型。由于引用本身已经绑定不可解绑,因此所用的 const 引用都是底层 const,即引用对象不能改变(俗称常引用)。由于常引用引用的是常量,所以可以使用表达式,字符串字面值等常量为常引用进行初始化。
与 const 指针的比较
根据 const 的位置的不同,可将指针中的 const 分为顶层 const (指针常量)和底层 const (常量指针),遵循“左数右指” 的原则
指针常量是常量,其指向是不能改变的。这对对象的拷贝操作没有什么影响,因为拷贝操作不会改变对象的值,没有违背“值不变原则”
常量指针是指针,其指向的内容是不能改变的。这对对象的拷贝操作会有很大的影响,因为常量指针可以指向非常量,但是非常量指针不能指向常量【安全原则:因为常量指针指向非常量是安全的,但非常量指针指向常量是危险的,因为可能一不小心可以通过非常量指针修改到常量】
右值引用
右值引用是 C++ 新标准中引入的。右值引用的定义形式为:
//基本类型 &&变量名 = 待绑定对象
int &&r = 10 + 1;
右值引用的主要作用有两个:
- 实现移动语义
- 实现完美转发
作用一、移动语义的实现。
C++ 中的 “移动语义” 是相对于 “拷贝语义” 的一个概念。在引入”移动语义“之前,C++ 主要利用拷贝功能来实现对象的移动,通常的操作是在新分配好的内存空间上调用拷贝构造函数,将旧对象复制到新内容中,然后再释放旧对象。而”移动语义“的引入使得 C++ 允许直接将旧对象的所有权直接转让给新分配的内存空间,这样一来避免了旧对象的复制与释放,极大地提高了代码的运行效率。如果说 ”拷贝语义“ 描述了对象的拷贝功能,那么”移动语义“ 描述的则是对象的剪切功能。(注:转让意味着将所有权交给新区域的同时,还要放弃自身的所有权)
C++ 中的 ”移动语义“ 主要利用右值引用来实现。C++ 允许将一个右值引用 rr 绑定到一个临时对象上,从而将该临时对象的生命周期延长到与 rr 一样长。这样一来,我们便可以通过 rr 在程序当中的任何位置访问该临时对象。
我们先来看一个 swap 模板函数的例子
//基于 “拷贝语义” 的 swap 函数
template <typename T>
void swap(T &a, T &b){
T tmp = a; //拷贝构造:将 a 拷贝至 tmp
a = b; //拷贝赋值:将 b 拷贝至 a
b = tmp; //拷贝赋值:将 tmp 拷贝至 b
}
//基于 “移动语义” 的 swap 函数
template <typename T>
void swap(T &&a, T &&b){
T tmp(std::move(a)); //移动构造:将 a 的所有权转让给局部对象 tmp
a = std::move(b); //移动赋值:将 b 的所有权转让给 a
b = std::move(tmp); //移动赋值:将 tmp 的所有权转让给 b
}
正如前面关于左值和右值定义中描述的那样,在基于 “移动语义” 的 swap 函数中, tmp、a 和 b 都是有名称的变量,因此它们都是左值。要想实现 “移动” 的功能,就必须将左值引用转变为右值引用,而 std::move 标准库函数则正好能够完成这一任务,它的作用是接受一个左值并返回该左值的右值引用。通过 std::move 和 “移动语义”,第二个版本的 swap 比第一个版本的 swap 函数减少了三次拷贝数据的过程,大大提高了 swap 的运行效率。
注:关于移动构造函数和移动赋值运算符可见:C++ 拷贝控制(二) — 移动构造函数和移动赋值运算符
作用 二:完美转发
完美转发主要应用于这样一组场景:我们需要将一个函数的参数原封不同地传递给另一个函数。在 C++ 中,函数参数除了类型和值以外,还有 const 和 non-const 以及 左值和右值 两组属性。所谓的完美转发,就是在将一个函数的参数传递给另一个参数时,参数的类型、值以及所有属性都不能改变,这在泛型编程当中有着广泛的应用。我们来看下面两段代码:
//代码 1
template <typename T> void f1(T &t){
cout << "hello world" << endl;
}
template <typename T> void f1(const T &t){
cout << "hello world" << endl;
}
int main(void){
int i = 10;
const int ci = 11;
f1(i); //调用 f1(int&),模板参数 T 是 int
f1(ci); //调用 f1(const int&),模板参数 T 是 int
f1(31); //调用 f1(const int&),模板参数 T 是 int
return 0;
}
在 代码 1 中,为了兼容 f1(i)、f1(ci) 与 f1(31) 这三种调用方式,我们需要为 f1 函数重载 T& 和 const T& 两个版本。这种做法不仅费时费力,而且随着函数参数的增加,所需要重载的模板数量也会急速增加。我们来看看右值引用是如何解决这个问题的:
//代码 2
template <typename T> void f1(T &&t){
cout << "hello world" << endl;
}
int main(void){
int i = 10;
const int ci = 11;
f1(i); //实参是一个左值,模板参数 T 是 int&
f1(ci); //实参是一个左值,模板参数 T 是 const int&
f1(31); //实参是一个右值,模板参数 T 是 int
return 0;
}
针对模板函数的实现,C++ 11 在原有的正常绑定规则上增加一个新的绑定规则 —— 引用折叠,具体规则如下:
- 若模板函数 func 的形参类型为 T&, 则不论传递给 func 的实参是左值还是右值,一律统统折叠为左值引用
- 若模板函数 func 的形参类型为 T&&, 则传递给函数的左值实参折叠为左值引用,右值实参折叠为右值引用
总之一句话:在定义函数模板时使用右值引用做形参,能够保证参数的属性(const 属性和 左/右值属性)不发生变化。
在 代码 2 中,虽然 i 和 ci 是左值,但由于发生了引用折叠,所以 f1(i) 和 f1(ci) 是合法的。在引用折叠的规则下,无论一个函数有多少个参数,借助右值引用我们都只需要定义一个函数模板即可,这就大大减少了编程时的低效劳动。
右值引用和常引用的异同
相同点:
- 右值引用和常引用都允许和表达式,字符串字面值等进行绑定
不同点:
- 常引用可以和普通变量绑定,而右值引用只能绑定到右值上面
- 常引用类型的变量是不可修改的,而右值引用类型的变量是可以修改的
引用和指针的区别
- 引用和指针都是复合类型,但指针是变量,有自己的内存地址,而引用是别名,没有自己的内存地址
- 引用一旦定义就必须初始化,而指针可以先定义,然后再初始化
- 引用一旦和某个具体的变量绑定后,就不能再解除绑定,而指针变量在指向某一变量后,依然可以改变指向。
- 存在多维指针,但不存在多维引用【C++ 中不允许直接定义引用的引用,但可以通过类型别名或者模板类型参数间接定义】
- 因为引用没有自己的内存地址,所以只存在引用指针的引用,但不存在指向引用的指针
注:实际上,C++ 当中引用的实现也是利用到了 const 指针来实现的,但是由于编译器做了一部分工作,因此引用本身对于程序员而言是透明的。