(五)引用
什么是引用?
引用也是c++的初学者比较容易迷惑的概念,它几乎拥有指针所有的功能,但是语法更加简单,本章我们就来学习引用,并且分清它与指针的区别.
那么什么是引用呢?
简单概述:引用就是别名
例:
int main(){
int num;
//注意:别名mum前面的符号&不是取址运算符,而是引用运算符,虽然它们符号相同,但是功能却不一样.
int &mum=num;
// mum是num的别名,这两个变量是一个变量,只不过名字不同而已,这就好像李四有个外号叫李大嘴,大家称呼李四指的是李四这个人,称呼李大嘴也是指的李四
//这个人,李四和李大嘴都是一个人,只是名字出现了不同.
}
引用的地址
int a;
int &ra=a;
按照栈堆内存的方式就可以很容易理解 a ra在栈内存 而他们的实例是在堆内存,他们引用是相同的。
引用就是别名常量
当给一个变量定义了一个别名,那么该别名就永远属于这个变量,当有别的变量对它进行赋值时,虽然它的内容会改变,但是它的内存地址值不会发生变化,同时这个别名所对应的变量的
值也会发生改变.
由于这种特性,所以我们可以将引用看成是一个别名常量,它的内存空间无法改变,能改变的只是它所引用的值。
引用对象
我们也可以定义一个对象的别名,例:
Human Mike;//定义一个Human对象Mike
Human &rMike=Mike;//定义一个Mike的别名 rMike;
注意:定义一个引用的时候,一定要对该引用进行初始化.
以下例子是错误的:
int a;
int &ra;
ra=a;
这样是错误的.引用就象常量,只能对其初始化,不能赋值。
空引用
我们知道指针进行删除操作后,需要将它们赋值设为空,引用却不需要这么做,这是因为引用是原来对象的别名,加入该对象存放在栈中,那么在对象超出作用域时别名会和对象一起消失.
加入该对象存放在堆中,由于堆中内存空间必须使用指针来访问,因此用不着别名,即使在定义一个该指针的别名,那么将指针删除并赋空之后,该指针的别名中的地址也相应赋空了。
按值传递
什么是按值传递呢?
例如:
void swap(int a,int b){
int c;
cout<<"swap函数中,交换前,a:"<<a<<"b:"<<b<<endl;
c=a;
a=b;
b=c;
cout<<"swap函数中,交换后,a:"<<a<<"b:"<<b<<endl;
}
int main(){
int a=3,b=4;
cout<<"在主程序中,交换前,a:"<<a<<"b:"<<b<<endl;
swap(a,b);
cout<<"在主程序中,交换后,a:"<<a<<"b:"<<b<<endl;
return 0;
}
通过上面事例可以发现,主程序中(main函数)的a b 两个值在交换的前后并没有发生改变,而swap函数中的ab在交换前后发生了改变
这到底是为什么呢?加入说swap函数没有交换主程序中的a和b,那么它交换的到底是谁的值呢?
这个问题看起来复杂,其实很简单,swap函数交换的是main函数中a和b的“副本”的值,也就是说在main函数中定义的a和b的备份的值,swap函数交换的是main函数中的a和b的副本,
而不是a和b本身。
那么为什么swap函数不直接交换a和b本身,却去交换它们的副本的值呢?
这是因为当我们直接将a和b传递给swap函数时,这样的传递方式是 --按值传递---.
假如将a和b按值传递给swap函数,那么编译器会自动在栈中创建a和b的拷贝,然后将a和b的拷贝传递给swap函数.在swap函数中对a和b的拷贝进行交换.因此我们看到的输出语句,a和
b确实进行了交换,只不过交换的是a和b的副本。
由于交换的是a和b的副本,并不是a和b本身,所以在swap函数结束后,输出的值显示main函数中的a和b并没有改变.
按址传递
什么是按址传递?
从字面上理解就是按地址的方式传递.
例:
void swap(int *a,int *b){//这里参数要改为指针
int c;
cout<<"swap函数中,交换前,a:"<<*a<<"b:"<<*b<<endl;
c=*a;
*a=*b;
*b=c;
cout<<"swap函数中,交换后,a:"<<*a<<"b:"<<*b<<endl;
}
int main(){
int a=3,b=4;
cout<<"在主程序中,交换前,a:"<<a<<"b:"<<b<<endl;
swap(&a,&b);//这里加上取地址符。
cout<<"在主程序中,交换后,a:"<<a<<"b:"<<b<<endl;
return 0;
}
根据输出就可以看到,主函数中的a和b也改变了.
按别名传递
把指针作为函数的接收参数虽然能够正常使用,但是它却不易阅读,而且很难使用.
所以我们可以把接收指针参数改为接收两个别名,由于别名改变的是内存地址中的值,所以也可以完成改变main函数中a b值的需求
void swap(int &a,int &b){//这里参数要改为指针
int c;
cout<<"swap函数中,交换前,a:"<<a<<"b:"<<b<<endl;
c=a;
a=b;
b=c;
cout<<"swap函数中,交换后,a:"<<a<<"b:"<<b<<endl;
}
int main(){
int a=3,b=4;
cout<<"在主程序中,交换前,a:"<<a<<"b:"<<b<<endl;
swap(a,b);//这里加上取地址符。
cout<<"在主程序中,交换后,a:"<<a<<"b:"<<b<<endl;
return 0;
}
利用指针返回多值
我们知道函数只能返回一个值,那么假如有的时候我们需要函数返回多个值时该怎么办?
指针或者引用可以帮助我们解决这个问题,我们使用别名或者指针的方式传递给函数一个以上的变量,在函数体中将需要返回的值赋给这些变量,由于使用引用或者指针传递变量允许函数
改变原来的变量.因此这些在函数体中被修改的变量均可以看做是已经被该函数返回的值。
下例:用指针来演示一下返回三个值的操作。
int func(int a,int *b,int *c);//声明
int main(){
int a=1,b=2,c=3;
cout<<"主程序,调用func函数前..\n";
cout<<"a:"<<a<<endl<<"b:"<<b<<endl<<"c:"<<c<<endl;
func(a,&b,&c);
cout<<"主程序,调用func函数前..\n";
cout<<"a:"<<a<<endl<<"b:"<<b<<endl<<"c:"<<c<<endl;
return 0;
}
int func(int a,int *b,int *c){
cout<<"func函数中,计算前...\n";
cout<<"a:"<<a<<endl<<"b:"<<*b<<endl<<"c:"<<*c<<endl;
a=a+1;
*b=(*b)*(*b);
*c=(*c)*(*c);
cout<<"func函数中,计算后...\n";
cout<<"a:"<<a<<endl<<"b:"<<*b<<endl<<"c:"<<*c<<endl;
return a;
}
写完以后我发现好坑爹啊,这里的返回3个值的概念,居然是改变3个值?好坑...
这样的方法的好处是我们可以实现汇报执行程序时的非法操作信息,我们也可以把a作为返回的判断值,把*b和*c作为运算的返回值。
用接下来的代码进行演示:
int func(int a,int *b,int *c);//声明
int main(){
int a,b,c;
int check;
cout<<"请输入要进行运算的数字,";
cout<<"您输入的数字将作为圆的半径和正方形的边长,";
cin>>a;
check=func(a,&b,&c);
if(check){
cout<<"输入的数字超过计算范围!\n";
}else{
cout<<"圆的面积为:"<<b<<endl;
cout<<"正方形的面积为:"<<c<<endl;
}
return 0;
}
int func(int a,int *b,int *c){
if(a>20000){
a=1;
}else{
*b=a*a*3.14;
*c=a*a;
}
}
用引用来返回多值
其实就是改变多值了,可看可不看,用引用重写上面的代码,
int func(int a,int &b,int &c);//声明
int main(){
int a,b,c;
int check;
cout<<"请输入要进行运算的数字,";
cout<<"您输入的数字将作为圆的半径和正方形的边长,";
cin>>a;
check=func(a,b,c);
if(check){
cout<<"输入的数字超过计算范围!\n";
}else{
cout<<"圆的面积为:"<<b<<endl;
cout<<"正方形的面积为:"<<c<<endl;
}
return 0;
}
int func(int a,int &b,int &c){
if(a>20000){
a=1;
}else{
b=a*a*3.14;
c=a*a;
}
}
按值传递对象
从前面几节我们了解了按址传递和按值传递的区别,按址传递可以修改原始变量的值,按值传递由于是传递的原始变量的副本,因此它不会修改原始变量的值.
假如仅仅是传递变量的话,采用指针或者引用这种按址传递的优势不是很明显,但是假如是传递较大的对象的话,这种优势是相当的明显的.
这是因为,按值传递在向函数传递一个对象时,会象传递变量那样建立一个该对象的拷贝,而从函数返回一个对象时,也要建立这个被返回的对象的一个拷贝.
这样加入该对象的数据非常多时,这种拷贝带来的内存开销是相当可观的.比如说该对象拥有0.000多个double型成员变量,每个double型变量占据8个字节,1000个就占据8000
字节,每次通过值传递的方式给函数传递该对象,都要在栈中复制该对象,占用8000个字节的栈内空间,而返回该对象,又要在栈中复制一次,这样就又要占用8000个字节的内存空间.我
们知道栈的内存只有2M大小,8000个字节占用8K,那么仅仅传递该对象就占用了栈内16k字节的空间。并且别的对象想要访问该对象的8000个数据成员的时候,也要同样采取复制的方式
,那么系统的开销将无法估算了。
然后,按值传递所付出的开销远不止如此,由于在传递过程中需要复制对象,因此会默认调用复制构造函数,该函数的作用就是创建某个对象的临时副本.关于复制构造函数,将会在深
入函数中做进一步讲解,这里你只需要知道,这要在栈中创建临时拷贝都会自动调用复制构造函数即可。
而当函数返回时,传递该对象时创建的该对象的副本会被删除,这时候又会自动调用该对象的析构函数来释放内存。假设返回的仍然是该对象,并且仍旧采用按值传递的方式,那么就
又会调用复制构造函数建立一个该对象的临时副本,当改值被成功返回给调用程序后,然后再调用该对象的析构函数删除临时拷贝并释放内存。
我们看到复制构造函数和析构函数一连被执行了两次,这无疑会增加系统的开销,我们用一个实例来演示一下按值传递一个对象的复制与删除过程。
class A{
public :
A(){cout<<"执行构造函数创建一个对象\n";}
A(A&){cout<<"执行复制构造函数创建该对象的副本\n";}
~A(){cout<<"执行析构函数删除该对象\n";}
};
A func(A one){
return one;//由于返回也是按值传递 所以又调用了类A的复制构造函数
}
int main(){
A a;//创建一个对象会自动调用构造函数
func(a);//将对象a按值传递给func函数中,然后对调用类A的复制构造函数
return 0;
}
通过输出可以发现,将一个对象按值传递给一个函数,会调用两次复制构造函数和两次析构函数,这样系统的开销很很大的
按址传递对象
为了解决上面开销大的问题,我们可以如下写法:
A func(A *one){
return *one;
}
int main(){
A a;
func(&a);
return 0;
}
这样就可以减少一次复制构造函数的执行,也就对应的减少一次调用析构函数来删除复制构造函数创建对象.
但是复制构造函数依然执行了一次,那么我们如何避免这次复制构造函数的执行呢?
上面函数func中 返回return *one 由于返回的是对象,而不是地址,所以这种返回方式是按值返回.就会造成调用复制构造函数。
如果我们不需要调用复制构造函数那么就可以如下写
A* func(A *one){
return one;
}
这里需要注意的是返回值是类A的内存地址引用 写法为 A* 而不能是A 否则就会报类型不匹配异常。
使用const指针传递对象
按址传递对象虽然可以避免调用复制构造函数和析构函数,但是由于它得到了该对象的内存地址,可以随时修改对象的数据.所以它实际上是破坏了按值传递的保护机制。比如说按值传递
就象把卢浮宫的那副达芬奇的画制作了一个副本,送交法国总理希拉克的官邸,这样希拉克对该画的任何操作也不会影响到原画。不过假如希拉克亲自跑到卢浮宫去观赏原画,那么他就完
全可以对原画进行修改或操作。不过我们仍然对此有解决办法,那就是用const指针来接受对象,这样就可以防止任何试图对该对象所进行的操作行为,并且保证返回一个不被修改的对象
例:
const A* const func(const A *const one){}
//这样参数one前加上const 那么参数one就是不可修改的
//在A前加上const这样参数指针one 指向的对象也就是不可修改的
//在func前面加上const 代表返回的one指针不可修改
//在类名A前面加上const 代表返回的指针one 也是不可以被修改的
这样就保证了传递进来的数据不被修改,同时又保证了返回的数据也不会被修改。
我们将函数的返回值和接收参数都定义为const,就可以保证函数内不可修改原始值,同时避免利用返回值对原始值进行修改.所以加上这个const,实际上是为了实现按值传递的开销,
因为不用再调用复制构造函数。但是加const很麻烦,下面有种简便的方法.按别名传递对象。
按别名传递对象
由于引用不能重新分配去引用另一个对象,它始终是常量,所以我们不用将它设置为常量.所以就不需要加const修饰符
A *cunc(A &one){
return one;
}
int main(){
A a;
a.set(11);
A &b=func(a);
count<<b.get()<<endl;
return 0;
}
那么到底是引用还是指针呢?
既然引用实现了指针的功能,而且使用起来更加方便,为什么还要指针呢?
这是因为指针可以为空,但是引用不能为空,指针可以被赋值,但是引用只可以被初始化,不可以被赋为另一个对象的别名.如果你想使一个变量记录不同对象的地址,那么就必须使用指针
例:
int main(){
int a=6;
int *p=&a;
int b=9;
p=&b;
return 0;
}
指针p可以让其指向a 也可以指向b 但是别名却不可以,另外,在堆中创建一块内存区域,必须要用指针来指向它,否则该区域就会变成无法访问的内存空间。当然我们也可以使用引用
来引用指向内存空间的指针。
用例子来演示上面这句话的意思
例:
int main(){
int *p=new int;
int &r=*p; //这样,这个r就变成了用指针p读取到的值的别名。
r=4;
cout<<*p<<endl;
return 0;
}
但是我们要明白一点,我们不可以直接用引用来指向堆中新建的空间,因为引用只是个别名,它不可以作为指针来使用。
因为在机器运行不正常的情况下,也就是机器虚拟内存大小,无法创建新空间的情况下,那么new int 会自动返回一个空指针。我们知道引用不能为空,因此这种情况下使用
int *&r=new int;就会导致一个无用的别名。而使用(*)读取一个无用的别名则会引起系统崩溃。
解决的办法是不要将引用初始化为新建内存区域的别名,而要将r初始化为指向该区域的指针的别名。前提是首先要判断该指针不为空。
例:
int *p=new int; //第一行定义了一个指向int的指针p,该指针指向新建的一块内存.
if(p!=null){ //第二行测试p,加入不为空,表示空间创建成功。
int &r=*p; //将r初始化为p指向的内存空间中数据的别名
r=3; //设置空间值为3
cout<<r<endl; //输出该空间的值
}
指针与引用的区别:
指针可以为空,引用不能为空.
指针可以被赋值,引用不能被赋值。
指针可以指向堆中空间,引用不可以指向堆中空间。
了解了 指针与引用的区别以后,我们就可以有选择的来使用指针或者引用了。
引用和指针可以一起用
上一节我们已经学习了一个指针和引用混合使用的例子,如:int *&r=new int;
int *func(int &one,int *two,int x);
上面这行语句声明了一个func函数,该函数有三个参数,第一个是int型变量的别名one,第二个是指向int型变量的指针two,第三个是整型参数x。该函数返回一个指向int型变量
的指针。
另外我们也要注意指针的一些特殊写法,如:
int *r,ra; 这里的r是指针 ra是变量。一定不要将两者都看作是指针
引用容易犯的错误
与指针一样,引用使用不当也会出现致命性的错误。我们知道引用是对象的别名,那么假如这个对象不存在了,使用这个对象的别名会产生什么样的后果呢?
class A{
public:
A(int i){x=i;}
int get(){return x;}
private:
int x;
};
A&func(){
A a(23);
return a;
}
int main(){
A &r=func();//用别名接收别名
cout<<r.get()<<endl;
return 0;
}
由于对象a是个局部对象,因此当函数func结束后,局部对象a也就被删除了。由于对象a消失了,所以func()函数返回的其实是一个并不存在的对象的别名。因此打印的结果并不是23
,返回一个随机数,如果去掉func方法上的& 那么就会返回a
引用按值返回的堆中对象
常见错误2.
A func(){
cout<<"跳转到func函数中!\n";
A *p=new A(99);
cout<<"队中对象的内存地址"<<p<<endl;
return *p;
}
int main(){
A &r=func();
cout<<"队中对象的副本的地址"<<&r<<endl;
cout<<r.get()<<endl;
return 0;
}
由于p所指向的堆中空间必须使用delete运算符才能被删除,因此该空间成了不可访问的区域,结果导致了内存泄露。
又或者说:p指针被删除了,它所指向的空间还存在。该空间的地址只有p保存着,而p找不到了,所以我们无法找到该空间。由于无法找到该空间,所以无法对其进行释放,结果造成了
内存泄露。
引用按别名返回的堆中对象
要避免上面例子出现的内存泄露,我们就不能用按值的方式来返回一个堆中对象,而必须按地址或者别名的方式返回一个别名或者内存地址,这样就不会调用复制构造函数创建一个该对象的
副本,而是直接将该对象的别名或者地址返回。由于返回的对象的别名或者地址初始化给了main函数中的一个引用或者指针,因此即使被调用函数中的局部指针超过作用域被系统释放,也
可由main函数中的引用或者指针找到该堆中空间,不会令该空间成为不可访问的区域,从而避免了内存泄露。
所以将上例中的func()函数改为
A& func(){
cout<<"跳转到func函数中!\n";
A *p=new A(99);
cout<<"堆中对象的地址:"<<p<<endl;
return *p;
}
int main(){
A &r=func();
cout<<"堆中对象的副本的地址:"<<&r<<endl;
cout<<r.get()<<endl;
A *p=&r;
delete p;
return 0;
}
通过输出语句可以看出两次的内存地址是相同的,由于它们的地址相同,所以即使func函数中的局部指针p被系统自动销毁了,我们也能通过main函数中的别名r来访问到堆中对象。
因为r与堆中对象的地址是相同,r是堆中对象的别名,所以对r的操作就是对堆中对象的操作。由于无法对引用使用delete运算符,因此我们只能定义一个指针来存储应用的地址,然后
删除该指针指向的内存空间。
这一切操作都非常顺利,程序也输出了正确的结果。但是这个程序却隐藏着一个非常严重的问题。
由于p指向的堆中对象被删除了,因此堆中对象的别名r成了个空别名。它就成为了一个不存在的对象的别名,因此假如再使用这个空别名去访问不存在的对象的成员函数get()时 就会输出
一个随机数。这样都导致了一个不易察觉的错误。
在哪里创建,就在哪里释放
为了解决上例中的不易察觉的错误,我们就需要做到在哪里创建,就在哪里释放。
只要在堆中创建一块内存空间,就会返回一个指向该空间的指针,我们一定不要弄丢该指针,加入该指针丢失,那么该堆中空间就会成为一块不可访问的区域,也就是我们常说的内存泄露
同时假如我们将存储在堆中的对象初始化给一个引用,那么当该对象被删除时,这个引用也就成了空引用,假如我们无意中使用了这个空引用的话,就会令程序出错。
上面的两个例子中的程序所犯的错误,在这里就来解决这些错误,我们可以在func()函数中创建堆中对象,然后在main函数中释放该对象,但是在上例的程序告诉我们,这样也不安全,虽然
我们也可以用指针来代替引用,但是加入我们无法确定指向堆中对象的指针是哪一个,是func()函数的p指针,还是main函数中接受堆中对象的r指针,那么我们就有可能将该指针删除两次,
或者忘记删除指针。这样为了避免指针混淆,我们就必须:在哪里创建,就在哪里释放。
因此我们在main函数中创建一个堆中对象,然后按引用的方式传递到func()函数中,在func()函数中对该对象操作完毕后返回该对象,然后在main函数中释放该对象.这样就实现了在
main函数中创建,在main函数中释放。
写法如下:
#include<iostream>
using namespace std;
class A{
public:
A(int i){cout<<"执行构造函数创建一个对象\n";x=1;}
A(A&A){x=a.x;cout<<"执行复制构造函数创建一个对象\n";}
~A(){cout<<"执行析构函数!\n";}
int get(){return x;}
void set(int i){x=1;}
private:
int x;
};
A& func(A&a){
cout<<"跳转到func函数中!\n";
a.set(66);
return a;
}
int main(){
A *p=new A(99);
func(*p);
cout<<p->get()<<endl;
delete p;
return 0;
}