C++ 八股复习笔记
主线记忆:“指针赋值拷的是地址,值拷贝造的是新对象;move 只贴标签,搬家靠构造。”
一、速览卡片
| # | 考点 | 一句话说明 |
|---|---|---|
| ① | 虚函数表与 vptr | 虚表编译期生成于 .rodata,vptr 在对象首部、构造时写入,运行时经 vptr 决议虚函数 |
| ② | 左值与右值 | move 只贴右值标签,真正搬资源的是移动构造;有名字的右值引用是左值 |
| ③ | new/delete 与 malloc/free | new 自动调构造析构、自动算大小、失败抛异常;malloc 只搬内存、返回 void*、失败返 NULL |
| ④ | 智能指针 | shared_ptr 引用计数原子操作(安全),但被管理对象不受保护;weak_ptr 只加弱计数,lock() 升级后再用 |
| ⑤ | struct 与 class | 只有默认访问权限和默认继承方式不同,能力完全等价 |
| ⑥ | delete this | 对象自杀合法,仅限堆对象,销毁后只能 return |
| ⑦ | 内存对齐 | 数据按类型大小对齐才能被 CPU 一次取全;64 字节对齐防伪共享 |
| ⑧ | 进程线程与同步 | 进程=资源分配单位,线程=调度单位;线程安全单例 = C++11 magic statics |
| ⑨ | 编译四阶段 | 预处理 → 编译 → 汇编 → 链接 |
| ⑩ | 静态/动态链接 | 静态把库代码拷进 exe,动态运行时才加载 dll/so |
| ⑪ | static 四用法 | 函数内局部变量 / 文件级 / 类成员变量 / 类成员函数,四种位置四种语义 |
| ⑫ | inline 与深拷贝 | inline 的本义是就地展开,主业是让函数定义能安全写进头文件;有指针成员的类要手写五件套 |
二、逐考点详解
① 虚函数表与 vptr
动态多态的三要素:继承 + 虚函数重写 + 通过基类指针或引用调用。三者缺一,多态不成立。
- 虚表(vtbl):编译期由编译器为每个含虚函数的类生成,存在只读数据段(.rodata),全类只有一份,与创建几个对象无关。
- vptr:存放在对象内存布局的最前面(64 位下占前 8 字节,在成员变量之前),每次构造对象时由构造函数里编译器插入的代码写入——不是链接期初始化,链接器根本接触不到运行时创建的对象。多继承时对象里有多个基类子对象,每个都可能有各自的 vptr,各指向各的虚表。
- 虚函数代码:和普通成员函数一样在代码段(.text),对象里只存指向虚表的指针。
构造/析构期间的 vptr 切换(面试加分项):构造 Derived 时先执行 Base(),此刻 vptr 指向 Base 的虚表;Base 构造完、进入 Derived() 时改写为 Derived 的虚表。析构正好镜像:Derived 析构函数体执行完后切回 Base 虚表,再执行 ~Base。
推论:构造/析构函数中调用虚函数不发生多态——此刻 vptr 还指着本类的表,调用的是本类版本。
指针赋值拷的是地址,访问的还是原对象;值拷贝造的是新对象,新对象的 vptr 由它自己的构造函数写入,与数据来源无关;new 表达式会先构造一个堆对象再返回其地址。
Base* p = &d; —— 无新对象,p 指向原对象(Derived 虚表)Base b = d; —— 栈上新对象,切片,vptr 指向 Base 虚表Base* p = new Derived(); —— 堆上新对象,经典多态写法,记得 delete
A:普通成员函数若不访问成员,编译期地址已定,this 只是传进去但从未解引用——可以正常打印;一旦访问成员就要解引用 this(地址 = this + 偏移),立刻段错误。虚函数必崩:调用第一步就要读对象首部的 vptr,nullptr 连对象都没有,第一步就倒下。
记忆:普通函数是"编译器指路",虚函数是"通过 vptr 自己找路"——没有对象就没有路标。
Base* p = new Derived(); delete p; —— 若 Base 析构不是虚函数,只会执行 ~Base(),Derived 部分(构造函数里申请的资源)永久泄漏,且严格来说属于未定义行为。口诀:有虚函数的类,析构必 virtual。delete 的析构顺序是从子到父,与构造镜像。
② 左值、右值与移动语义
定义:左值 = 有身份(identity)的表达式——有名字、可取地址、可持续存在;右值 = 临时的、将亡的表达式——字面量、临时对象、std::move(a)、返回值。
A:什么都没搬。
std::move(x) 等价于 static_cast<T&&>(x),只是给表达式贴上"我是右值"的标签,零开销,x 的内存此刻毫无变化。真正搬家的是之后被选中的移动构造函数/移动赋值。move 之后 x 处于"有效但未指定"状态:不能假设它的值,但可以重新赋值接着用。
void f(std::string&& s) 里,s 是左值还是右值?想把 s 的资源转出去该怎么写?A:类型是右值引用 ≠ 它是右值。有名字的右值引用是左值——右值引用只是"我准备好了被搬"的入场券,进门之后它是有名字的活人,想用就用。所以
g(s) 走拷贝;想转资源必须 g(std::move(s)),每层传递都要自己重新贴标签。注意:
g(&s) 是错误答案——传地址只是告诉对方"对象在哪",属于借用,不转移所有权;而且类型对不上(string* 不能传给 string 按值参数)。
std::string d = std::string("hi"); 调用了什么构造?A:C++17 起这是保证的拷贝消除:临时对象直接构造在 d 的内存里,拷贝和移动都不调用,只调一次普通构造函数。C++17 之前理论上会调移动构造。类比:商家不再把货寄到中转仓再转寄,而是直接寄给买家,仓库环节整个砍掉。
输出题(构造列表 + 左值陷阱):
1 | class A { |
B(B&& o) : A(o) 里 A 部分走的是拷贝构造(b)而不是移动构造(c)?A:初始化列表本质是一串函数调用,
A(o) 就是用表达式 o 去调用 A 的构造函数做重载决议。o 有名字、是左值表达式 → A(A&&) 绑不上左值直接出局 → 只剩 A(const A&) 能接(B 隐式转 A),于是拷贝构造胜出。想走移动必须显式写 A(std::move(o)),输出才是 c3。语言为什么这样设计:若右值引用参数自动是右值,移动构造里连用一下参数都做不到。安全且显式。
对 int、指针这类没有资源可抢的类型,move 退化成一个拷贝——标签贴了,但移动构造本质上就是拷贝内容。是否展开、是否消除,均由编译器按优化等级自行判断。
③ new/delete、malloc/free 与野指针
核心对比:
| new / delete | malloc / free | |
|---|---|---|
| 身份 | C++ 运算符(可重载) | C 标准库函数 |
| 返回值 | 带类型的指针 | void*,需手动强转 |
| 构造/析构 | 自动调用构造函数,delete 自动调析构 | 只搬内存,不碰构造析构 |
| 大小 | 编译器按类型自动算 | 手动传 sizeof(T) |
| 失败 | 抛 std::bad_alloc |
返回 NULL |
必须同族配对:new↔delete、new[]↔delete[]、malloc↔free。混用是未定义行为。
free 释放 new 出来的对象会怎样?反过来 malloc + delete 呢?A:new + free:对象占的堆内存被还了,但析构函数从未执行——构造函数里申请的子资源(内部 new 的内存、句柄)永久泄漏。malloc + delete 更糟:delete 会对一个从未构造过的对象调用析构,对象成员全是随机垃圾,拿着随机地址去 free——崩溃/堆损坏,未定义行为。一句话:new+free 是"忘了打扫",malloc+delete 是"对着毛坯房拆装修"。
malloc 三部曲:
- 查空闲账本:free 时小块不还 OS,登记进空闲链表(分箱 bins);申请时按大小查账本,有则拆分返回,顺带合并相邻碎片——全程无系统调用。
- 账本没有 → 向 OS 进货:小块走
brk把堆顶推高,大块(默认 128KB 以上)走mmap在映射区开一块,切出所需返回。 - OS 也不给 → 才返回 NULL。
p = realloc(p, newSize) 是危险写法?A:两个坑。坑一:realloc 失败返回 NULL,直接赋值会让 p 变空,旧块地址丢失——旧数据既用不了也释放不了,泄漏。坑二:成功"搬家扩"时旧块已被释放,其他还存着旧地址的指针全部变野指针。正确姿势:临时指针接收 → 判 NULL → 成功才替换。realloc 的三种结果:原地扩(后面空间够)、新地址(搬家,旧块释放)、NULL(申请失败)。
进程内存布局(低 → 高):
1 | 低地址 |
栈向下、堆向上,相向而长,为彼此腾出空间。栈空间有限(默认约 8MB),但栈帧分配只是挪动栈指针,无系统调用,极快。
野指针:指针还在,指向的内存没了(危险,解引用即未定义行为)。内存泄漏:内存还在,指向它的指针没了(不崩,但越用越占内存)。
野指针四大来源:释放后未置空、函数返回局部变量地址、越界、多线程中一个线程释放另一个线程还在用。对应防护:释放即置 nullptr、不返回栈地址、智能指针、线程同步。
④ 智能指针
shared_ptr 结构:对象指针 + 控制块指针(引用计数、弱引用计数、删除器)。多线程同时拷贝/销毁各自的 shared_ptr 是安全的——计数器是原子类型,加 1 减 1 不可分割。
A:第一句:多个线程各自拷贝/销毁自己那份 shared_ptr,计数不会出错,由原子操作保证。第二句:
p-> 解引用之后就是普通对象,两个线程同时 p->push_back() 照样数据竞争——shared_ptr 只保证"几个人在用"的计数正确,"几个人同时读写"是否安全一概不负责。
循环引用与 weak_ptr:A、B 互持 shared_ptr 时,双方强计数互相顶住(外部释放后各自剩 1),谁都到不了 0,双双泄漏。weak_ptr 只加弱计数、不动强计数,A 的生死不再依赖 B,死结解开。
规则:强计数归零 → 对象立即析构(与弱计数无关);弱计数归零 → 控制块才释放(weak_ptr 还要靠它查询状态)。
1 | std::weak_ptr<A> pa; |
weak_ptr 不能直接解引用,必须先 lock() 升级成 shared_ptr 再用——升级同时给对象上了”使用期间不死”的保险。
⑤ struct 与 class
C++ 中两者本质是同一个东西,只有两点语法差别:
| struct | class | |
|---|---|---|
| 默认成员访问 | public | private |
| 默认继承方式 | public | private |
习惯上 struct 表达纯数据聚合(POD),class 表达有行为、有封装的对象——这是风格约定,不是能力差异。多态、虚函数、模板,两者完全等价。
template <class T> 与 template <typename T> 完全等价,历史遗留的两个关键字。
⑥ delete this
合法,本质是 delete 一个普通指针——对象自己销毁自己,常用于引用计数归零、连接关闭等”无人认领”的时刻。
三条铁律:
- 只能用于 new/malloc 出来的堆对象;栈对象 delete this 是未定义行为。常配合私有化构造函数 + 静态工厂方法(create)强制堆分配。
- delete this 之后,严禁再访问任何成员、调用任何成员函数——this 已成悬垂指针,函数里剩下的唯一合法语句是 return。
- 调用方此后不得再使用该指针。
类比:安乐死——合法执行,但执行后不存在”再去做什么事”,只能结束。
⑦ 内存对齐与缓存行
对齐的本质:CPU 通过内存总线按块取数(64 位机器一块 8 字节),且 L1/L2/L3 缓存与内存之间以缓存行(64 字节)为最小单位搬运——读 1 字节,整条 64 字节一起进缓存。
- 数据地址是其类型大小的整数倍 → 恰好落在同一块里,一次取全;
- 错位(跨两块)→ 两次传输再拼接,慢一倍;老架构直接硬件异常。
malloc 一律返回 16 字节对齐的地址:它不知道调用方会存什么类型,按最严标准保证安全。结构体中每个成员按自身对齐要求摆放,不足填充(padding),所以 sizeof 可能大于成员之和——大成员放前面可少浪费尾部填充。
64 字节对齐与伪共享:两个线程分别写的变量若挤在同一条缓存行里,缓存一致性会让整条行互相失效——两核抢一行,性能暴跌。这就是伪共享。解法是让热点数据独占一行:
1 | struct alignas(64) Data { int a; }; // 一人一行,互不干扰 |
例 1 · 防伪共享:两个线程高频写的计数器,各自 alignas(64) 分开放置。
例 2 · 配合 SIMD:向量计算要求 16/32/64 字节对齐的加载指令。
⑧ 进程、线程与同步
进程 vs 线程:进程是资源分配的最小单位,线程是 CPU 调度的最小单位。线程共享进程地址空间(堆、全局变量、文件句柄),独享栈和寄存器上下文;线程切换不换页表、不刷 TLB,成本低得多。
同步手段全家桶:
| 手段 | 一句话 | 适用 |
|---|---|---|
| mutex | 独占,同时只允许一个线程 | 临界区、写操作 |
| 信号量 | 计数器,允许 N 个 | 连接池限流 |
| 读写锁 | 读共享、写独占 | 读多写少的面板数据 |
| 条件变量 | 不满足条件就睡,等通知唤醒 | 生产者消费者(配 mutex) |
| 原子操作/CAS | 无锁,硬件保证不可分割 | 计数器、无锁队列 |
A:
count++ 实际执行三步:从内存读到寄存器 → 寄存器加 1 → 写回内存。两个线程穿插执行会读到同一个旧值、各自写回同一个新值——加两次只加了一(丢失更新)。shared_ptr 必须用 std::atomic<int>:硬件把三步锁成一步(x86 上是 lock xadd),别人插不进来。
拿不到锁时:自旋锁原地空转反复检查(while 循环烧 CPU,无系统调用);mutex 睡过去让出 CPU,等唤醒(睡觉+唤醒开销大,等待期不占 CPU)。临界区极短 + 多核 + 等待时间可预期很短 → 自旋锁;否则默认 mutex。自旋锁的典型实现就是
while (flag.test_and_set()) {}——像陀螺一样原地打转。
lock-free 的概念澄清:它不是”代码里没有锁”,而是一条性质——任何线程停摆,系统整体仍有线程在推进。基于锁的代码里,持锁线程被挂起会拖死所有等待者;CAS 循环没有”谁锁谁”的关系,被挂起的线程只是自己慢点,别人照样成功。更强的 wait-free 保证每个线程有限步内完成(lock-free 只保证”有人能走”,wait-free 保证”人人都能走”)。is_lock_free() 问的是”原子操作在硬件上是否真无锁”,与算法层面是两个问题。代价:CAS 反复失败烧 CPU、ABA 问题、内存序复杂——工程默认用锁,只在剖析证明的热点处上无锁。
线程安全单例(标准答案):
1 | class Singleton { |
C++11 起标准保证:函数内 static 局部变量只初始化一次,多线程同时首次调用时只有一个线程执行初始化,其余自动等待,之后零开销——懒加载、线程安全、无 DCL 指令重排坑,一个写法全部解决。老式懒汉双检锁(DCL)需要处理”分配内存 → 构造 → 写指针”被重排成半成品的问题,要靠内存屏障,易写错,已无必要。饿汉式在程序启动(单线程阶段)即创建,天然无线程安全问题,但启动就付账,可能没人用。
⑨ 编译的四个阶段
| 阶段 | 干什么 | 产物 |
|---|---|---|
| 预处理 | #include 展开、#define 替换、#ifdef 裁剪 | 编译单元(臃肿的纯代码) |
| 编译 | 语法/类型检查,翻译成汇编 | .s 汇编文件 |
| 汇编 | 汇编文本 → 机器码 | .o 目标文件(外部符号地址空缺) |
| 链接 | 符号解析 + 重定位,拼成可执行文件 | .exe / a.out |
记忆:预处理摊大饼 → 编译变汇编 → 汇编变机器码 → 链接拼全家。类比流水线:备料(拆包缩写)→ 翻译操作手册 → 转成机器人指令(电话号码还空着)→ 汇总装订总手册并查号填电话。
⑩ 静态链接与动态链接
| 静态链接 | 动态链接 | |
|---|---|---|
| 库代码何时进程序 | 链接时拷进 exe | 运行时由 OS 加载 dll/so |
| exe 体积 | 大 | 小 |
| 外部依赖 | 无(自带) | 目标机器需有对应库,缺了报”缺少 xxx.dll” |
| 多程序共享 | 不能,各拷一份 | 能,内存里一份大家用 |
| 库更新 | 全部重新链接 | 换库文件即可 |
类比:静态是出书和引用文字直接抄进自己书里(单看就够,但永远用旧版);动态是写”详见《XX 手册》第 3 页”(书薄了、手册出新版自动用新版,但读者手里必须有那本手册)。
⑪ static 的四种用法
| 位置 | 效果 |
|---|---|
| 函数内局部变量 | 只初始化一次,生命周期延长到程序结束 |
| 全局变量/普通函数 | 限制在本 .cpp 内,其他文件 extern 也链接不到 |
| 类成员变量 | 全类共享一份,不属于任何对象;类内声明类外定义(C++17 可 inline static 一步到位) |
| 类成员函数 | 没有 this,不能访问非静态成员,可用类名直接调用 |
文件级 static 是”本文件的私人物品”;类级 static 是”类本身的财产”。
⑫ inline、深拷贝与移动赋值
A:本义是建议编译器就地展开、省掉压栈/跳转开销,但这只是建议——小的简单函数基本总会展开,大的、递归的、虚函数写了也不展开(是否展开由编译器按优化等级自行判断)。它今天真正的主业是让函数定义可以安全写进头文件:普通函数定义写进头文件会被多个 .cpp 包含,链接时报多重定义;inline 函数被特许"多份定义视为同一个"。类内定义的成员函数、模板函数天然带 inline 属性。
注意与
#pragma once 分工不同:pragma once / include guards 管"同一个 .cpp 里头文件别被粘贴两次"(编译期),inline 管"多个 .o 里的机器码算一份"(链接期)。
深拷贝与 Rule of Three/Five:类里有裸指针时,编译器默认生成的拷贝构造是浅拷贝——两个对象的指针指向同一块内存,析构时重复释放,崩溃。深拷贝就是自己写拷贝构造:申请新内存 + 拷内容。
A:需要手写析构 = 类直接管理资源 = 默认拷贝(浅拷贝)必然出错。只写一部分的话,没写的那些编译器会自动生成默认版本,而默认版本全是浅拷贝——只修"释放"环节,"复制"环节还是坏的。所以析构、拷贝构造、拷贝赋值必须一起写(Rule of Three),C++11 起加移动构造、移动赋值(Rule of Five)。现代捷径:用智能指针包装成员,让类不直接碰裸指针,法则自动满足。
移动赋值三步走:① 释放自己原有的资源(否则旧资源泄漏)② 接管对方的资源 ③ 把对方置空(对方析构时不重复释放)。外加自赋值检查
if (this == &other) return *this;——否则 a = std::move(a) 会先删掉自己要接管的资源。移动构造没有"旧资源可放",所以只有接管、置空两步。
三、易混淆点对比汇总
| 易混对 | 关键区别 |
|---|---|
| 野指针 vs 内存泄漏 | 指针在、内存没了(会崩) vs 内存在、指针没了(悄悄涨) |
| std::move vs 移动构造 | 只贴右值标签、零开销 vs 真正搬资源的人 |
| 左值 vs 右值引用类型 | 表达式有无名字:有名字的右值引用是左值 |
| 指针赋值 vs 值拷贝 | 拷地址(还是原对象) vs 造新对象(vptr 由自己的构造写) |
| 普通成员函数 vs 虚函数 | 编译期定址 vs 第一步必读 vptr(nullptr 必崩) |
| new/malloc 混用 | 忘了调析构(漏扫) vs 对未构造对象调析构(拆毛坯房) |
| 信号量 vs mutex | 计数 N 个(限流) vs 二元独占(互斥) |
| lock-free vs wait-free | 整体总有进展 vs 每个线程有限步完成 |
| lock-free 概念 vs is_lock_free() | 算法性质 vs 该原子操作硬件上是否真无锁 |
| 饿汉 vs 懒汉单例 | 启动即建(天然安全) vs 首次调用才建(需防并发创建) |
| 重复包含 vs 多重定义 | #pragma once 管(编译期) vs inline/定义放 .cpp 管(链接期) |
| 浅拷贝 vs 深拷贝 | 拷指针(共享内存) vs 拷内容(各有其块) |
四、流程图
图 1 · malloc 分配决策流
蓝 = 固定环节,绿 = 运行时分支,黄 = 可配置阈值
图 2 · 编译流水线
#include/#define/#ifdef
语法类型检查
.s → 机器码
符号解析 + 重定位
(静态/动态两种模式)
图 3 · 构造/析构期间 vptr 切换
vptr = Base 虚表
vptr 改写为 Derived 虚表
vptr 切回 Base 虚表
对象内存归还
推论:构造/析构中调虚函数 → 永远调到本类版本(无多态)
五、面试速记
- 指针赋值拷的是地址,值拷贝造的是新对象;新对象的 vptr 由构造它的构造函数决定,与数据从哪拷来无关。
- move 只贴标签,搬家靠构造;有名字的右值引用是左值,每层传递资源要自己重新 move。
- 普通成员函数是编译器指路,虚函数是对象自己找路——nullptr 没有路标。
- 有虚函数的类,析构必 virtual。
- new/delete、new[]/delete[]、malloc/free 必须同族配对;混用是未定义行为。
- 野指针是”指针在、内存没了”,泄漏是”内存在、指针没了”。
- shared_ptr 保证计数正确,不保证对象读写安全。
- malloc 三部曲:查账本 → 向 OS 进货 → 才允许失败。
- 需要析构函数 = 需要手写全部拷贝家族(Rule of Three/Five),或者用智能指针让法则自动满足。
- C++11 之后,线程安全单例 = 一个函数内 static 变量。
- inline 的本义是建议展开,主业是解决头文件多重定义。
- 临界区短到比”睡觉+唤醒”还便宜才用自旋锁,否则用 mutex。
整理方式:以”提问 → 纠错 → 追问”的复习对话为底稿,按考点编号归档;所有代码均经对话验证。