
在这篇文章开始之前,我想给大家推荐一个非常牛的人工智能学习网站。在近几年,大家也知道人工智能和 AI 技术的发展也是非常迅速,越来越多的高校也纷纷一引入了相关课程,将其纳入教学体系。所以面对这样的趋势,提前掌握一些 AI 知识,必定能为未来的职业发展增加竞争力。 人工智能学习网站
引用不是新定义的一个变量,而是给已存在变量取一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。比如:水浒传中李逵,宋江叫“铁牛”,江湖人称“黑旋风”;林冲,外号豹子头; 类型&引用别名=引用对象 C++中为了避免引入太多运算符,会复用C语言的一些符号,比如前面的<<和>>,这里引用也和去地址使用同一个符号&,要注意区分,但是个人觉得用更多符号反而更好,不容易混淆

创建i这个变量的时候会开辟一块空间叫i,int& j = i,就是给这块空间又去了一个名字叫j,还可以再取一个名字叫k


引用可以给一个变量取多个别名,也可以给别名取别名


k已经是i,j的别名了,就不能是实体m的别名了,图中k=m就是赋值了
C++中引用就是来解决指针不足的问题,引用的作用就是在大部分场景去替代指针,但是部分场景还是离不开指针
之前完成x和y的交换,是使用指针来完成的,这也可以使用引用平替

rx和ry是x和y的别名,rx和ry的交换,就是x和y的交换。这里引用看似没有初始化,其实是有的,引用在函数调用的时候才定义,定义的时候x和y传过来了
并且指针交换和引用交换是能同时存在的,在C++之中二者构成了函数重载
在数据结构这里也可以直接使用引用,图中形参就是实参的别名

再比如说现在要交换p1和p2两个指针,使用二级指针的话比较绕(二级指针解引用就是一级指针),可以使用引用来简化一下,引用既然可以给普通变量定义别名,也可以给指针变量定义别名

这里报错是因为调用不明确,他们两个函数参数按道理是不同的两个类型,但是二者都可以接收指针,所以不支持重载。将1注释掉就好了。图中注释是rp1和rp2,我这里标错了
再举一个C语言单链表的例子

不使用引用的时候,链表的尾插是这样的,二级指针确实很绕人,学了这门就,博主今天再看还是会蒙一下

这样phead就是plist的别名了,插入第一个节点的时候newnode给phead,phead改变就是plist改变
学校的一些教材上是这样写的,让人很难理解

首先要理解,如果结构体前面没有typedef,这里的x就是定义的结构体变量,p就是结构体的指针变量,且二者都是全局变量
struct A
{
}x,*p前面有typedef,就是对类型的别名
//等价于
typedef struct SListNode SLTNode;
typedef struct SListNode* PSLTNode;所以* PSLTNode,其实就是SListNode *,就是结构体的指针。就可以这样就和前面没什么区别了。我感觉他的设计本意是为了避免二级指针让人绕进去,但是加了C++的一些语法,反而写的更史了
引用也是不能完全替代指针的,像在链表,树,这些节点定义的位置,只能使用指针,节点与节点之间的物理空间并不连续,一定是一块物理空间存下一块物理空间的地址,所以至少要有一个指针。 树和链表的操作都有一个要求,需要改变指向,如图使用引用的话,第一个节点存了第二个节点的别名,但是如果第二个节点删了之后,是不能变成第三个节点的别名的。指针核心不能被引用的点就是,C++的引用无法改变指向

但是Java没有指针,只有引用。Java的引用是可以改变指向的
再说一下返回的问题,传值返回和传值传参类似,都会产生临时变量,这里并不是用ret作为一个返回值(func函数调用结束之后才会有返回值,此时ret都销毁了),返回的是ret的拷贝的临时变量。一般返回的对象比较小,这种情况下临时变量会存到寄存器里面,如果比较大,就会在两个栈帧中开一片空间来存(在函数调用之前就会开好),func销毁,中间的空间再拷贝给x,再把中间的栈帧(寄存器存的值)销毁

要看懂这个图首先要理解函数调用会在栈这个区域建立一个栈帧
func() += 1;所以也不能对func的返回值直接进行加等,func传值返回返回的是ret拷贝的临时对象,临时对象又有一个特点,临时对象是不能被修改的
再说传引用返回,这里返回的是ret的别名


