Hello,大家好,今天我们就来聊聊有关C++中的引用知识
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间
比如:李逵,在家称为"铁牛",江湖上人称"黑旋风"。【水浒108将各个有称号】
那要怎么去“引用”呢?
[&]
,它是【按位与】,也是【取地址】,但是在C++中呢,它叫做【引用】它的语法是怎样的呢?
类型& 引用变量名(对象名) = 引用实体;
int a = 10;
int& b = a;
int&
,那么就必须要去对其进行一个初始化,指定一个其引用的对象,否则就会报错int a = 10;
int& b = a;
int& c;
a == b == c
int a = 10;
int& b = a;
int& c = a;
int a = 10;
int& b = a;
int& c = b;
int a = 10;
int c = 20;
int& b = a;
int& b = c;
double
类型,c2引用c1,所以c2也是double
类型的。其他类型可以自己试试看double c1 = 3.14;
double& c2 = c1;
然后我们重点来说说有关指针这一块的引用【⭐】
int a = 10;
int* p = &a;
int*& q = p;
int*
代表q是一个指针类型,&
则表示指针q将会去引用另一个指针int*&
的这个写法要认识一下,我在下面还会讲到以上就是有关C++中的引用所要介绍的特性,还望读者牢记
知道了引用的基本特性后,接下去我们来聊聊有关它的使用场景
void swap1(int* px, int* py)
{
int t = *px;
*px = *py;
*py = t;
}
swap1(&a, &b);
void swap2(int& x, int& y)
{
int t = x;
x = y;
y = t;
}
通过运行结果来看确实也可以起到交换两数的功能
void swap(int* px, int* py)
{
int t = *px;
*px = *py;
*py = t;
}
void swap(int& x, int& y)
{
int t = x;
x = y;
y = t;
}
通过调试来看一下吧
在讲解引用的特性时,我说到了引用的类型不仅仅限于普通变量,还可以是指针。但上面说的是普通指针,接下去我们来说说结构体指针,也涉及到了引用类型在做参数时的场景
typedef struct SingleNode {
struct SingleNode * next;
int val;
}SLNode;
void PushFront(SLNode** SList, int x)
{
SLNode* newNode = BuyNode(x);
newNode->next = *SList;
*SList = newNode;
}
int main(void)
{
SLNode* slist;
PushFront(&slist, 1);
return 0;
}
SList
也就相当于是外部函数外部传入的实参slist
。这就是很多学校《数据结构》的教科书中统一的写法,说是使用了纯C实现,但却利用了C++中的【引用】,如果没有学习过C++的小伙伴一定是非常难受void PushFront(SLNode*& SList, int x);
PushFront()
内部我们也可以去做一个修改,直接使用形参SList
即可,无需考虑到要对二级指针进行解引用变为一级指针void PushFront(SLNode*& SList, int x)
{
SLNode* newNode = BuyNode(x);
newNode->next = SList;
SList = newNode;
}
最后再来补充一点,很多教科书不仅仅是像上面这种写法,而且还会更加精简,它们将结构体定义成这种形式
typedef struct SingleNode {
struct SingleNode * next;
int val;
}SLNode, *PNode;
typedef
的作用,对于SLNode而言其实就是对这个结构体的类型由进行了一个typedef,也就是对其进行一个重命名,这样我们在使用这个结构体定义变量的时候就不需要再去写struct SingleNode slist
了,直接写成SLNode slist
即可typedef struct SingleNode SLNode
*PNode
是什么意思呀❓”。这也是我重点要说明的部分,其实这就相当于是对struct SingleNode*
做了一个typedef,也就是对这个结构体指针的类型做了一个重命名叫做【PNode】,那后面如果要使用这个结构体指针的话直接使用的【PNode】即可typedef struct SingleNode* PNode
于是对于头插的形参部分又可以写成下面这种形式,与SLNode*& SList
是等价的
void PushFront(PNode& SList, int x);
最后,我们来讲讲有关引用做参的第三个场景,也就是在递归调用的时候
指针的解引用
来带动外部的变化BTNode* ReBuildTree(char* str, int* pi)
char str[20] = "abc##de#g##f###";
int i = 0;
BTNode* root = ReBuildTree(str, &i);
InOrder(root);
pi
好了),因为pi
就是i
的引用,两者是等价的,属于同一块空间,因此无论函数递归调用多少层,内部参数的变化会也会带动外部的变化BTNode* ReBuildTree(char* str, int& pi)
{
if (str[pi] == '#')
{
pi++;
return NULL;
}
BTNode* root = (BTNode*)malloc(sizeof(BTNode));
root->val = str[pi++];
root->left = ReBuildTree(str, pi);
root->right = ReBuildTree(str, pi);
return root;
}
BTNode* root = ReBuildTree(str, i);
第二种引用的场景就是【做返回值】,因为这种场景在后面说到的C++里的类和对象中会大量出现,而且由于引用的语法很难理解,因此我会带你一步步学习,搞懂这这一种场景的使用
在讲引用做返回值之前我需要讲解一些知识点作为铺垫,希望正在阅读的你也可以认真观看和思考,这对下面的理解至关重要
int Add(int x, int y)
{
int c = x + y;
return c;
}
int main(void)
{
int ret = Add(1, 2);
cout << "ret = " << ret << endl;
return 0;
}
call
指令的时候,会把call指令的下一条指令地址压入栈中,相当于记住了这个地址。eax
)call
指令的下一跳指令开始执行,此时我们通过汇编指令mov
就可以知道编译器将【eax】中临时存放的函数的返回值转存到了这个临时变量ret中如果上面有些看不懂的话可以看看函数栈帧的创建和销毁
知道了这些以后我们再来对比一下下面的两个Count函数,你觉得它们哪里不太一样呢
int Count()
{
int n = 0;
n++;
// ...
return n;
}
int Count()
{
static int n = 0;
n++;
// ...
return n;
}
static
的区别。通过画出函数调用的堆栈图我们可以看出对于两个不同的Count()函数而言其内部临时变量所存放的位置是不同的。我们知道,对于函数中的普通变量而言,是存放在当前所开辟函数的栈帧中的,即存放在内存中的栈区;但是对于函数中的静态变量而言,是不存放在当前函数栈帧中的,而是存放在内存中的静态区,包括平常可能会使用到的全局变量也是存放在其中首先你必须要清楚的一些点:
4 / 8B
,若是一个函数需要返回空间很大的东西时就无法承载,就比如说要返回一个结构体就可能会放不下,因为结构体中存在各种各样的数据类型。所以对于临时变量而言有下面两种形式
静态区中的变量
在函数栈帧销毁之后是如何返回给到外界的值做接收的呢?那有同学想:既然它都不存在于这个函数的栈帧中,那么也就不需要临时变量了吧,直接返回这个n不就好了eax
最后通过【mov】指令将寄存器中存放的临时值给到ret所在的这块空间有些不太理解函数的调用和返回过程的同学可能就会钻牛角尖提出这样的问题:做这么一个临时变量做返回不是很麻烦吗,为什么不先把这个值返回给外界,然后再销毁函数栈帧呢?
call
指令去调了这个函数,它的栈帧就被建立起来。此时就在我们就正处在这个函数的栈帧内部了,每个栈帧都是通过[ebp]
和[esp]
来维护它所在这块空间的。虽然是记住了call
指令的下一跳指令,但是没有记住需要接收的这个【ret】,因此在被调用的函数栈帧内部是无法找到这个接收变量ret的,是很难定位到它所在的这个空间的。但是我们又想把这个值返回回去,此时只能借助一个出了栈帧不会销毁的容器去承载、暂时保存一下这个返回值,然后当我们回到call
指令的下一条指令时继续往下执行,才能顺理成章地找到这个【ret】,然后将寄存器中存放的临时值再赋值给到它做接收希望我这么说你可以真正理解了这个过程
【总结一下】:
临时变量
去充当返回值【小一点的话可能是寄存器eax,大一点可能是在上一层栈帧开好的】然后再返回给外界的值做接受通过上面的示例你应该会觉得对于【栈区】而言使用临时变量返回还是合情合理的,可以【静态区】为什么也要通过临时变量来返回呢,这不是多此一举吗?
int& Count()
{
static int n = 0;
n++;
// ...
return n;
}
如果你想要进一步了解其返回的过程和直接【传值返回】有何区别,此时可以通过汇编来看看
eax
中,而是通过汇编里的一个属性操作符offset
进行了n个位置的偏移(汇编这一块我研究的不是很深,感兴趣的老铁可以去看看 链接)但其实也不局限于上面的这两种,只要是出了作用域不会销毁都可以使用【传引用返回】
#define N 10
typedef struct Array
{
int a[N];
int size;
}AY;
int PostAt(AY& ay, int i)
{
assert(i < N);
return ay.a[i];
}
PostAt()
函数,其所返回的ay.a[i]
出了这个函数的作用域之后会不会销毁?很明显它并不是一个静态变量或者是全局变量,而是在外部就已经开好的一个结构体变量,其实也算是一个局部变量,只是它不存在于PostAt()这个函数的栈帧中,而是在main函数的栈帧中AY ay;
// 修改返回值
for (int i = 0; i < N; i++)
{
PostAt(ay, i) = i * 10;
}
int& PostAt(AY& ay, int i)
PostAt()
函数,是C++11在STL中出现的新函数叫做【at()】,功能就是我上面所实现的这些,随着后面C++STL的学习会说到这一块,感兴趣的可以提前了解一下在上面,我介绍到了一种对函数返回进行优化的方法 ——> 传引用返回,于是有的同学就觉得它很高大上,因此所以函数都使用了传引用返回,你认为可以吗?
int& Add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int& ret = Add(1, 2);
Add(3, 4);
cout << "Add(1, 2) is :" << ret << endl;
return 0;
}
返回局部变量或临时变量的地址
,上面我有说到过对于传引用返回而言并不需要临时变量去进行拷贝,返回的是这个变量的别名c
只是存在于Add()函数栈帧中的一个临时变量而已,上面我们说到过对于出了作用域就会销毁的变量是不可以进行返回的,因此会报出下面这个Warning接下去就来好好谈一谈究竟问题出在哪里
&
的话,那这就是我们平常写的一个函数,然后外界去做一个接受。但若是加上引用之后就不对了,因为这是一个临时变量,出了当前作用域后会随着函数栈帧的销毁而销毁,此时就已经出现问题了
如果此刻直接去访问【ret】的话它的值会是多少呢?答:可能是3,可能是7,也有可能是一个随机值
Add(3,4);
屏蔽掉。那么此时的结果就有可能是3,有可能是随机值< 原理解说 >
可能就像上面这么说不是很好理解,我们通过【薛定谔的猫】这个梗来进行讲解
通过上面这个案例我想说明什么呢?
接下去我们再来详细说说ret使用引用接收这一块较难理解的地方
int& ret = Add(1, 2);
int ret = Add(1, 2);
好了,看到这里,相信你对引用做返回值的使用场景应该有了很深刻的理解,来做个总结
最后的话再带读者来回顾一下【传值返回】和【传引用返回】
在上一模块,我介绍了有关引用的两种使用场景,相信你在学习了之后也是一头雾水,学它有什么用呢?和普通的传值有何区别?本模块就来对【传值】和【传引用】这两种方式来做一个对比
#include
struct A {
int a[10000];
};
void TestFunc1(A a){}
void TestFunc2(A& a){}
void TestRefAndValue()
{
A a;
// 以值作为函数参数
size_t begin1 = clock();
for (size_t i = 0; i < 10000; ++i)
{
TestFunc1(a);
}
size_t end1 = clock();
// 以引用作为函数参数
size_t begin2 = clock();
for (size_t i = 0; i < 10000; ++i)
{
TestFunc2(a);
}
size_t end2 = clock();
// 分别计算两个函数运行结束后的时间
cout << "TestFunc1(A)-time:" << end1 - begin1 << endl << endl;
cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl;
}
19ms
,引用做参则是0ms
,虽然二者存在差距,但是差距并不大。传引用作参替代了我们在C语言中学习的二级指针,无需考虑传入一级指针的地址,然后再函数内部在做解引用不过呢,这个时间其实还看不出引用的强大之处,我们通过另一个场景,来看看值返回与引用返回二者的差距是否会大一些
// 值返回
A TestFunc1()
{
return a;
}
// 引用返回
A& TestFunc2()
{
return a;
}
看到这里之后你对C++中的【引用】应该是有一个很清晰的概念了,也知道了它的强大之处。接下去我来普及一个东西叫做【常引用】,也是引用里面很重要的一块知识点
权限放大 —— 用普通的变量替代只读变量
首先来看下下面这段代码,你认为什么地方有问题?
const
了,首先对于【const】关键字修饰的变量具有常性,是不可以被随意修改的,但此时变量d引用了c,那么c和d就从属于同一块地址了,不过变量d不具有常性,因此它是可以被修改的。那么这个时候就会产生歧义了,也就出现了问题int a = 1;
int& b = a;
const int c = 2;
int& d = c;
权限保持 —— 用只读的变量替代只读变量
const int c = 2;
const int& d = c;
权限缩小 —— 用只读的变量替代普通变量
int c = 2;
const int& d = c;
看完了上面这段三点你应该清楚了常引用是怎样一个概念,但是有很多同学在学习了常引用之后却将其他知识混淆了,所以我专门拎出几块容易搞混的给读者说明
1. 权限方法只适用于引用和指针类型
const int m = 1;
int n = m; //普通变量不受约束
2. 临时变量具有常属性【⭐】
int Count()
{
int n = 0;
n++;
// ...
return n;
}
int ret = Count();
int& ret = Count();
void print(string& str)
{
cout << str << endl;
}
int main(void)
{
print("hello world!");
return 0;
}
Windows环境下运行
const
呀,怎么会和常扯上关系呢?hello world!
而言,其实就是准备传入函数的一个实参,此时编译器根据字符串hello world
构造一个string类型的临时对象,这个临时变量具有const属性,当这个临时变量传递给非const的string&引用类型时,无法隐式完成const到非const的类型转换,造成了一个权限的放大,便出现上面的编译错误❌const
做修饰即可,这样便可以做到【权限保持】,顺利通过编译✔void print(const string& str)
通过以上代码,可以看出在设计函数时,形参尽可能地使用const,这样可以使代码更为健壮,将错误暴露于编译阶段
3. 类型转换都会产生临时变量
double
类型的变量引用了一个整型的变量,可以看到也出现了我们上面所碰到的一些编译问题int i = 10;
double& rd = i;
int
类型的变量给到一个double
类型的变量做引用,那类型的都不一样肯定是会出问题呀!”const
,却不会出现问题了,你怎么解释呢(double)i
你我们在C语言都有学过,这是一种的显式的强制类型转换,将一个int类型的变量强制转换为了double类型,但其实在编译器看来,却不是这样的i
并不是被转换成了一个【double】类型,而是产生了一个【double】类型的临时变量,然后把i
的值按照【double】的类型放到了这个临时变量中,在C语言数据存储章节我们有提到过对于浮点数放到内存中是要分为整数部分和小数部分的,按照对应的权值转换为二进制的形式存放到内存中int i = 10;
cout << (double)i << endl;
int i = 10;
double dd = i;
i
,而是i
在进行类型转换的时候产生一个【double】类型的临时变量,rd是对它进行了一个引用const
做一个【权限保持】就不会出问题了const double& rd = i;
有关【const常】和引用之间的语法点其实还有很多,但涉及到一些读者的水平,将上面这些都理解了也算懂了七八十,后面有机会再做补充
好,最后我们对指针和引用这一块来做一个小结,相信你一定觉得它们之间有着千丝万缕般的关系
int a = 10;
int& ra = a;
cout<<"&a = "<<&a<<endl;
cout<<"&ra = "<<&ra<<endl;
int a = 10;
int* pa = &a;
不过从【汇编层面】来看,其实二者是一样的,引用也是用指针去实现的,也会开空间
int main()
{
int a = 10;
int& ra = a;
ra = 20;
int* pa = &a;
*pa = 20;
return 0;
}
lea
是【load effective address】加载有效地址,将变量a中存放的内容放到寄存器eax
中lea eax,[a]
eax
中的值,也就是变量i的地址放入变量【ra】的地址所指示的内存单元中,虽然引用使用的都是同一块空间,但是在底层还是开出了一块空间来存放,这个我们可以不用关心mov dword ptr [ra],eax
eax
mov eax,dword ptr [ra]
14h(十六进制)
也就是20放到寄存器eax
的值(指向变量i的地址)所指示的内存单元中mov dword ptr [eax],14h
其实难以理解的读者可以通过这么一个生活中的案例去理解
所以对于引用来说在我们看来是不会开新空间的,但实际上底层却做了相反的事,和我们想的是千差万别
下面对本文所讲解的内容进行一个总结,希望读者可以分清楚引用和指针之间的区别
int a = 10;
int& b = NULL; //×
int* pa = NULL; //✔
int a = 1;
int& b = a;
int& c = b;
int&&
这种形式再去引用,因为这个涉及到C++中的左值、右值引用,后续专门出文章讲解int&& c = b;
最后,来总结一下本文所学习的内容
以上就是本文要介绍的所有内容,如果觉得有帮助可以给个三连哦