C++ 八股复习笔记

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 写 2 次(Base 版 → Derived 版),析构再写 1 次切回 Base 版

推论:构造/析构函数中调用虚函数不发生多态——此刻 vptr 还指着本类的表,调用的是本类版本。

⚠️ 易混淆点:三种"赋值"的本质区别
指针赋值拷的是地址,访问的还是原对象;值拷贝造的是新对象,新对象的 vptr 由它自己的构造函数写入,与数据来源无关;new 表达式会先构造一个堆对象再返回其地址。

Base* p = &d; —— 无新对象,p 指向原对象(Derived 虚表)
Base b = d; —— 栈上新对象,切片,vptr 指向 Base 虚表
Base* p = new Derived(); —— 堆上新对象,经典多态写法,记得 delete
Q:nullptr 上调用成员函数会怎样?

A:普通成员函数若不访问成员,编译期地址已定,this 只是传进去但从未解引用——可以正常打印;一旦访问成员就要解引用 this(地址 = this + 偏移),立刻段错误。虚函数必崩:调用第一步就要读对象首部的 vptr,nullptr 连对象都没有,第一步就倒下。
记忆:普通函数是"编译器指路",虚函数是"通过 vptr 自己找路"——没有对象就没有路标。
⚠️ 易混淆点:基类析构函数必须是 virtual
Base* p = new Derived(); delete p; —— 若 Base 析构不是虚函数,只会执行 ~Base(),Derived 部分(构造函数里申请的资源)永久泄漏,且严格来说属于未定义行为。口诀:有虚函数的类,析构必 virtual。delete 的析构顺序是从子到父,与构造镜像。

② 左值、右值与移动语义

定义:左值 = 有身份(identity)的表达式——有名字、可取地址、可持续存在;右值 = 临时的、将亡的表达式——字面量、临时对象、std::move(a)、返回值。

Q:std::move(x) 到底做了什么?之后 x 还能用吗?

A:什么都没搬。std::move(x) 等价于 static_cast<T&&>(x),只是给表达式贴上"我是右值"的标签,零开销,x 的内存此刻毫无变化。真正搬家的是之后被选中的移动构造函数/移动赋值。move 之后 x 处于"有效但未指定"状态:不能假设它的值,但可以重新赋值接着用。
Q:参数 void f(std::string&& s) 里,s 是左值还是右值?想把 s 的资源转出去该怎么写?

A:类型是右值引用 ≠ 它是右值。有名字的右值引用是左值——右值引用只是"我准备好了被搬"的入场券,进门之后它是有名字的活人,想用就用。所以 g(s) 走拷贝;想转资源必须 g(std::move(s)),每层传递都要自己重新贴标签。
注意:g(&s) 是错误答案——传地址只是告诉对方"对象在哪",属于借用,不转移所有权;而且类型对不上(string* 不能传给 string 按值参数)。
Q:std::string d = std::string("hi"); 调用了什么构造?

A:C++17 起这是保证的拷贝消除:临时对象直接构造在 d 的内存里,拷贝和移动都不调用,只调一次普通构造函数。C++17 之前理论上会调移动构造。类比:商家不再把货寄到中转仓再转寄,而是直接寄给买家,仓库环节整个砍掉。

输出题(构造列表 + 左值陷阱):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A {
public:
A() { std::cout << "a"; }
A(const A&) { std::cout << "b"; }
A(A&&) { std::cout << "c"; }
};

class B : public A {
public:
B() : A() { std::cout << "1"; }
B(const B& o): A(o) { std::cout << "2"; }
B(B&& o) : A(o) { std::cout << "3"; } // 注意:A(o) 传的是左值
};

B b1; // 输出 a1(先父后子)
B b2 = b1; // 输出 b2(拷贝构造)
B b3(std::move(b1)); // 输出 b3 ← 陷阱!
Q:为什么 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。混用是未定义行为。

Q:free 释放 new 出来的对象会怎样?反过来 malloc + delete 呢?

A:new + free:对象占的堆内存被还了,但析构函数从未执行——构造函数里申请的子资源(内部 new 的内存、句柄)永久泄漏。malloc + delete 更糟:delete 会对一个从未构造过的对象调用析构,对象成员全是随机垃圾,拿着随机地址去 free——崩溃/堆损坏,未定义行为。一句话:new+free 是"忘了打扫",malloc+delete 是"对着毛坯房拆装修"。

malloc 三部曲:

  1. 查空闲账本:free 时小块不还 OS,登记进空闲链表(分箱 bins);申请时按大小查账本,有则拆分返回,顺带合并相邻碎片——全程无系统调用。
  2. 账本没有 → 向 OS 进货:小块走 brk 把堆顶推高,大块(默认 128KB 以上)走 mmap 在映射区开一块,切出所需返回。
  3. OS 也不给 → 才返回 NULL。
