C++ 引用、inline 与 nullptr 详解
C++ 引用是变量的别名,不开辟内存,用于替代指针传参以减少拷贝并提升效率。内联函数(inline)在编译时展开调用代码,避免栈帧开销,适用于高频短小函数,但可能导致代码膨胀。nullptr 是 C++11 引入的关键字,替代 NULL 宏,提供类型安全的空指针表示,避免隐式转换错误。三者结合使用可优化 C++ 程序性能与安全性。

C++ 引用是变量的别名,不开辟内存,用于替代指针传参以减少拷贝并提升效率。内联函数(inline)在编译时展开调用代码,避免栈帧开销,适用于高频短小函数,但可能导致代码膨胀。nullptr 是 C++11 引入的关键字,替代 NULL 宏,提供类型安全的空指针表示,避免隐式转换错误。三者结合使用可优化 C++ 程序性能与安全性。

引用不是新定义的变量,而是给已存在变量取一个别名。编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
类型&引用别名=引用对象
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 两个指针,使用二级指针的话比较绕(二级指针解引用就是一级指针),可以使用引用来简化一下,引用既然可以给普通变量定义别名,也可以给指针变量定义别名。
这里报错是因为调用不明确,他们两个函数参数按道理是不同的两个类型,但是二者都可以接收指针,所以不支持重载。将注释掉的代码去掉就好了。
再举一个 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 结束了,返回的值也还在。
引用在出了作用域的那个对象不是局部对象的情况下,既可以减少拷贝,又可以修改引用对象。
可以引用一个 const 对象,但是必须用 const 引用。const 引用也可以引用普通对象,因为对象的访问权限在引用过程中可以缩小,但是不能放大。
如图,如果 a 被 const 修饰了,引用就不能使用了,此时 a 不能改变,但是 b 是 a 的别名,可以改变 a,这就是一个权限放大的场景,权限是不能放大的,想要正常引用就要权限平移,b 也要被 const 修饰。
下图 c 自身可以修改,可读可写,但是 d 作为 c 的别名的时候权限缩小了,d 作为 c 的别名,只能读不能写,不是 c 的权限缩小了。
// 权限缩小放大,只存在于 const 指针&引用
const int* p1 = &a; // 不能权限放大
int* p2 = p1; // p1 指向的内容只能读,不能写
const int* p2 = p1; // 正确写法
// 可以权限缩小
int* p3 = &e;
const int* p4 = p3;
再补充一下,const 引用是可以引用常量的。
const int& a = 10;
如果不是改变形参影响实参的场景,引用传参在形参位置尽量加上 const,不这样写有很多限制,如图 y 可以传,但是 z 和常量就传不过去。
不需要注意的是类似 int& rb = a * 3; double d = 12.34; int& rd = d; 这样一些场景下 a * 3 的和结果保存在一个临时对象中,int& rd = d 也是类似,在类型转换中会产生临时对象存储中间值,也就是此时,rb 和 rd 引用的都是临时对象,而 C++ 规定临时对象具有常性,所以这里就触发权限放大,必须要用常引用才可以。
这里也会产生临时变量。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++ 中内联就是用来替换宏的。
inline 对于编译器而言只是一个建议,也就是说,你加了 inline 编译器也可以选择在调用的地方不展开,不同编译器关于 inline 什么情况展开各不相同,因为 C++ 标准没有规定这个。inline 适用于频繁调用的短小函数,对于递归函数,代码相对多一些的函数,加上 inline 也会被编译器忽略。
这里转到反汇编来理解。
从调用 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 这个变量当中。
当前的内联就没有发挥作用。
vs 编译器 debug 版本下面默认是不展开 inline 的,这样方便调试(调试是要保留栈帧建立的过程的),在 release 模式下就可以展开了,但是 release 就不能调试了,想看汇编进入调试才可以,debug 版本想展开需要设置一下以下两个地方。
设置好后再进入汇编。
这里就没有 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 函数的指令,所以内联也是有缺陷的,它会导致代码指令膨胀,最后转换成二进制的可执行程序就变大了。
inline 不建议声明和定义分离到两个文件,内联函数建议直接在.h 文件里定义。
分离会导致链接错误(预处理阶段.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++11 中引用 nullptr,nullptr 是一个特殊的关键字(就相当于语法层在编译的时候就特殊解决该问题了),nullptr 是一种特殊类型的字面量,它可以转换成任意其它类型的指针类型(值还为 0)。使用 nullptr 定义空指针可以避免类型转换的问题,因为 nullptr 只能被隐式转换为指针类型,而不能被转换为整数类型。
C++ 中 NULL 可能被定义为字面常量 0,或者 C 中被定义为无类型指针(void * )的常量。不论采取何种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,本想通过 f(NULL)调用指针版本的 f(int * )函数,但是由于 NULL 被定义成 0,调用了 f(int x),因此与程序的初衷相悖。f((void* )NULL); 调用会报错。
在 C++ 之中,void* 给 int* 是需要强制类型转换的。
以上就是 C++ 语法入门的全部内容了,C++ 知识点整理起来,比 C 多了很多。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online