再谈c++中的引用

时间:2023-01-27 07:30:01

《从汇编看c++的引用和指针》一文中,虽然谈到了引用,但是只是为了将两者进行比较。这里将对引用做进一步的分析。

1 引用的实现方式

在介绍有关引用的c++书中,很多都说引用只是其引用变量的一个别名。我自己不是很喜欢这种解释,因为觉得这种解释会给人误解,好像引用和变量就是一回事,而且,书中也没有给出,为什么引用就是一个别名的原因,大都只是给出一个例子,证明引用可以改变传递给函数的实际参数的值,从而说明,用引用传参,和传值不是一回事。这里,我将介绍一下自己分析的引用实现方式。下面先来看c++源码:

int main() {
int i = ;
int j = ;
int& ri = i; //指向i的引用
int* pj = &j; //指向j的指针 //用指针和引用的方式分别对i和j加1
ri++;
(*pj)++;
}

下面来看一下main函数的汇编码:

int main() {

    push    ebp
mov ebp, esp
sub esp, ; 00000010H ; 2 : int i = 1; mov DWORD PTR _i$[ebp], ;将1写入_i$[ebp]代表的内存,即给变量i赋值1 ; 3 : int j = 2; mov DWORD PTR _j$[ebp], ;将2写入_j$[ebp]代表的内存,即给变量j赋值2 ; 4 : int& ri = i; lea eax, DWORD PTR _i$[ebp];获得_i$[ebp]所带表的内存地址,写入寄存器eax
mov DWORD PTR _ri$[ebp], eax;将eax写入_ri$[ebp]所带表的内存,这里,完成了引用的初始化
;可以看到,虽然我们使用变量i来初始化引用,但是,编译器仍然保存的是变量i的地址 ; 5 : int* pj = &j; lea ecx, DWORD PTR _j$[ebp]; 将_j$[ebp]所代表的内存地址给寄存器ecx
mov DWORD PTR _pj$[ebp], ecx;将ecx里面的值写入_pj$[ebp]所代表的的内存,这里完成了指针变量的初试化,可以看到
;指针变量pj确实保存的是变量j的地址 ; 6 :
; 7 : ri++; mov edx, DWORD PTR _ri$[ebp];将_ri$[ebp]所代表的内存的内容(即变量i的地址)写入寄存器edx
mov eax, DWORD PTR [edx];将edx的值指向的内存(即存储i的内存)的内容(即i的值1)写入寄存器eax
add eax, ;将eax里面的值加1,结果存入eax寄存器
mov ecx, DWORD PTR _ri$[ebp];将_ri$[ebp]所代表的内存的内容(即变量i的地址)写入寄存器ecx
mov DWORD PTR [ecx], eax;将eax的值写入ecx的值指向的内存(即变量i所在的内存) ; 8 ; 8 : (*pj)++; mov edx, DWORD PTR _pj$[ebp];将_pj$[ebp]所代表的内存的内容(即变量j所在内存的地址)写入寄存器edx
mov eax, DWORD PTR [edx];将edx的值指向的内存(即变量j所在的内存)的内容(即变量j的值2)写入寄存器eax
add eax, ;将eax里面的值加1,结果存到寄存器eax里面
mov ecx, DWORD PTR _pj$[ebp];将_pj$[ebp]所代表的内存内容(即变量j所在的内存的地址)写入寄存器ecx
mov DWORD PTR [ecx], eax;将eax的值写入ecx的值指向的内存(即变量j所在的内存) ; 9 : }

从上边初始化指针和引用的汇编码可以看出:两者都是保存的变量的地址,虽然初始化指针的时候明确使用了&运算符,但是初始化引用的时候,编译器仍然保存的是变量i的地址。

而在用指针和引用操作各自所指的变量的时候,对于引用,虽然没有使用*运算符,通过保存的地址获取变量的对应值,但是,编译器已经帮我们做了,可以把对引用和指针的操作的汇编码进行比较,可以发现,二者是一样的。

因此,对于指针,pj它保存的是变量j的地址,但是如果我们要通过指针变量pj获取j的指针,必须自己用*运算符做这种寻址操作;但是,但我们是用引用的时候,ri虽然也保存的是变量i,但是获取变量i的值的时候不必使用*运算符来寻址,因为编译器已经帮我们做了,正因为省略了用*运算符寻址的过程,使得引用使用起来更像普通变量一样,也就是说使用引用ri和使用变量i的效果是一样的。我觉得,这就是把引用称为变量的别名的原因。

作为一种特殊的类型,引用也有它自己的特点:

1 必须初始化

2 不能指向NULL

3 一旦初始化了引用,这个引用就一直引用某一变量,无法改变。比如,ri只能是i的引用,不能通过ri=j使其引用变量j,这条指令只是将ri引用的变量i赋值为j的值,ri仍然引用的是i,也就是说保存的仍然是变量i的地址,即给引用赋值,只能在初试化的时候。