Q:为什么 p = realloc(p, newSize) 是危险写法?

A:两个坑。坑一:realloc 失败返回 NULL,直接赋值会让 p 变空,旧块地址丢失——旧数据既用不了也释放不了,泄漏。坑二:成功"搬家扩"时旧块已被释放,其他还存着旧地址的指针全部变野指针。正确姿势:临时指针接收 → 判 NULL → 成功才替换。realloc 的三种结果:原地扩(后面空间够)、新地址(搬家,旧块释放)、NULL(申请失败)。

进程内存布局(低 → 高):

1
2
3
4
5
6
7
8
9
10
11
低地址
┌ .text 代码段(机器指令,虚函数代码在这)
├ .rodata 只读数据(常量字符串、虚函数表)
├ .data 已初始化全局/静态变量
├ .bss 未初始化全局/静态变量
├ 堆 ↑ 向高地址增长(new/malloc,小块走 brk)
│ (大片空隙)
├ mmap 区 动态库 .so、malloc 大块(≥128KB)
├ 栈 ↓ 向低地址增长(局部变量、函数参数)
└ 内核空间(用户程序不可直接访问)
高地址

栈向下、堆向上,相向而长,为彼此腾出空间。栈空间有限(默认约 8MB),但栈帧分配只是挪动栈指针,无系统调用,极快。

⚠️ 易混淆点:野指针 vs 内存泄漏
野指针:指针还在,指向的内存没了(危险,解引用即未定义行为)。内存泄漏:内存还在,指向它的指针没了(不崩,但越用越占内存)。
野指针四大来源:释放后未置空、函数返回局部变量地址、越界、多线程中一个线程释放另一个线程还在用。对应防护:释放即置 nullptr、不返回栈地址、智能指针、线程同步。

④ 智能指针

shared_ptr 结构:对象指针 + 控制块指针(引用计数、弱引用计数、删除器)。多线程同时拷贝/销毁各自的 shared_ptr 是安全的——计数器是原子类型,加 1 减 1 不可分割。

Q:"引用计数线程安全,但对象不是"两句话分别是什么意思?

A:第一句:多个线程各自拷贝/销毁自己那份 shared_ptr,计数不会出错,由原子操作保证。第二句:p-> 解引用之后就是普通对象,两个线程同时 p->push_back() 照样数据竞争——shared_ptr 只保证"几个人在用"的计数正确,"几个人同时读写"是否安全一概不负责。

循环引用与 weak_ptr:A、B 互持 shared_ptr 时,双方强计数互相顶住(外部释放后各自剩 1),谁都到不了 0,双双泄漏。weak_ptr 只加弱计数、不动强计数,A 的生死不再依赖 B,死结解开。

规则:强计数归零 → 对象立即析构(与弱计数无关);弱计数归零 → 控制块才释放(weak_ptr 还要靠它查询状态)。

1
2
3
4
5
6
7
std::weak_ptr<A> pa;
std::shared_ptr<A> tmp = pa.lock(); // 升级:强计数 +1,使用期间对象不死
if (tmp) {
tmp->foo(); // A 还活着
} else {
// A 已死,lock 返回空 shared_ptr,走兜底逻辑
}

weak_ptr 不能直接解引用,必须先 lock() 升级成 shared_ptr 再用——升级同时给对象上了”使用期间不死”的保险。


⑤ struct 与 class

C++ 中两者本质是同一个东西,只有两点语法差别:

struct class
默认成员访问 public private
默认继承方式 public private

习惯上 struct 表达纯数据聚合(POD),class 表达有行为、有封装的对象——这是风格约定,不是能力差异。多态、虚函数、模板,两者完全等价。

补充:template 里的 class 与 typename
template <class T> 与 template <typename T> 完全等价,历史遗留的两个关键字。

⑥ delete this

合法,本质是 delete 一个普通指针——对象自己销毁自己,常用于引用计数归零、连接关闭等”无人认领”的时刻。

三条铁律:

  1. 只能用于 new/malloc 出来的堆对象;栈对象 delete this 是未定义行为。常配合私有化构造函数 + 静态工厂方法(create)强制堆分配。
  2. delete this 之后,严禁再访问任何成员、调用任何成员函数——this 已成悬垂指针,函数里剩下的唯一合法语句是 return。
  3. 调用方此后不得再使用该指针。

类比:安乐死——合法执行,但执行后不存在”再去做什么事”,只能结束。


⑦ 内存对齐与缓存行

对齐的本质:CPU 通过内存总线按块取数(64 位机器一块 8 字节),且 L1/L2/L3 缓存与内存之间以缓存行(64 字节)为最小单位搬运——读 1 字节,整条 64 字节一起进缓存。

  • 数据地址是其类型大小的整数倍 → 恰好落在同一块里,一次取全;
  • 错位(跨两块)→ 两次传输再拼接,慢一倍;老架构直接硬件异常。

