今天把最近的 C++ 学习笔记整理成一篇博客,把引用特性、const 引用、指针 vs 引用、inline 函数、NULL 与访问限定符这些高频考点一次性说透,结合汇编底层、代码示例和踩坑点,帮你把这些知识点焊死在脑子里。
一、引用:变量的 "别名",但没你想的那么简单
1. 引用的三大核心特性
引用本质上是给变量起一个别名,和原变量共享同一块内存空间,核心规则只有三条:
-
定义时必须初始化:不存在 "未绑定的引用",必须在声明时就绑定到一个已存在的变量上。
-
一个变量可以有多个引用:就像一个人可以有多个外号,多个引用都指向同一个实体。
-
绑定后无法改变指向:一旦绑定到某个变量,就再也不能绑定到其他变量,这也是引用和指针最关键的区别之一。
看一段最直观的代码:
int main()
{
int x;
x = 0;
int y = 1;
int& r = x;
// 错误:r不能改变引用指向,这里试图让r绑定到y,编译直接报错
// r = y;
return 0;
}
很多新手会误以为r = y是 "让 r 指向 y",但实际上它只是把y的值赋值给了r(也就是x),r 依然绑定着 x。
这里补充一个面试高频场景:链式结构(链表 / 树)中为什么不能用引用代替指针? 因为引用无法改变指向,而链表节点的 next 指针需要动态修改指向,引用做不到这一点,这种场景必须用指针。
2. const 引用:权限收缩与临时对象的处理
const 引用的核心规则是:对对象的访问权限只能缩小,不能放大。 比如一个 const 变量,不能用非 const 引用绑定它;但非 const 变量,可以用 const 引用绑定(相当于只读权限)。
(1)权限不能放大的示例
const int y = 1;
// 错误:权限放大,r2是可读可写的引用,但y是const只读变量
// int& r2 = y;
// 正确:权限收缩,r2是只读引用,和y的权限匹配
const int& r2 = y;
// 再看这个场景:权限有没有放大?没有
int z = y;
这里的int z = y只是把y的值拷贝给了 z,并没有改变y的权限,所以是合法的。
(2)临时对象与 const 引用的特殊规则
临时对象是编译器为了存储表达式结果,临时创建的未命名对象(比如类型转换、表达式求值时都会产生)。 临时对象具有常性,只能用 const 引用绑定,否则会触发权限放大错误。
double d = 1.1;
// 错误:int& 试图绑定double转换产生的临时int对象,权限放大
// int& r5 = d;
// 正确:const int& 绑定临时对象,权限匹配
const int& r5 = d;
// 同理,表达式求值的临时对象也需要const引用
const int& r4 = x * 10;
这也是很多人写代码时踩坑的地方:类型转换场景下,非 const 引用无法绑定临时对象,必须加 const。
二、指针 vs 引用:底层实现与核心区别
很多资料说 "引用是指针的语法糖",这句话只对了一半,我们从底层和语法两个维度对比:
|-----------|------------------|--------------------------------|
| 对比维度 | 引用 | 指针 |
| 语法概念 | 变量的别名,不开辟独立空间 | 存储变量地址,需要开辟独立空间 |
| 初始化 | 必须初始化,且绑定后无法改变指向 | 建议初始化,语法上不强制,可随时改变指向 |
| 访问方式 | 直接访问对象 | 需要解引用才能访问对象 |
| sizeof 结果 | 等于引用类型的大小 | 固定为地址空间大小(32 位 4 字节,64 位 8 字节) |
| 安全性 | 极少出现空引用、野引用问题 | 容易出现空指针、野指针,需要手动管理 |
底层真相:引用在汇编层面就是指针
看这段代码的汇编实现:
int x = 1;
int& r1 = x;
int* ptr = &x;
对应的汇编指令:
; int& r1 = x;
lea eax, [x]
mov dword ptr [r1], eax
; int* ptr = &x;
lea eax, [x]
mov dword ptr [ptr], eax
可以看到,引用和指针的汇编实现几乎完全一样 ------ 都是把变量的地址存到了对应的空间里。 引用只是给指针加了一层语法糖:编译器帮你隐式解引用,同时限制了修改指向的能力。
三、inline 内联函数:不是魔法,是编译器的 "建议"
inline 函数是为了替代 C 语言的宏函数而设计的,核心作用是减少函数调用开销,提升高频调用函数的效率。
1. inline 的核心规则
-
本质是给编译器的建议:加了 inline,编译器也可以选择不展开(比如函数体过大、递归函数),不是强制展开。
-
适用场景:短小、频繁调用的函数(比如 getter/setter、简单计算函数),不适合长函数、递归函数。
-
定义与声明不能分离:如果 inline 函数的声明和定义分离在两个文件中,会导致链接错误(因为展开后没有独立的函数地址)。
2. 展开 vs 不展开的效率对比
假设一个函数有 100 行指令,被调用 10000 次:
-
不使用 inline:需要 10000 次
call指令 + 100 次函数指令,额外开销来自函数调用的压栈、跳转、返回。 -
使用 inline:直接把 100 行指令展开到每个调用处,总指令数是
10000*100,没有调用开销,但会增加可执行文件体积。
3. 汇编视角看 inline 展开
inline int Add(int x, int y)
{
int ret = x + y;
ret += 1;
ret += 1;
ret += 1;
return ret;
}
int main()
{
int ret = Add(1, 2);
return 0;
}
-
Debug 模式(默认不展开) :会生成函数调用指令
call Add,保留函数地址,方便调试。 -
Release 模式(可能展开) :编译器会直接把 Add 的函数体指令展开到 main 函数中,没有
call指令,直接执行计算逻辑。
注意:VS 的 Debug 模式默认不展开 inline 函数,想要调试 inline 函数,需要手动开启编译器选项。
四、NULL 与 C++ 的空指针
在传统 C 头文件<stddef.h>中,NULL 的定义是这样的:
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void*)0)
#endif
这就导致了一个经典的函数重载坑:
void f(int x)
{
cout << "f(int x)" << endl;
}
void f(int* ptr)
{
cout << "f(int* ptr)" << endl;
}
int main()
{
f(0); // 调用f(int),因为0是int类型
f(NULL); // C++中NULL被定义为0,同样调用f(int),而不是预期的f(int*)
return 0;
}
这也是 C++11 引入nullptr的原因:nullptr是专门的空指针类型,不会和整数类型混淆,解决了 NULL 的二义性问题。
五、类与访问限定符:封装的基础
C++ 的三大特性之一是封装,而访问限定符就是封装的核心工具:
-
public:公有的成员,在类外可以直接访问。
-
private:私有的成员,只能在类内访问,类外无法直接访问。
-
protected:受保护的成员,类外无法直接访问,但子类可以访问(后续继承章节会详细讲)。
1. class vs struct 的默认访问权限
-
class:默认访问权限是private。 -
struct:默认访问权限是public(为了兼容 C 语言)。
2. 类的默认规则与编码习惯
-
定义在类内部的成员函数,默认是
inline函数(编译器可能展开)。 -
类的成员变量,通常会被限制为
private/protected,通过公有的成员函数(getter/setter)访问,这是封装的基本规范。 -
命名习惯:成员变量通常加前缀(比如
m_),和局部变量区分开,避免命名冲突。
最后:这些知识点的面试高频问法
-
引用和指针的区别?底层实现是怎样的?
-
const 引用为什么能绑定临时对象?临时对象的常性是什么?
-
inline 函数的作用是什么?什么时候会被编译器忽略?
-
为什么链式结构不能用引用代替指针?
-
NULL 和 nullptr 的区别是什么?C++ 中 NULL 的定义有什么坑?
这些都是 C++ 入门阶段绕不开的考点,也是后续学习 STL、模板、继承多态的基础。只有把底层逻辑搞懂,才能避免写代码时的各种 "玄学 bug"。