但是这样的传引用返回的行为,本质上是非常危险的,func函数结束之后,func栈帧就销毁了,但是tmp依旧是ret这块空间的别名,tmp依旧在访问ret,就相当于野指针的访问。此时返回的就不一定是0了,也有可能是随机值 VS上返回的是0,但是报警告了,越界读是不一定报错的,越界写才会报错


补充一下,一块空间被销毁了之后,还是可以访问这块空间的,无论free还是栈帧结束,是把这块空间的使用权还给操作系统,但是这块空间没有清空,也就是上上张图还能输出0的原因,而且空间是可以反复使用的,这块空间操作系统还可以分配给别人用 ,类似于租房。房子退了之后,我还可以进入这块房间的原因是,又悄悄地配了一把钥匙。

再改一下,func1返回的是ret的别名(tmp),x又相当于是tmp的别名,相当于x就是ret的别名。所以这里虽然func1被销毁了,但是还能通过x别名,访问到这一块空间。
再补充一下,这里返回了ret的别名,ret是个局部变量,为什么ret销毁了,x还能是ret的引用呢?

这里转到反汇编,这里定义个i的变量初始化为0

反汇编lea就是取地址,把i的地址取出来放到eax这个寄存器之中,然后再把eax的值给p。这两句汇编代码的意思就是把i的地址给p,这里开了空间,


这里取i的地址给eax,再把eax的值给一个叫r1的变量,这r1就是一个指针,这就可以发现二者是一样的

并且可以看到解引用对指针指向的变量++,同引用(r1是i的别名,r1的改变就是i的改变),二者的汇编指令也是一样的
所以说引用是个语法层的概念,语法层上是不开空间,但是语法最终要被编译成指令,在汇编指令这一层是没有引用的,只有指针,引用的底层也是转换成指针实现的
再回头看就懂了,func1函数结束,栈帧销毁,ret也跟着销毁了,虽然销毁了,但是空间还在,取了别名,x就已经存了地址
所以C++引用这里是分上层和下层的,这两层要分开理解,语法层是表达层,底层是实现层 就比如像老婆饼里没有老婆一样,表达的意思是这个饼做出来和老婆做得一样好,都不一定见的这个饼是一个女性做的,也有可能是个扣脚大汉做的呢
虽然语法上说引用是别名,没有拷贝,但底层毕竟是指针,是开了空间的(4个或8个,开个指针)。看似没有提高效率,但是在传值传参的时候,如果传了一个大对象(几百个字节),传引用传参的高效率就体现出来了,无论传多大的对象,引用的开销从真正底层的角度来说,只需要一个指针的开销

栈帧是向下生长的,func1销毁之后,调用func2函数,func2和func1栈帧是一样大的,因为他们都定义了一个变量。func2建立栈帧的位置和大小是和之前的func1重叠的。func2没有操作直接销毁了,但是VS下的栈帧销毁并没有清空空间,所以再次访问x,x是这块空间的别名,就输出456了
如图出了作用域,func1结束,ret还在,此时被static修饰的ret就不存在func1的栈帧之中了

再举一个例子,依旧是顺序表,这里随便搭了一个架子,细节不要在意



下面使用引用做返回值,就不需要再使用SLModify


此时SLat就变成了一个既可以读,又可以写的函数,return pls->a[i];此时返回第i个位置值的别名,既可以读到这个值,也可以修改这个值,而且不会返回类似野引用的东西,pls->a[i]不是一个局部变量,而是外面的结构体指向的数组上的内容,返回的是a指向数组的第i个位置值的别名


且顺序表中数组的空间在堆上(因为堆内存可以在程序运行时动态分配,数组在栈上分配内存,其大小必须在编译时确定,无法动态修改),堆上的内存由程序员手动管理(或通过智能指针等机制自动管理),可以长期存在,直到显式释放,也就是出了作用域,SLat结束了,返回的值也还在
引用在出了作用域的那个对象不是局部对象的情况下,既可以减少拷贝,又可以修改引用对象

下图c自身可以修改,可读可写,但是d作为c的别名的时候权限缩小了,d作为c的别名,只能读不能写,不是c的权限缩小了

// 权限缩小放大,只存在于const指针&引用
const int* p1 = &a;
// 不能权限放大
// int* p2 = p1;
// p1指向的内容只能读,不能写
// p2对指向的内容还能写
const int* p2 = p1;//正确写法
// 可以权限缩小
int* p3 = &e;
const int* p4 = p3;再补充一下,const引用是可以引用常量的
const int& a = 10;如果不是改变形参影响实参的场景,引用传参在形参位置尽量加上const,不这样写有很多限制,如图y可以传,但是z和常量就传不过去


