在上一篇文章中,我们已经掌握了引用的基本概念与用法。今天继续深入,重点讲解 const 引用、引用与指针的关系,以及 inline 和 nullptr 这两个容易被忽视却十分重要的知识点。
引用
const 引用
引用类型用 const 修饰,我们称之为 const 引用。
这里有一个 int 类型的变量 a,用 const 修饰成了常量。
cpp
const int a = 10;
我们给 a 取别名 b,如果这样取:
cpp
int& b=a;//b是a的引用
编译器就会报错。

这种情况本质上涉及权限的放大,我们用 const 修饰 a 本质上是不允许 a 的内容被修改的,而我们的引用 b 是 int 类型的,也就是引用 b 可以修改 a 的内容,这就是权限的放大。
cpp
const int& b = a;
这种情况就是权限的平移,还有一种情况,权限的缩小,这是允许的。
cpp
int c = 20;
int& d = c;//权限的平移
const int& e = c;//权限的缩小
e 作为 c 的别名,但是 e 不能修改该空间中存放的内容,这就是权限的缩小。
除引用外,指针中也有权限放大或缩小的这一说法,与引用一样,指针的权限也只能平移或缩小。
我们再来看两个场景:
cpp
//给表达式取别名
int a = 10;
int& b = a + 10;
//类型强转
double c = 3.14;
int& d = c;
这两个情况下编译器也都报错了。

这是为什么呢?因为涉及权限的放大。
我们之前讲过,函数在返回值的时候会产生临时对象,返回来的是临时对象的值,这里也是一样的。
编译器在计算表达式的时候,会创建临时对象用于存表达式的计算结果,所以我们的引用 b 实际上是给这个临时对象取别名。
临时对象具有常性,而我们的引用 b 类型是 int ,本质上就是一种权限放大,临时对象值不能改变,这样写 b 就可以改变它的值了,是不允许的。
在类型强制转换的过程中,编译器也会创建一个临时对象来存储类型强转后的 c 的值,所以 d 也是给这个临时对象取别名,与给表达式取别名一样,涉及权限放大。
所以在这两种场景下也必须使用 const 引用。
cpp
//给表达式取别名
int a = 10;
const int& b = a + 10;
//类型强转
double c = 3.14;
const int& d = c;
引用和指针的关系
引用和指针在功能上有所重叠,但是是独立的两个东西,相辅相成,没有谁可以替代谁。
- 引用的创建不开空间,指针的创建会开空间。
- const 修饰引用只有一种方法,const 修饰指针分几种情况。
- 指针的使用很容易出现空指针和野指针情况,引用很少会出现野引用,相较之下引用比较安全。
- 引用直接使用就可以访问到指向对象,指针需要解引用才可以。
- 在sizeof访问引用或指针时,引用大小是引用对象的类型大小,指针大小和类型无关,和环境(X64 和 X86)有关。
此外,从汇编语言的角度来看,引用的底层也是通过传地址来实现的 。

在汇编层面,我们可以看到引用和指针都是先将变量地址放到寄存器 rax 中,然后将该地址复制过来的。# inline
在语法层面,我们将用 inline 修饰的函数定义为 inline(内联)函数。
cpp
inline 返回类型 函数名(参数列表)
{
//函数体
}
inline 函数在编译阶段会在调用位置处被编译器展开成代码。
cpp
#include<iostream>
using namespace std;
//内联函数
inline void Func()
{
cout<<"hello,world"<<endl;
}
int main()
{
Func();
Func();
Func();
return 0;
}
编译阶段就会被展开成:
cpp
int main()
{
cout<<"hello,world"<<endl;//Func函数体被展开成代码
cout<<"hello,world"<<endl;
cout<<"hello,world"<<endl;
return 0;
}
不是所有用 inline 修饰的函数都会在调用处被展开,我们用 inline 修饰该函数只是给编译器提建议,建议将该函数作为内联函数,采不采纳由编译器决定。
通常只有几行代码且被频繁调用的函数会被编译器展开成内联函数,递归函数特别少。
而且在 vs 的 Debug 模式下该内联函数是默认不展开的,方便调试,如果要看,需要进行以下操作:
- 右键单击项目,选择属性,在C/C++ ->常规->调试信息格式选程序数据库。
- 优化->内联函数拓展改为只适用于_inline(内联函数)。
然后我们就可以看了。

