C++引用

引用

本篇介绍C++相比于C语言特有的引用语法,包含c++11特性。

左值引用

在C++11前,只有一种引用,那就是左值引用。在C++11中新加入了右值引用并且添加了许多实用的新特性。

  • 在该小标题下指的引用均为左值引用

引用变量是一个别名,也就是说,它是某个已存在变量的另一个名字。一旦把引用初始化为某个变量,就可以使用该引用名称或变量名称来指向变量。

1
2
3
4
5
int i = 123;
double d = 321.0;
int& r = i;
double& s = d;
//创建了两个引用,即别名

引用其实本身的类型等价于const typename *
这就导致了它有如下的特点:

  • 一个变量可取多个别名。
  • 引用必须初始化。
  • 引用只能在初始化的时候引用一次 ,不能更改为转而引用其他变量。

每次调用左值引用其实都暗含了一步取地址的过程
因为左值引用本质上存储的是地址,着意味着可以把引用当成是一种特殊的指针,但并不是说其本质就是指针

1
2
3
4
5
6
7
// 经典例子
void swap(int& x, int& y){
int temp;
temp = x; /* 保存地址 x 的值 */
x = y; /* 把 y 赋值给 x */
y = temp; /* 把 x 赋值给 y */
}

返回左值引用的函数可以作为左值,但引用可以看作指针,故而要当心引用空悬的问题,例子如下

1
2
3
4
5
6
7
8
9
10
char &get_val(string &str, string::size_type ix){
return str[ix];
}
int main(){
string s("a value");
cout << s << endl;
get_val(s, 0) = 'A';//返回左值引用的函数作为左值
cout << s << endl;
return 0;
}

值传递和引用传递的差别

值传递是指参数列表里的类型不是引用的,在这种情况下,如果类型是类的话,其实在传递参数时是会调用拷贝构造,而引用在传递参数的时候则不会。

因而拷贝构造函数不能使用值传递,若拷贝构造函数采用传值传参,用date1去初始化date2,会调用Date (Date d),当Date d = date1时,相当于Date d (d1),此时又会调用拷贝构造函数,如此循环导致无穷递归。


左值&右值

也许上面的左值引用完全不怎么需要在意左值右值的差别,但要想进一步区分左值引用和右值引用之间的差别,必须先了解C++11中左右值,将亡值的区分

  • C++11中将右值拓展为 纯右值 (prvalue)将亡值 (xvalue)
    • 纯右值:非引用返回的临时变量,运算表达式的结果,字面常量
    • 字符串字面量是左值,而且是不可被更改的左值。字符串字面量并不具名,但是可以用&取地址所以也是左值。
      (a+1),a为int[10],这种状态下也是左值
      返回左值引用的函数可以作为左值
    • 将亡值:与右值引用相关的表达式 如:将要被移动的对象,T&&函数返回的值,std::move()的返回值,转换成T&&的类型的转换函数的返回值

左值右值判断方法精简:

  • 如果你可以对一个表达式取地址,那这个表达式就是个左值。
  • 如果一个表达式的类型是一个左值引用 (例如, T&const T&, 等.),那这个表达式就是一个左值。如:返回左值引用的函数
  • 其它情况,这个表达式就是一个右值。从概念上来讲(通常实际上也是这样),右值对应于临时对象,例如函数返回值或者通过隐式类型转换得到的对象,大部分字面值(e.g.105.3)也是右值。

右值引用 (rvalue reference)

C++11中增加右值引用,在C++98中的引用都称为左值引用 (lvalue reference)
右值引用就是给右值取别名,新名字就是左值。如果一个prvalue被绑定到一个引用上,它的生命周期则会延长到跟这个引用变量一样长。也正因为如此,右值引用不用担心引用空悬的问题。(反正你也解绑不了,寿命延长了我也杀不掉你)
右值引用本身是不可修改绑定的左值,但可以通过它修改右值
右值引用本身也仍然是一个指针,只是指向的值是匿名的罢了

1
2
3
mov   dword ptr [rbp+0A4h],0Ah
lea rax,[rbp+0A4h]
mov qword ptr [c],rax

这里在语法中所谓不能寻址的右值,它的位置其实也是栈上(rbp+0A4h)
1
2
3
4
5
int num = 10;
int &b = num; //正确
int &&c = 10; //正确
int &&c = num;//错误,左值不能赋给右值引用
c+=1; //正确,右值10变为11