4 永远也无法获取引用的地址,即无法通过&ri获取ri的地址,这样获取的都是变量i的地址

5 没有引用类型的数组,即,不能声明int& a[3];

2 传引用与返回引用

下面来看引用作为参数和返回值的情况:

c++源码如下:

//该函数传递引用也返回引用
int& f(int& i) {
i++;
return i;
} int main() {
int i = ;
int j = f(i);
}

下面是main函数对应的汇编码:

     : int main() {

    push    ebp
mov ebp, esp
sub esp, ; 8 : int i = 1; mov DWORD PTR _i$[ebp], ;将1写入_i$[ebp]所代表的内存,即为变量i赋值1 ; 9 : int j = f(i); lea eax, DWORD PTR _i$[ebp];将_i$[ebp]所代表的内存(即变量i所在内存)给寄存器eax
push eax;将eax的值压栈,作为参数传递给函数f
call ?f@@YAAAHAAH@Z ; 调用函数f
add esp, ;这条指令的作用是将栈顶指针下移4byte,目的是为了释放传递参数所分配的栈空间
mov ecx, DWORD PTR [eax];将eax的值所指向的内存(即变量i所在的内存)的内容(即变量i的值)给寄存器ecx
mov DWORD PTR _j$[ebp], ecx;将ecx的值写入_j$[ebp]所代表的的内存(即变量j所在内存),初始化变量j ; 10 : }

可以看到,传递给函数f的参数时实际参数变量i的地址,这和上面讲的ri=i一样,只不过这里使用实参初始化形参引用。

下面是函数f的汇编码:

; 2    : int& f(int& i) {

    push    ebp
mov ebp, esp ; 3 : i++; mov eax, DWORD PTR _i$[ebp]; 将_i$[ebp]所代表的的内存内容(即传进来的参数,也就是变量i的地址)给寄存器eax
mov ecx, DWORD PTR [eax];将eax的值所指向的内存(即变量i所在内存)的内容(即变量i的值)给寄存器ecx
add ecx, ;将ecx的值加1
mov edx, DWORD PTR _i$[ebp];将_i$[ebp]所代表的内存内容(即传进来的参数,也就是变量i的地址)给寄存器edx
mov DWORD PTR [edx], ecx;将ecx的值给edx的值所指向的内存(即变量i所在的内存) ; 4 : return i; mov eax, DWORD PTR _i$[ebp];将_i$[ebp]所代表的内存内容(即传进来的参数,也就是变量i的地址)给寄存器eax,作为返回值 ; 5 : }

可以看到,函数f返回的值也是一个地址,即实参i的地址。

从上面可以总结,当引用作为参数和返回值的时候,实际上也是传递和返回的地址

3 临时变量和引用

当用引用作为参数时,可能产生临时变量,然后用临时变量的地址初始化引用参数,但是产生临时变量有条件:

c++源码如下:

void f(int& i) {}

int main() {
int i;
f(i + );
}

编译之后,就会产生如下错误:
再谈c++中的引用

出现这种错误,是因为要调用函数f,必须为其传递一个地址,但是(i + 1)并不是一个变量,也就是说,并没有为(i + 1)生成临时变量,无法获取地址,它只是一个非左值。下面就来看一下生成临时变量的条件:

在引用参数为const的情况下:

1 实参的类型正确,但是不是左值(上面的类型就是这种情况)

2 实参的类型不正确,但是可以转换成正确的类型

所谓左值,就是可以被引用的数据对象,常规变量(可以修改的左值)和const变量(不可以修改的左值)都可以看成左值,而非左值,包括字面常量(比如单纯的数字1,但用引号括起来的字符串除外,他们有地址表示)和包含多项的表达式,他们不能被取地址,也不允许赋值。

因此,如果把上述函数f的参数类型改成const int&就可以通过编译,因为这时候会为(i + 1)生成临时变量。

为什么作为函数参数的引用类型要是const才能生成临时变量?因为使用引用作为函数参数的目的,就是可以修改做为参数传递的变量,但是,创建临时变量会阻止这种意图,也就是说,如果作为参数的引用没有const关键字,而临时变量依然可以产生,那么,引用操作的将是这些临时变量,而不是传递进来的参数本身(这和传值差不多)。而有const关键字的时候,表示函数不能改变这些传进来的参数,因此,用生成的临时变量来初始化这些参数也就无所谓了。

但是,上面的规则,只是对于内建类型成立,而对于复合类型(结构体和类,不包括数组,因为没有引用类型的数组,数组只能通过指针传递),总是会生成临时变量:

class X {
private:
int i;
int j;
};
void f(X& x) {}
int main() {
int i;
f(X());
}

上面的c++代码可以通过编译。