这里也会产生临时变量。double d = i;这里i不是直接赋值给d的,i会先放到临时变量之中,临时变量再赋值给d,这期间i的结构会重新改变,会按浮点数的存储规则存储在临时变量之中,这就是隐式类型转换的结果 显式类型转换的结果也是如此,int p = (int)&i;强制类型转换本质上也是产生一个临时变量。 临时变量具有另外一个特点,由于是开的临时空间,也会使用寄存器之类的来存,所以具有常性,就像被const修饰,所以说这里没加const(double& rd = i;)语法检测不能通过的原因是rd引用的不是i,rd引用的是临时变量,直接引用造成了权限的放大,临时变量就像被const修饰一样

到这里,前面传参一个类型转换的值也可以传过去了

C++中指针和引用就像是两个性格迥异的亲兄弟,指针是哥哥,引用是弟弟,在实践中他们相辅相成,功能有重叠性,但是各有自己的特点,互相不可替代
先看一下宏常见的问题

连续两个分号没有问题,但在下面的场景下就很坑了
int ret2 = ADD(1, 2) * 3;//有分号没办法乘

看似没有问题,但这个宏在另一种场景下就又不对了

所以可以看出宏函数很容易出现问题,很复杂,还不能调试
所以C++之父就搞了个内联出来
写个函数是不容易写错的,但写个宏函数很容易写错 宏函数的优点就是可以提高效率,适用于高频调用的小函数,预处理阶段宏会替换,不建立栈帧,而且宏甚至可以传类型
#define ADD(T, a, b)((a) + (b))
int ret1 = ADD(int, 1, 2);C++中内联就是用来替换宏的

这里转到反汇编来理解

从调用Add函数来看,push就是压栈,栈是向下生长的,上面是高地址,下面是低地址,栈顶插了一个2后,esp会向下走一点点

call是调用一个函数,后面跟的是函数的地址(函数被编译完是一串指令,函数的地址就是第一句指令的地址),汇编也是可以调试的,f11就进入Add函数

call就是jmp指令的地址,jmp也是一个跳转指令,jmp到1830这个地址,h是16进制的后缀

此处就是函数真实的地址了,push ebp之后,再把esp给ebp

然后让esp向下减,开栈帧

这里传值返回是传到一个临时的寄存器当中,a mov到eax之中,add就是+=,把b+=到eax中,相当把a和b加和的结果放到eax寄存器当中

最后ret就是return,退出栈帧

imul是乘的指令,把eax乘3的结果放到eax之中,最后一条指令把eax的结果mov到ret2这个变量当中
当前的内联就 没有发挥作用



设置好后再进入汇编

这里就没有call Add这一指令了,图中就是展开的逻辑,1先给eax,add+=把1和2加起来放到eax之中,再让eax乘3放到ecx这个寄存器当中,然后再将ecx这个寄存器的值放到ret2变量里,和调用函数实现的逻辑是一摸一样的
这是函数中又加入了几行代码,展开就可以看到没有call Add,call Add变成了这些指令,一行变20行

我这里自己试了一下,在VS中一个函数大概10句代码量的时候,在设置下加了inline也不会展开了

这样由编译器决定是否展开的原因就是,随着代码的增多,展开的指令就变多了,如果交给程序员就会有一个恶性膨胀的问题,如图,如果

不展开的话,有1w个调用的地方,就有1w个call指令。1w个call指令都是执行的func函数的指令,所以内联也是有缺陷的,它会导致代码指令膨胀,最后转换成二进制的可执行程序就变大了

分离会导致链接错误(预处理阶段.h文件展开,就得到了函数的声明,调用函数的时候,此时内联是无法展开的,此时函数还没有实现,此时内联是一定废掉了)。就要call函数的地址,但此时也没有地址,此时只有声明,函数的地址是要由定义来生成的,定义编译成很多句指令,第一句指令的地址才是函数的地址。之后链接时会出现报错
nullptr是C++11的一个关键字,是用来替代C语言的NULL的
NULL实际上是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码:
#ifndef NULL
#ifdef __cplusplus
//C++中的NULL被替换为0,默认为整型
#define NULL 0
#else
//C语言中的NULL也被替换为0,但是被强制类型转换为(void*)
#define NULL ((void *)0)
#endif
#endif

在C++之中,void* 给int* 是需要强制类型转换的

以上就是C++语法入门的全部内容了,主播花了两篇终于写完了,真的好肝,C++知识点整理起来,比C多了很多