常量左值引用 (const lvalue reference)

  • 常量左值引用,可以绑定左值和右值,但不能更改引用的值,常量左值引用绑定右值时如同右值引用,也可以延长右值的生命周期与自身相同。可以把绑定右值的常量左值引用看作不可修改其绑定的右值的右值引用。(注意一下语序)
  • 非常量左值引用只能绑定左值,右值引用只能绑定右值。
  • 常量右值引用目前暂无作用。据我测试就是个只能传入右值的常量左值引用。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    int a=10;              //非常量左值(有确定存储地址,也有变量名)
    const int a1=10; //常量左值(有确定存储地址,也有变量名)
    const int a2=20; //常量左值(有确定存储地址,也有变量名)
    //非常量左值引用
    int &b1=a; //正确,a是一个非常量左值,可以被非常量左值引用绑定
    int &b2=a1; //错误,a1是一个常量左值,不可以被非常量左值引用绑定
    int &b3=10; //错误,10是一个非常量右值,不可以被非常量左值引用绑定
    int &b4=a1+a2; //错误,(a1+a2)是一个常量右值,不可以被非常量左值引用绑定
    //常量左值引用
    const int &c1=a; //正确,a是一个非常量左值,可以被非常量右值引用绑定
    const int &c2=a1; //正确,a1是一个常量左值,可以被非常量右值引用绑定
    const int &c3=a+a1; //正确,(a+a1)是一个非常量右值,可以被常量右值引用绑定
    const int &c4=a1+a2; //正确,(a1+a2)是一个常量右值,可以被非常量右值引用绑定

当函数和引用打架的时候(误)

引用之所以难,因为其与许多东西扯上了关系,模板,传参等等。
直接开始把移动语义完美转发万能引用分开来看比较分立,我也是看的头疼,如果从函数角度入手,叙述这些概念会更清晰些。

函数返回值

  • 返回一般简单类型或者返回类类型的函数,如int fun1(int a)或者char* fun2()这种函数,返回值为纯右值
  • 返回值为左值引用的函数,上方讨论过,返回值为左值。
  • 返回值为右值引用的函数,返回值为将亡值

对于底层来说,无论是用右值引用还是普通变量保存返回值,最终在函数返回时都是通过eax寄存器保存的。区别在于:

  • 普通变量:就直接将eax赋值给普通变量
  • 右值引用:就将eax的值放到另一个位置(临时变量),然后再将这个位置的地址赋值到右值引用上。
  • 常量左值引用: 和右值引用是一样的效果,只是常量左值引用就不可以修改这个临时变量而已
    (所以函数返回值的临时变量不是一直存在的,要看返回时用什么类型的变量来接收)

函数参数

正如正常的函数一般,函数可以被重载为如参数分别为int& a int&& a,那样将会对左右值属性不同的参数编译器调用不同的函数。

移动语义 std::move()

首先来讲讲我们为什么需要移动语义,很多时候我们只是单纯创建一些右值,然后赋给某个对象用作构造函数。
这时候会出现的情况是,我们首先需要在main函数里创建这个右值对象,然后复制给这个对象相应的成员变量。
如果我们可以直接把这个右值变量移动到这个成员变量而不需要做一个额外的复制行为,程序性能就这样提高了。
而这些操作正是通过右值引用这个类型的诞生,才使得拷贝和移动语义的分离。
可以说引入右值引用的目的就是为了引入移动语义。

  • 拷贝堆区对象需要重写拷贝构造函数和赋值函数,实现深拷贝
  • 如果堆区源对象是临时对象(右值),深拷贝会造成无意义的内存申请和释放操作
  • C++11的移动语义可以直接使用源对象,可以提高效率。

例如把指针指向已经生成的堆区上对象,把传入参数的指针设置为nullptr,这样就可以减少无谓的new与delete,减少拷贝花费的时间。

移动语义需要的两个函数

  • 移动构造函数
    className(className&& object) {}
  • 移动赋值函数
    className& operator=(className&& object) {}