malloc 一律返回 16 字节对齐的地址:它不知道调用方会存什么类型,按最严标准保证安全。结构体中每个成员按自身对齐要求摆放,不足填充(padding),所以 sizeof 可能大于成员之和——大成员放前面可少浪费尾部填充。

成员地址 = this 的值 + 成员偏移

64 字节对齐与伪共享:两个线程分别写的变量若挤在同一条缓存行里,缓存一致性会让整条行互相失效——两核抢一行,性能暴跌。这就是伪共享。解法是让热点数据独占一行:

1
struct alignas(64) Data { int a; };   // 一人一行,互不干扰

例 1 · 防伪共享:两个线程高频写的计数器,各自 alignas(64) 分开放置。
例 2 · 配合 SIMD:向量计算要求 16/32/64 字节对齐的加载指令。


⑧ 进程、线程与同步

进程 vs 线程:进程是资源分配的最小单位,线程是 CPU 调度的最小单位。线程共享进程地址空间(堆、全局变量、文件句柄),独享栈和寄存器上下文;线程切换不换页表、不刷 TLB,成本低得多。

同步手段全家桶:

手段 一句话 适用
mutex 独占,同时只允许一个线程 临界区、写操作
信号量 计数器,允许 N 个 连接池限流
读写锁 读共享、写独占 读多写少的面板数据
条件变量 不满足条件就睡,等通知唤醒 生产者消费者(配 mutex)
原子操作/CAS 无锁,硬件保证不可分割 计数器、无锁队列
Q:普通 int 做引用计数为什么线程不安全?

A:count++ 实际执行三步:从内存读到寄存器 → 寄存器加 1 → 写回内存。两个线程穿插执行会读到同一个旧值、各自写回同一个新值——加两次只加了一(丢失更新)。shared_ptr 必须用 std::atomic<int>:硬件把三步锁成一步(x86 上是 lock xadd),别人插不进来。
自旋锁 vs mutex
拿不到锁时:自旋锁原地空转反复检查(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
2
3
4
5
6
7
8
9
10
11
class Singleton {
public:
static Singleton& get() {
static Singleton instance; // Magic Statics:标准保证初始化线程安全
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
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、深拷贝与移动赋值

Q: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 分配决策流

malloc(size) 入口
↓
① 查空闲链表/分箱 bins
↓
有合适块?→ 拆分返回(顺带合并相邻碎片)
↓ 没有
② 按阈值分流(默认 128KB,可配置)
↓
小块 → brk 推高堆顶   |   大块 → mmap 映射区开块
↓
切出所需大小返回
↓ OS 拒绝
返回 NULL

蓝 = 固定环节,绿 = 运行时分支,黄 = 可配置阈值

图 2 · 编译流水线

.cpp 源码
→
预处理
#include/#define/#ifdef
→
编译
语法类型检查
→
汇编
.s → 机器码
→
.o 目标文件
→
链接
符号解析 + 重定位
(静态/动态两种模式)
→
可执行文件

图 3 · 构造/析构期间 vptr 切换

进入 Base()
vptr = Base 虚表
→
进入 Derived()
vptr 改写为 Derived 虚表
⇄
析构:~Derived 体执行完
vptr 切回 Base 虚表
→
~Base()
对象内存归还

推论:构造/析构中调虚函数 → 永远调到本类版本(无多态)


五、面试速记

  1. 指针赋值拷的是地址,值拷贝造的是新对象;新对象的 vptr 由构造它的构造函数决定,与数据从哪拷来无关。
  2. move 只贴标签,搬家靠构造;有名字的右值引用是左值,每层传递资源要自己重新 move。
  3. 普通成员函数是编译器指路,虚函数是对象自己找路——nullptr 没有路标。
  4. 有虚函数的类,析构必 virtual。
  5. new/delete、new[]/delete[]、malloc/free 必须同族配对;混用是未定义行为。
  6. 野指针是”指针在、内存没了”,泄漏是”内存在、指针没了”。
  7. shared_ptr 保证计数正确,不保证对象读写安全。
  8. malloc 三部曲:查账本 → 向 OS 进货 → 才允许失败。
  9. 需要析构函数 = 需要手写全部拷贝家族(Rule of Three/Five),或者用智能指针让法则自动满足。
  10. C++11 之后,线程安全单例 = 一个函数内 static 变量。
  11. inline 的本义是建议展开,主业是解决头文件多重定义。
  12. 临界区短到比”睡觉+唤醒”还便宜才用自旋锁,否则用 mutex。

整理方式:以”提问 → 纠错 → 追问”的复习对话为底稿,按考点编号归档;所有代码均经对话验证。