C++中引用(reference)的用法详解
1.简介
引用是C++引入的新语言特性。从语意上来说,引用就是一个变量的别名,就好象古代人的“字”和“号”,东坡居士和苏轼只是一个人的不同称呼。对引用的操作对变量产生的影响与对变量直接操作完全一样。例如:
int i = 0;
int & iRef = i;
iRef++; // i = iRef = 1
尽管引用不使用指针的操作符(*, ->)但是,它看上去跟指针好象并没有区别,而且就上面的例子而言,这个引用所产生的作用完全可以由指针完成。那么为什么C++中还要增加这样一个特性呢?引用显然应该具备指针不能完成的功能,否则它就失去了价值。这方面的探讨我们留到第3节。
2.引用的语法
在这里我们只讨论一些语法相关的问题。
·引用必须在定义的同时初始化
int i;
int & j; // 错误,没有初始化。
int & k=i; // 正确
这个例子有个很好的比喻,小时候小朋友间会互相起“外号”,这些外号在产生的时候总是有所指的,即针对一个具体的小朋友的。引用也一样,定义的时候,必须指明它是谁的别名。
·外部(extern)引用定义不必给出初值
extern int & i; // 正确,不必给出初值
·引用初始化后不能再使其成为其它变量的引用
int j, k;
int & i = j;
i = k; // 错误,不能更改!
引用类似一个常量指针(int * const p),不能修改引用的指向。
·引用的地址
假设有如下定义:
int j;
int & i = j;
那么,&i应该是什么呢?是一个“引用的地址”么?答案是:no。&i = &j,就是j这个变量的地址。
3.引用使用技巧
3.1 引用和多态
引用是除指针外另一个可以产生多态效果的手段。这意味着,一个基类的引用可以指向它的派生类实例。例如:
class A;
class B: public A
{
...
};
B b;
A & aRef = b; // 基类引用指向派生类
如果A类中定义有虚函数,并且在B类中重写了这个虚函数,就可以通过aRef产生多态效果。
3.2 作为参数
引用的一个重要作用就是作为函数的参数类型。C/C++的函数参数是传值的,如果有大对象(例如一个大的结构)需要作为参数传递的时候,以前的(C语言中)方案往往是指针,因为这样可以避免将整个对象全部压栈,可以提高程序的效率。但是现在(C++中)又增加了一种同样有效率的选择,就是引用。
与指针类型的参数一样,引用不论指向什么类型的对象,作为参数传递的时候都是只压栈4个字节(在32位机上)。引用所占用的4字节大小是根据编译器产生的代码判断的,因为sizeof(a_reference)只能得到它所指向对象的大小。
引用型参数应该在能被定义为const的情况下,尽量定义为const,这不光是让代码更健壮,也有些其它方面的需要,例如,假设有如下函数声明:
string foo();
void bar(string & s);
那么下面的表达式将是非法的:
bar(foo());
bar("hello world");
原因在于foo()和"hello world"串都会产生一个临时对象,而在C++中,这些临时对象都是const类型的。因此上面的表达式就是试图将一个const类型的对象转换为非const类型,这是非法的。
3.3 作为返回值
引用作为返回值的时候,有一些规则必须遵守。这些规则包括:
- 不能返回局部变量的引用。这条可以参照Effective C++[1]的Item 31。主要原因是局部变量会在函数返回后被销毁,因此被返回的引用就成为了“无所指”的引用,程序会进入未知状态。
- 不能返回函数内部new分配的内存的引用。这条可以参照Effective C++[1]的Item 31。虽然不存在局部变量的被动销毁问题,可对于这种情况(返回函数内部new分配内存的引用),又面临其它尴尬局面。例如,被函数返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由new分配)就无法释放,造成memory leak。
- 可以返回类成员的引用,但最好是const。这条原则可以参照Effective C++[1]的Item 30。主要原因是当对象的属性是与某种业务规则(business rule)相关联的时候,其赋值常常与某些其它属性或者对象的状态有关,因此有必要将赋值操作封装在一个业务规则当中。如果其它对象可以获得该属性的非常量引用(或指针),那么对该属性的单纯赋值就会破坏业务规则的完整性。
另外,引用也常常与一些操作符的重载相关:
- 流操作符<<和>>。这两个操作符常常希望被连续使用,例如:cout << "hello" << endl;因此这两个操作符的返回值应该是一个仍然支持这两个操作符的流引用。可选的其它方案包括:返回一个流对象和返回一个流对象指针。但是对于返回一个流对象,程序必须重新(拷贝)构造一个新的流对象,也就是说,连续的两个<<操作符实际上是针对不同对象的!这无法让人接受。对于返回一个流指针则不能连续使用<<操作符。因此,返回一个流对象引用是唯一选择。这个唯一选择很关键,它说明了引用的重要性以及无可替代性,也许这就是C++语言中引入引用这个概念的原因吧。
- 赋值操作符=。这个操作符象流操作符一样,是可以连续使用的,例如:x = j = 10;或者(x=10)=100;赋值操作符的返回值必须是一个左值,以便可以被继续赋值。因此引用成了这个操作符的唯一返回值选择。
在另外的一些操作符中,却千万不能返回引用:
- +-*/四则运算符。它们不能返回引用,Effective C++[1]的Item23详细的讨论了这个问题。主要原因是这四个操作符没有side effect,因此,它们必须构造一个对象作为返回值,可选的方案包括:返回一个对象、返回一个局部变量的引用,返回一个new分配的对象的引用、返回一个静态对象引用。根据前面提到的引用作为返回值的三个规则,第2、3两个方案都被否决了。静态对象的引用又因为((a+b) == (c+d))会永远为true而导致错误。所以可选的只剩下返回一个对象了。
3.4 什么时候使用引用
现在可以总结一下什么时候使用引用这个问题了。首先我们要看看什么时候必须使用引用:
- 流操作符<<和>>、赋值操作符=的返回值
- 拷贝构造函数的参数、赋值操作符=的参数
其它下面的情况都是推荐使用引用,但是也可以不使用引用。如果不想使用引用,完全可以使用指针或者其它类似的东西替代:
- 异常catch的参数表
- 大对象作为参数传递
- 返回容器类中的单个元素
- 返回类数据成员(非内建数据类型成员)
- 返回其它持久存在的,且获得者不负责销毁的对象
另外一些情况下,不能返回引用:
- +-*/四则运算符
4.参考资料
[1] Effective C++: Scott Meyers
----------------------------------------------
另参考:
c++比起c来除了多了类类型外还多出一种类型:引用。这个东西变量不象变
量,指针不象指针,我以前对它不太懂,看程序时碰到引用都稀里糊涂蒙过去。
最近把引用好好地揣摩了一番,小有收获,特公之于社区,让初学者们共享。
引用指的是对一个对象的引用。那么什么是对象?在c++中狭义的对象指的是
用类,结构,联合等复杂数据类型来声明的变量,如 MyClass myclass,CDialo
g mydlg,等等。广义的对象还包括用int,char,float等简单类型声明的变量
,如int a,char b等等。我在下文提到“对象”一词全指的是广义的对象。c++
的初学者们把这个广义对象的概念建立起来,对看参考书是很有帮助的,因为大
多数书上只顾用“对象”这个词,对于这个词还有广义和狭义两种概念却只字不
提。
一。引用的基本特性
首先让我们声明一个引用并使用它来初步认识引用。
例一:
1。 int v,k,h;
2。 int &rv=v;
3。 rv=3; //此时v的值也同时变成了3。
4。 v=5;
5。 k=rv+2; //此时k=5+2=7。
6。 h=12;
7。 rv=h;
8。 rv=20;
第1句声明了三个对象(简单变量)。
第2句的意思是:声明了一个引用,名字叫rv,它具有int类型,或者说它是
对int类型的引用,而且它被初始化为与int类型的对象v“绑定”在一起。此时r
v叫做对象v的引用。
第3句把rv的值赋为3。引用的神奇之处就在这里,改变引用的值的同时也改
变了和引用所绑定在一起的对象的值。所以此时v的值也变成了3。
第4句把v的值改为5,此时指向v的引用的值也被改成了5。所以第5句的中k的
值是5+2等于7。
上述5句说明了引用及其绑定的对象的关系:在数值上它们是联动的,改变你
也就改变了我,改变我也就改变了你。事实上,访问对象和访问对象的引用,就
是访问同一块内存区域。
第6,7,8三句说明了引用的另一个特性:从一而终。什么意思?当你在引用
的声明语句里把一个引用绑定到某个对象后,这个引用就永远只能和这个对象绑
定在一起了,没法改了。所以这也是我用了“绑定”一词的原因。而指针不一样
。当在指针的声明语句里把指针初始化为指向某个对象后,这个指针在将来如有
需要还可以改指别的对象。因此,在第7句里把rv赋值为h,并不意味着这个引用
rv被重新绑定到了h。事实上,第7句只是一条简单的赋值语句,执行完后,rv和
v的值都变成了12。第8句执行完后,rv和v的值都是20,而h保持12不变。
引用还有一个特性:声明时必须初始化,既必须指明把引用绑定到什么对象
上。大家知道指针在声明时可以先不初始化,引用不行。所以下列语句将无法通
过编译:
int v;
int &rv;
rv=v;
再举一例:
例二:
class MyClass
{
public:
int a;
...
...
};
MyClass myclass;
Myclass& cc=myclass;
myclass.a=20; //等价于cc.a=20
cc.a=60; //等价于myclass.a=60
从以上例子可以看到,无论这个对象有多复杂,使用该对象的引用或是使用
该对象本身,在语法格式上是一样的,在本质上我们都使用了内存中的同一块区
域。
取一个引用的地址和取一个对象的地址的语法是一样的,都是用取地址操作
符"&"。例如:
int i;
int &ri;
int *pi=&ri;//这句的作用和int *pi=&i是一样的。
当然了,取一个对象的地址和取这个对象的引用的地址,所得结果是一样的
。
二。引用在函数参数传递中的作用
现在让我们通过函数参数的传递机制来进一步认识引用。在c++中给一个函数
传递参数有三种方法:1,传递对象本身。2,传递指向对象的指针。3,传递对象
的引用。
例三:
class MyClass
{
public:
int a;
void method();
};
MyClass myclass;
void fun1(MyClass);
void fun2(MyClass*);
void fun3(MyClass&);
fun1(myclass); //执行完函数调用后,myclass.a=20不变。
fun2(&myclass); //执行完函数调用后,myclass.a=60,改变了。
fun3(myclass); //执行完函数调用后,myclass.a=80,改变了。
//注意fun1和fun3的实参,再次证明了:使用对象和使用对象的引用
,在语法格式上是一样的。
void fun1(MyClass mc)
{
mc.a=40;
mc.method();
}
void fun2(MyClass* mc)
{
mc->a=60;
mc->method();
}
void fun3(MyClass& mc)
{
mc.a=80;
mc.method();
}
我们有了一个MyClass类型的对象myclass和三个函数fun1,fun2,fun3,这三个
函数分别要求以对象本身为参数;以指向对象的指针为参数;以对象的引用为参
数。
请看fun1函数,它使用对象本身作为参数,这种传递参数的方式叫传值方式
。c++将生成myclass对象的一个拷贝,把这个拷贝传递给fun1函数。在fun1函数
内部修改了mc的成员变量a,实际上是修改这个拷贝的成员变量a,丝毫影响不到
作为实参的myclass的成员变量a。
fun2函数使用指向MyClass类型的指针作为参数。在这个函数内部修改了mc所
指向的对象的成员变量a,这实际上修改的是myclass对象的成员变量a。
fun3使用myclass对象的引用作为参数,这叫传引用方式。在函数内部修改了
mc的成员变量a,由于前面说过,访问一个对象和访问该对象的引用,实际上是访
问同一块内存区域,因此这将直接修改myclass的成员变量a。
从fun1和fun3的函数体也可看出,使用对象和使用对象的引用,在语法格式
上是一样的。
在fun1中c++将把实参的一个拷贝传递给形参。因此如果实参占内存很大,那
么在参数传递中的系统开销将很大。而在fun2和fun3中,无论是传递实参的指针
和实参的引用,都只传递实参的地址给形参,充其量也就是四个字节,系统开销
很小。
三。返回引用的函数
引用还有一个很有用的特性:如果一个函数返回的是一个引用类型,那么该
函数可以被当作左值使用。什么是左值搞不懂先别管,只需了解:如果一个对象
或表达式可以放在赋值号的左边,那么这个对象和表达式就叫左值。
举一个虽然无用但很说明问题的例子:
例四:
1。 int i;
2。 int& f1(int&);
3。 int f2(int);
4。 f1(i)=3;
5。 f2(i)=4;
int& f1(int&i)
{
return i;
}
int f2(int i)
{
return i;
}
试试编译一下,你会发现第4句是对的,第5句是错的。对这个例子而言,i的
引用被传递给了f1,然后f1把这个引用原样返回,第4句的意义和i=3是一样的。
查了查书,引用的这个特性在重载操作符时用得比较多。但是我对重载操作
符还是稀里糊涂,所以就举不出例子了。
强调一个小问题,看看如下代码有何错误:
int &f1();
f1()=5;
...
...
int &f1()
{
int i;
int &ri=i;
return ri;
}
注意函数f1返回的引用ri是在函数体内声明的,一旦函数返回后,超出了函
数作用域,ri所指向的内存区域,即对象i所占据的内存区域就被收回了,再对这
片内存区域赋值会出错的。
四。引用的转换
前面所举的例子,引用的类型都是int类型,并且这些引用都被初始化为绑定
到int类型的对象。那么我们设想是否可以声明一个引用,它具有int类型,却被
初始化绑定到一个float类型的对象?如下列代码所示:
float f;
int &rv=f;
结果证明这样的转换不能通过msvc++6.0的编译。但是引用的转换并非完全不
可能,事实上一个基类类型的引用可以被初始化绑定到派生类对象,只要满足这
两个条件:1,指定的基类是可访问的。2,转换是无二义性的。举个例子:
例五:
class A
{
public:
int a;
};
class B:public A
{
public:
int b;
};
A Oa;
B Ob;
A& mm=Ob;
mm.a=3;
我们有一个基类A和派生类B,并且有一个基类对象Oa和派生类对象Ob,我们
还声明了一个引用mm,它具有基类类型但被绑定到派生类对象Ob上。由于我们的
这两个类都很简单,满足那两个条件,因此这段代码是合法的。在这个例子中,
mm和派生类Ob中的基类子对象是共用一段内存单元的。所以,语句mm.a=3相当于
Ob.a=3,但是表达式mm.b却是不合法的,因为基类子对象并不包括派生类的成员
。
五。总结
最后把引用给总结一下:
1。对象和对象的引用在某种意义上是一个东西,访问对象和访问对象的引用其实
访问的是同一块内存区。
2。使用对象和使用对象的引用在语法格式上是一样的。
3。引用必须初始化。
4。引用在初始化中被绑定到某个对象上后,将只能永远绑定这个对象。
5。基类类型的引用可以被绑定到该基类的派生类对象,只要基类和派生类满足上
文提到的两个条件 。这时, 该引用其实是派生类对象中的基类子对象的引用
。
6。用传递引用的方式给函数传递一个对象的引用时,只传递了该对象的地址,系
统消耗较小。在函数体内访问 形参,实际是访问了这个作为实参的对象。
7。一个函数如果返回引用,那么函数调用表达式可以作为左值。
六。其他
1。本文中的代码在msvc++6.0中调试验证过。
2。第四节“引用的转换”中的例子:
float f;
int &rv=f;
查看bc++3.1的资料,据说是合法的。此时编译器生成了一个float类型的临时
对象,引用rv被绑定到了这个临时对象上,就是说,此时rv并不是f的引用。不知
道bc++3.1里的这个特性有什么用。
3。可以在msvc++6.0里声明这样的引用:
const int &rv=3;
此时rv的值就是3,而且无法更改。这可能没有有什么用。因为如果我们要使
用一个符号来代表常数的话,有的是更常见的方法:
#define rv 3
4。把第四节中的例子稍稍修改一下:
float f;
int &rv=(int&)f;
这时就可以通过msvc++6.0的编译了。此时rv被绑定到了f上,rv和f共用一片
存储区。不过由于引用rv的类型是int,所以通过rv去访问这片存储区时,存储区
的内容被解释为整数;通过f去访问这片存储区时,存储区的内容被解释为实数。
----------------------------------------------------------
关于引用的初始化有两点值得注意:
(1)当初始化值是一个左值(可以取得地址)时,没有任何问题;
(2)当初始化值不是一个左值时,则只能对一个const T&(常量引用)赋值。而且这个赋值是有一个过程的:
首先将值隐式转换到类型T,然后将这个转换结果存放在一个临时对象里,最后用这个临时对象来初始化这个引用变量。
例子:
double& dr = 1; // 错误:需要左值
const double& cdr = 1; // ok
第二句实际的过程如下:
double temp = double(1);
const double& cdr = temp;
特别注意第二种情况 会产生临时对象,此时引用绑定的是哪个临时对象 如果是非const说明你想对引用的对象进行修改,此时你修改的是绑定的临时对象,原对象没变,也就没有达到你的目的,所以c++明确禁止这样初始化。使用时候尽量避免这种初始化,会导致产生临时变量的开销,大的对象时这个开销是很大的。
补充:何时产生临时对象。一 需要隐式类型转换时;二 函数返回值。 上述临时对象就是第一种情况。