举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
using namespace std;
class demo{
public:
demo():num(new int(0)){
cout<<"construct!"<<endl;
}

demo(const demo &d):num(new int(*d.num)){
cout<<"copy construct!"<<endl;
}
//添加移动构造函数
demo(demo &&d):num(d.num){
d.num = NULL;
cout<<"move construct!"<<endl;
}
~demo(){
cout<<"class destruct!"<<endl;
//特别注意,这里有可能会导致内存泄漏,实际应用需要改写析构函数
}
private:
int *num;
};
demo get_demo(){
return demo();
}
demo&& new_demo(){
demo a = new demo();
demo&& b = a;
return b;
}
int main(){
demo a = get_demo();
return 0;
}

在这里get_demo()返回纯右值,被移动构造函数捕获。
output
1
2
3
4
5
6
construct!
move construct!
class destruct!
move construct!
class destruct!
class destruct!

std::move()

可以将左值变为右值的函数,用于左值的移动语义。
我们可以用std::move实现高效的swap函数
在不使用 Move 语义的情况下

1
2
3
4
5
swap(A &a1, A &a2){
A tmp(a1); // 拷贝构造函数一次,涉及大量数据的拷贝
a1 = a2; // 拷贝赋值函数调用,涉及大量数据的拷贝
a2 = tmp; // 拷贝赋值函数调用,涉及大量数据的拷贝
}

如果使用Move语义,即加上移动构造函数和移动赋值函数:
1
2
3
4
5
void swap_A(A &a1, A &a2){
A tmp(std::move(a1)); // a1 转为右值,移动构造函数调用,低成本
a1 = std::move(a2); // a2 转为右值,移动赋值函数调用,低成本
a2 = std::move(tmp); // tmp 转为右值移动给a2
}


引用折叠 (reference collapsing)

引用折叠是指在模板函数进行类型推导的时候,发生的编译器解决矛盾的事件,而这个矛盾就是推导得到了引用的引用,这在是C++不被允许的。
引用折叠发生在函数和模板参数传递的过程中。另外,由于编译器优化的存在使得特定条件下非引用对象会自动转换为引用对象。
下面是一个小例子:

1
2
3
4
//注意在参数传递的时候由于传递的是引用,其实是不会引起复制的,值传递的话是会引起复制的
void fun(const T& a);
std::string& str = std::string("");
fun(str);

上面代码中实参str的类型为std::string&, 形参a被标记为std::string & const & = const std::string&
具体的折叠规则如下

T&& && -> T&&
T&& & -> T&
T& && -> T&
T& & -> T&

即左值引用具有传染性,右值引用是万能引用。
万能引用指可以接受一切引用非引用形式的参数。
而前者则不能传递纯右值,将亡值

1
2
template<typename T>
void fun(T&& t);


完美转发 std::forward

在函数模板中,可以将自己的参数“完美地”转发给其他函数,即准确转发参数的值和左右值属性

  • 能否实现完美转发,决定了该参数在传递过程中用的是拷贝语义还是移动语义

以下实现方式中,func2()可以调用两个重载版本,func1()无法调用rvalue重载版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void func1(int& i) {         // 参数为lvalue
std::cout << "lvalue" << i << std::endl;
}
void func1(int&& i) { // 参数为rvalue
std::cout << "rvalue" << i << std::endl;
}
void func2(int& i) { // 参数为lvalue
func1(i);
}
void func2(int&& i) { // 参数为rvalue
func1(i);
}
int main() {
int i = 3;
func2(i); // 调用lvalue
func2(8); // 调用rvalue
}

output
1
2
lvalue
lvalue

怎么解决func2无法调用重载版本的问题呢

  • func2(int&& i)中添加std::move()

    1
    2
    3
    void func2(int&& i) {         // 参数为rvalue
    func1(std::move(i));
    }
  • func2()改成模板参数写法

    1
    2
    3
    4
    5
    6
    7
    8
    template <typename T>
    void func2(T& i) { // 参数为lvalue
    func1(i);
    }
    template <typename T>
    void func2(T&& i) { // 参数为rvalue
    func1(std::move(i));
    }
  • C++11支持完美转发,提供以下方案
    • 如果类模板中(包括类模板和函数模板)函数的参数为T&&类型,则为万能引用(既可以接受左值引用,又可以接受右值引用)
    • 提供模板函数std::forward<T>(),用于转发参数,转发后保留参数的左右值类型
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      template<typename T>
      void func(T&& i) {
      func1(std::forward<T>(i));
      }

      int main() {
      int i = 3;
      func(i);
      func(8);
      }
      output
      1
      2
      lvalue
      rvalue