引用变量常见的一般如下:
int a = 3;
int &b = a;
int *c = &a;
b 即引用变量,此时它的值也是 3,且地址也是变量 a 的地址,这点可以通过 gdb 调试时看出:
(gdb) p a
$4 = 3
(gdb) p &a
$5 = (int *) 0x7ffeefbffa08
(gdb) p b
$6 = (int &) @0x7ffeefbffa08: 3
(gdb) p &b
$7 = (int *) 0x7ffeefbffa08
指针 *c 也是可以表示变量 a 的,但是单独申请了一段空间来存储变量 a 的地址:
(gdb) p *c
$8 = 3
(gdb) p c
$9 = (int *) 0x7ffeefbffa08
(gdb) p &c
$10 = (int **) 0x7ffeefbff9f8
这点和引用有所不同,定义引用变量无需申请内存空间。如果再将另一个变量赋给 b 呢?
int d = 4;
b = d;
可以再看下 b 和 a 有哪些变化:
(gdb) p &d
$1 = (int *) 0x7ffeefbff9f4
(gdb) p b
$2 = (int &) @0x7ffeefbffa08: 4
(gdb) p a
$4 = 4
尽管 a 和 b 的值已经更新为 4,但 b 依然是和 a 捆绑着的。即 b 在初始化时引用的对象就已经固定了,不会再改。
实际引入引用变量的用途是用作函数的参数,在引入之前,函数传参主要是值传递,但值传递会生成实参的副本。如果是一般的内置类型倒还好,但如果是数据较大的结构体或对象时,性能就有所损耗。如定义这个么函数:
void func(int v)
{
v = v + 5;
}
将前面定义好的变量 a 传递给该函数,打印如下:
(gdb) p &a
$1 = (int *) 0x7ffeefbffa08
(gdb) s
func (v=3) at test.cpp:20
20 v = v + 5;
(gdb) p &v
$2 = (int *) 0x7ffeefbff9ec
可以看到实参 a 与形参 v 的地址并不相同,v 即 a 的拷贝。那么 a 的值是怎么给到 v 的呢?可通过 disassemble 命令查看对应汇编程序:
(gdb) disassemble /m $pc
Dump of assembler code for function main():
...
11 int a = 3;
0x0000000100003d2f <+15>: movl $0x3,-0x8(%rbp)
12 func(a);
0x0000000100003d36 <+22>: mov -0x8(%rbp),%edi
0x0000000100003d39 <+25>: call 0x100003d70 <_Z4funci>
...
可以看到在调用 func() 方法时,程序是将实参 a 的值传给了寄存器 edi,rbp 始终指向当前栈帧的底部。再看看 func() 方法内部怎么操作的:
19 {
0x0000000100003d70 <+0>: push %rbp
0x0000000100003d71 <+1>: mov %rsp,%rbp
0x0000000100003d74 <+4>: mov %edi,-0x4(%rbp)
20 v = v + 5;
=> 0x0000000100003d77 <+7>: mov -0x4(%rbp),%eax
0x0000000100003d7a <+10>: add $0x5,%eax
0x0000000100003d7d <+13>: mov %eax,-0x4(%rbp)
程序再将寄存器 edi 的值传到当前栈帧中,也就是说形参和实参是通过寄存器来传值的。再回到函数传参,如果是引用传参呢?可定义这么个函数:
void refunc(int &r)
{
r = r + 6;
}
调试打印如下:
(gdb) p &a
$1 = (int *) 0x7ffeefbffa08
(gdb) s
refunc (r=@0x7ffeefbffa08: 3) at test.cpp:27
27 r = r + 6;
(gdb) p &r
$2 = (int *) 0x7ffeefbffa08
可以看到实参 a 与形参 r 指向的是同一块空间,操作 r 即操作 a 本身,省去了一次拷贝。这里传的是变量 a,如果传一个常数呢?譬如传 4:
refunc(4);
编译时就要报错了:
note: candidate function not viable: expects an l-value for 1st argument
翻译过来就是 refunc(int &r) 期待一个左值。左值字面上可以理解成赋值语句左边的变量,右值就是赋值语句右边的变量。深层次的区别就是左值是可以获取内存中确切地址的,如一般变量、指针、数组元素等都是左值,而常数、表达式等则是右值。所以上面介绍的引用实际上只是对左值的引用,那有没有对右值的引用呢?c++11 确实提出了一个新的引用,即右值引用,顾名思义是对右值的引用。如:
int && a = 3;
但这样定义个变量本身其实没多大意义,更多还是应用于移动语义,可定义这么个函数:
int rvalfunc(int && r)
{
r = r + 7;
return r;
}
添加返回值是为了能拿到右值引用结果,然后执行下面语句:
int a = rvalfunc(3);
主函数中汇编程序如下:
12 int a = rvalfunc(3);
=> 0x0000000100003d0f <+15>: movl $0x3,-0xc(%rbp)
0x0000000100003d16 <+22>: lea -0xc(%rbp),%rdi
0x0000000100003d1a <+26>: call 0x100003d50 <_Z8rvalfuncOi>
这块和左值引用时稍微有所不同了,执行的是 lea 命令,并不是将 -0xc(%rbp) 处的 3 传给寄存器 rdi,而是直接将变量 3 所在的地址 -0xc(%rbp) 传给 rdi。然后调用 rvalfunc() 方法:
29 {
0x0000000100003d50 <+0>: push %rbp
0x0000000100003d51 <+1>: mov %rsp,%rbp
0x0000000100003d54 <+4>: mov %rdi,-0x8(%rbp)
30 r = r + 7;
=> 0x0000000100003d58 <+8>: mov -0x8(%rbp),%rax
0x0000000100003d5c <+12>: mov (%rax),%ecx
0x0000000100003d5e <+14>: add $0x7,%ecx
0x0000000100003d61 <+17>: mov -0x8(%rbp),%rax
0x0000000100003d65 <+21>: mov %ecx,(%rax)
先将寄存器 rdi 中的值即地址传到栈帧 -0x8(%rbp) 处,接着传给寄存器 rax,再将 rax 中保存的地址所在的值 3 传给寄存器 ecx,最后再执行方法体中的处理程序。由此可以看出,当形参定义为右值引用时,并没有数据的拷贝,而是将数据所在的地址传给寄存器,然后在函数内部从寄存器中取出该地址并放在自己的栈帧空间中,再根据该地址取出对应的值放到寄存器中。
而移动语义也就是利用这点来实现的。避免了原始数据的拷贝,只是将所有权进行转移。
拓展:
1. 除了右值引用,其实 const 引用也是可以指向右值的。如:
const int &a = 5;
int b = 3, c = 4;
const int &d = b;
const int &e = b + c;
以上程序是可以正常编译的。可以看到 const & 既可以对左值,也可以对右值,某种程度上可以认为 const & 对左值引用和右值引用做了个统一。但是也还有个问题,就是在函数传参时如果定义为 const &,那么参数值就没法改变了,某些情况下不符合业务需求。
2. 汇编语法分 Inter 和 AT&T 两种,Inter syntax 主要用于 windows,AT&T syntax 主要用于 unix 和类 unix 系统。二者指令的操作数位置相反:
Inter syntax:mov to from
AT&T syntax: mov from to