在汇编命令中,我们如果要调用函数创建函数栈帧是call指令,然后会跳转到对应指令去,执行完才从当前指令继续往下执行,而这里是lea,将该字符串存储到rdx寄存器中去,也就是说,该函数被编译器展开成内联函数了。
C++引入内联函数这一用法,是有它的好处的,我们在调用函数创建函数栈帧时,是比较费时间的,内联函数在调用函数处不创建函数栈帧而是直接把函数展开,可以提高效率。
那我们把所有函数都定义成内联函数不就好了?肯定有人会这么干,所以主动权才交在编译器手中,如果在一个项目中真的所有函数都成了内联函数,全部展开,代码量会暴增,对内存极其不友好。
vs 一个项目中存在多个.h,.cpp文件时,我们对于普通函数通常会将声明放在头文件中,定义放在源文件中,也就是声明和定义分离开来。
内联函数的声明和定义不能分离,必须写在一块。

如果分离了就会出现以下错误:


报错位置位于中间文件main.obj,也就是在编译阶段出了问题。
我们回顾一下一个程序执行的过程,需要经过编译和链接两个阶段。
编译阶段又分为了预处理、编译、汇编三个过程。
在预处理阶段,包含的头文件都会被展开,宏等会进行简单的文本替换,生成.i后缀的文件。
在编译阶段,会进行语义分析,生成汇编代码,然后生成.s为后缀的文件。
在汇编阶段,将汇编代码转换成机器能理解的二进制代码,生成.obj中间文件。
在链接阶段,将这些.obj文件都整合起来,最终生成可执行程序.exe。
链接阶段会去找每个函数对应的链接地址,而内联函数在编译阶段就被展开了,编译器无法通过函数名找到该函数地址,就会报链接错误。
所以内联函数声明和定义不能分开。而普通函数的声明和定义一定要分开,如果一个普通函数定义在头文件,多个源文件都包含了该头文件,在预处理阶段头文件就会被展开,相当于每个源文件都有一份该函数的定义,就会出现重定义问题。
如果用static修饰函数,会把函数设为内部链接属性,这时在头文件中定义就不会出现问题,展开后是多个互不相关的副本。inline是特殊外部链接属性,链接器识别多个inline符号,合并为同一个函数。了解一下就好了。
宏与 inline
- 在 C 语言中,宏是在预处理阶段就被展开,而且是纯文本替换,缺少类型检查,不安全,所以 C++ 才引入了内联函数,也是为了解决这个问题。
- 宏比较复杂且不容易写,相比之下 inline 会好很多。
nullptr
NULL 实际上是一个宏,在传统 C 头文件(stddef.h)中,我们可以看到以下代码:
c
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
可以看到在 C++ 中 NULL 被替换成了整型 0,C 语言中被替换成了 void* 无类型的空指针,在使用时都会或多或少出现问题。
比如以下程序:
cpp
void F1(int a)
{
cout << "F1 int" << endl;
}
void F1(int* a)
{
cout << "F1 int*" << endl;
}
int main()
{
F1(0);
F1(NULL);
return 0;
}

我们第二个参数传 NULL 是想调用第二个函数的,结果 NULL 替换成 0 了,调用第一个函数去了。
因此 C++ 引入了 nullptr 关键字,它是一种特殊类型的字面量,可以转换成任意的指针类型而非整型,所以用它定义空指针可以避免类型问题。
cpp
int main()
{
F1(0);
F1(nullptr);
return 0;
}
