跳转到内容

附录 B · 考前速查卡

用法:考前 3 天每天手机过一遍;考前 30 分钟只看第一节。每条一行,卡壳就点回对应章节。 覆盖 02~13 章 + 15 章题库,所有结论与正文一致。

  • 多态只对指针/引用生效,按值传参会对象切片,调回基类版本。
  • 基类析构必须 virtual,否则 delete 基类指针是 UB:常见表现只调 ~Base、派生资源泄漏。
  • 构造/析构里调虚函数不发生动态绑定,走当前正在构造/析构那一类的版本。
  • 构造顺序 = 基类 → 成员(按声明顺序)→ 函数体,跟初始化列表书写顺序无关。
  • sizeof 答“占地多大”,alignof 答“住址要求”;结构体总大小必须是最大对齐数的整数倍。
  • 空类 sizeof == 1;64 位下含虚函数的类 sizeof == 8(一个 vptr)。
  • new = malloc + 构造new[]delete 是 UB;new int 不初始化、new int() 才是 0。
  • shared_ptr 管“指针”不管“对象”:引用计数原子(安全),对象内容读写仍要自己加锁。
  • 循环引用把一方改 weak_ptrlock() 提升时才临时把强计数 +1,保证提升期间对象不死。
  • std::move 不移动任何东西,它只是 static_cast<T&&>;搬资源的是移动构造函数。
  • 移动构造不标 noexcept,vector 扩容会退化为拷贝move_if_noexcept);类不可拷贝时仍会移动。
  • vector 扩容 GCC ×2、MSVC ×1.5,摊还 O(1);扩容后迭代器/指针/引用全部失效。
  • unique_ptr 不能拷贝,但能放进 vector —— vector 只要求元素可移动。
  • 函数模板不能偏特化,用“重载 + 全特化”替代;模板也不能是虚函数。
  • map 的 operator[] 会插入默认值;只想查存在用 find / contains
  • wait 必须带谓词(内部就是 while 循环),这样才免疫虚假唤醒;条件变量必须配 unique_lock。
  • 死锁四条件破一个就够,最常用“所有线程按固定顺序加锁”。
  • 内存序三档:relaxed 只保证原子性;acquire/release 配对做发布-消费;seq_cst 默认、最强也最慢。
  • 缓存行 64 字节,主内存比 L1 慢 100 倍以上;SoA 让缓存行里全是“要用的数据”。
  • DrawCall 贵在 CPU 提交 + GPU 状态切换,不是 GPU 算不过来;减少靠合批、图集、实例化。

一、编译链接与内存模型(02 章)

Section titled “一、编译链接与内存模型(02 章)”
变量 生命周期 / 初始化
全局变量 .data(已初始化)/ .bss(未初始化) 程序启动 → 结束,默认 0
static 全局变量 .data / .bss 程序启动 → 结束,默认 0
static 局部变量 .data / .bss 首次执行到声明处初始化,活到程序结束(作用域仅函数内)
局部变量 函数调用期间,默认垃圾值
new / malloc 的对象 直到 delete / free,不自动清零
字符串常量 "hello" .rodata(只读) 程序周期,改写会段错误
const 全局常量 .rodata 程序周期
const 局部变量 栈(编译期可优化) 同局部变量
虚函数表 vtable .rodata 程序周期,每类一份、同类对象共享
函数指令 .text 程序周期

默认初始化规则:全局/static 默认 0;栈和 new int 默认不初始化;new int() 才是 0。

判据 结论
每个成员的起始偏移 必须是 alignof(成员) 的整数倍;pack(n) 时取 min(n, alignof)
结构体对齐值 = 成员中最大的 alignof
结构体总大小 必须是结构体对齐值的整数倍(尾部补 padding)
{char, int, double} 16
{double, int, char} 16
{char, char, int} 8
{char, double, int} 24
{int, char, char} 8
省 padding 口诀 按成员大小从大到小
空类 / 空结构体 1(对象地址必须不同)
含虚函数的类 64 位下 8(一个 vptr)
#pragma pack(push,1){char,int} 5
小端上 int x = 0x12345678 内存低→高是 78 56 34 12(低位字节存低地址)
判大小端 int x=1; *(char*)&x == 1 → 小端;union 法同(严格说是 UB,规范写法 memcpy / bit_cast
一句话
预处理 .cpp → .i 展开 include、替换宏、条件编译、删注释
编译 .i → .s 词法 → 语法 → 语义 → 优化 → 汇编
汇编 .s → .o 机器码 + 符号表 + 重定位表
链接 .o + 库 → 可执行 符号解析(找定义)+ 重定位(填真实地址)
静态库 .a/.lib 链接期把代码拷进可执行文件:体积大、部署独立、启动快
动态库 .so/.dll 运行期加载:体积小、可共享、可热更,缺文件运行时报错
inline 函数 / 变量 头文件里放定义合法:编译期各生成一份,链接期按 COMDAT 合并成 1 份
模板 实例化时才生成代码,定义必须在使用处可见(放头文件)
extern "C" 关掉 C++ 名字修饰(name mangling),让 C++ 能链 C 的符号
static 全局变量 / 函数 内部链接,只在本 .cpp 可见,避免跨文件符号冲突
#pragma once vs #ifndef 前者编译器按文件去重、更快;后者靠宏名、跨平台
undefined reference 只声明没定义 / 源文件没编译 / 忘了链库 / 符号名不一致
编译错误 vs 链接错误 前者报 .cpp:行号(语法/类型);后者每个文件都能编,连起来找不到符号
对比项
分配方式 编译器自动分配/释放 程序员手动 new/delete
速度 快(只移动栈指针) 慢(查空闲块、可能系统调用)
容量 默认约 1~8MB,递归太深栈溢出 大,可达 GB 级
碎片 无(严格 LIFO) 有内存碎片
增长方向 向下(高地址 → 低地址) 向上
越界后果 段错误/崩溃 破坏堆管理结构,可能延迟崩溃

二、指针引用与内存管理(03 章)

Section titled “二、指针引用与内存管理(03 章)”
对比项 指针 引用
本质 存放地址的变量 变量的别名(绑定到对象)
是否占空间 是(64 位 8 字节) 概念上不占(实现上常是常量指针,优化后常为 0)
必须初始化 否(但最好置空) 必须
能否改指向 不能(绑定后不可换)
能否为空 能(nullptr) 不能(没有 NULL 引用)
多级 支持 int** 不支持
sizeof 恒为指针宽度 等于所引用对象的大小
函数重载 f(int*)f(int&) 是不同函数

使用建议:能引用就不用指针;必须可空/可换绑/要存进容器时用指针。 野指针 = 没初始化;悬垂指针 = 指向已释放对象;空指针 = nullptr。 避免三件套:① 初始化即置空/绑定 ② delete 后置空 ③ 优先用智能指针。

malloc / free new / delete
分配 只分配裸内存 分配内存 + 调用构造函数
释放 只释放内存 调用析构函数 + 释放内存
返回类型 void*(需强转) 类型化指针 T*
大小参数 自己算字节数 编译器按类型算
失败处理 返回 NULL std::bad_alloc
运算符重载 不支持(库函数) 可重载 operator new/delete(引擎自定义分配器)
数组 malloc(n*sizeof(T)) new T[n] / delete[]

new[] 必须配 delete[](常见实现会在数组头记录元素个数);混用是 UB,可能只析构一个元素。

场景 理由
默认独占所有权 unique_ptr 禁拷贝可移动;默认删除器下通常与裸指针同大小、零额外开销
多个持有者共享同一对象 shared_ptr 引用计数,控制块在堆上
打破循环引用 / 观察者 weak_ptr 只加弱计数;lock() 提升为 shared_ptr
包装非 new 资源(FILE*、贴图 ID) 智能指针 + 自定义删除器 默认删除器只会 delete
用基类指针持有派生对象 unique_ptr<Base> 多态基类必须有虚析构,否则 delete 基类指针是 UB
多线程共享 shared_ptr 计数可放心,对象内容要加锁 shared_ptr 管“指针”不管“对象”
对象很大 + 有长期 weak_ptr + 想尽早释放 shared_ptr(new T) 强计数归零就释放对象内存
unique_ptr shared_ptr
所有权 独占 共享
拷贝 禁止(=delete) 允许,计数 +1
移动 支持,转移所有权 支持
额外开销 无(默认删除器) 对象指针 + 堆上控制块指针
计数线程安全 计数增减是原子的
放进 vector 能(vector 只要求可移动)
适用 默认首选、性能热路径 真需要多所有者时(原子计数比裸指针慢)
make_shared shared_ptr(new T)
内存分配次数 1 次(对象 + 控制块一起) 2 次
缓存友好 好(对象和控制块挨着)
异常安全 安全 有泄漏窗口(f(shared_ptr(new T), g()) 里 g 先抛)
自定义删除器 不支持(只能用默认) 支持
对象内存何时释放 强计数归零只析构对象,整块内存要等弱计数也归零 强计数归零即释放对象内存
结论 能用就用 对象大 + 有 weak_ptr + 想尽早释放对象内存时用

时机 顺序
构造 基类(按继承顺序)→ 成员(按声明顺序)→ 构造函数体
初始化列表写反 没用,实际顺序仍按声明顺序,编译器会警告
析构 自身析构体 → 成员(逆声明顺序)→ 基类
虚基类场景 A → B → C → D;A 由最派生类 D 负责构造,B/C 构造里的 A(1) 被忽略
必须用初始化列表 const 成员、引用成员、没有默认构造的成员对象
初始化列表为什么快 直接以目标值构造(一次构造);体内 m = 42 要先构造临时对象再赋值
析构函数 无参数无返回值不可重载;不要抛异常(栈展开时抛 → std::terminate
代码 触发
S b = a; / S c(a); 拷贝构造(有新对象)
b = c;(b 已存在) 拷贝赋值
void f(S s) 按值传参 拷贝构造
S g(){ S t; return t; } 一般被 RVO/拷贝省略,退化为移动,最后才是拷贝
参数是右值(临时对象、std::move(x) 优先移动构造
push_back(std::move(x)) / emplace_back(args) 移动 / 原地构造
类管理裸资源但没写拷贝构造 默认浅拷贝 → 两次析构同一块内存 → double free
移动之后的源对象 “有效但未指定”:可析构、可重新赋值,内容不可依赖
规则 / 关键字 一句话
六个默认函数 默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值
Rule of 0 成员都用标准库类型(string/vector/智能指针),一个都不用写
Rule of 3 自定义析构 / 拷贝构造 / 拷贝赋值中任意一个 → 三个都要写
Rule of 5 涉及动态资源 + 移动语义 → 五个都写(或都 delete)
=default 要回编译器默认实现(自定义了别的构造后默认构造被吞掉)
=delete 禁用函数:禁拷贝、禁隐式转换、禁 new
explicit 禁单参构造的隐式转换:setPos(1.0f) 直接编译报错
空基类优化 EBO 空类当基类时那 1 字节可被合并(sizeof 4 而非 8);当成员不行
空类 / 含虚函数的类 sizeof(Empty)==1;含虚函数 64 位下 8

四、继承多态与虚函数(05 章)

Section titled “四、继承多态与虚函数(05 章)”
场景 绑定方式
非虚函数 静态绑定(编译期)
通过传递调用虚函数 静态绑定(切片后调基类版本)
通过指针/引用调用虚函数 动态绑定(运行期查虚表)
构造函数/析构函数里调用虚函数 静态绑定(当前正在构造/析构的类)
直接对对象调用(编译器能确定类型) 静态绑定(devirtualization,可能内联)
显式类名限定 d.Animal::speak() 静态绑定

对象切片:void f(Animal a) + f(dog) → 输出基类版本,多态失效。多态只对指针/引用生效。

追问 一句话答案
vtable 在哪 只读数据段 .rodata,每个类一份,同类对象共享
vptr 在哪 对象内存偏移 0(单继承);多继承时每个含虚函数的基类子对象各带一个
有几个虚表 ≈ 有几个“含虚函数的基类子对象”(纯数据基类不带 vptr)
构造/析构里调用会怎样 不发生动态绑定,vptr 指向当前类的表;基类构造里调纯虚函数是 UB
基类析构为什么要虚 非虚则 delete 基类指针是 UB:派生类析构不执行、资源泄漏
开销在哪 间接跳转(多 1~2 次内存访问)+ 无法内联 + 分支预测差
单次多贵 约 5~15ns(直接调用 1ns 量级);每帧 × 上万对象才明显
什么时候能内联 去虚拟化之后:直接对象、final 类/函数、构造析构内部、显式限定、LTO
结论
多继承构造顺序 按继承列表顺序(先 A 后 B)
C : A, B 布局(A/B 各有虚函数 + int,C 有 int cc) A 的 vptr@0、A::a@8、B 的 vptr@16、B::b@24、C::cc@28 → sizeof(C)=40
两个 vptr 挨着吗 通常不,中间隔着基类自己的成员;纯虚接口(无数据)才相邻
B* pb = pc; 地址 = pc + 16(this 指针调整,是正确性的一部分)
(void*)pa == (void*)pb false(比数值);要比“是否同一对象”先都转回 C*
菱形继承两个问题 二义性 + 基类子对象被复制两份
虚继承怎么解 只保留一份共享虚基类子对象,位置由最派生类决定
虚继承的代价 访问虚基类成员要先查偏移表(vbase offset / vbtable),多一次间接寻址
sizeof 对比(示例) 普通继承 8 → 虚继承 24(多两个 8 字节偏移指针 + 对齐填充)
谁构造虚基类 最派生类;虚基类最好提供默认构造函数
RTTI 存在哪 type_info 挂在虚表前置槽(如 -1 项)→ dynamic_cast/typeid 只对多态类型可用
dynamic_cast vs static_cast 前者运行期检查(失败返回 nullptr/抛异常,无法内联);后者编译期、不检查、错了是 UB

容器 底层 查找 插入/删除 随机访问 迭代器失效
vector 连续动态数组 O(n) 尾 O(1)★,中间 O(n) ✅ O(1) 扩容全失效;insert/erase 使插入点/被删点及其后失效
deque 分段连续 + 中控器 O(n) 头尾 O(1)★ ✅ O(1) 头尾插入:迭代器全失效、引用不失效;中间全失效
list 双向链表 O(n) O(1)(已知位置) 除被删元素外不失效
forward_list 单向链表 O(n) O(1) 同上
set / multiset 红黑树 O(log n) O(log n) 不失效(节点式)
map / multimap 红黑树 O(log n) O(log n) 不失效
unordered_map / unordered_set 哈希表(链地址) 平均 O(1) 平均 O(1) rehash 时全失效
array 固定大小内嵌数组 O(n) ✅ O(1)

★ = 摊还复杂度。选择金句:随机访问 + 尾部增删 → vector;头尾插入 → deque;大量中间插入删除 → list;有序 + 查找快 → map/set;只查快、不在乎顺序 → unordered_map;元素唯一 → set/map,可重复 → multiset/multimap。

问题 判据
三个指针 start / finish / end_of_storage,管 size 和 capacity
扩容因子 GCC ×2,MSVC ×1.5
为什么指数增长 线性增长总拷贝 O(n²);指数增长摊还 O(1)
扩容时元素怎么搬 优先移动(移动构造 noexcept 时),否则拷贝
push_back 未触发扩容 已有元素的迭代器/引用不失效,但 end() 失效
erase 被删元素及之后全失效;用 it = v.erase(it),别 erase 后再 it++
reserve vs resize reserve 只扩 capacity、不构造元素;resize 改 size 并默认构造
emplace_back vs push_back emplace 转发参数原地构造,省一次临时对象构造 + 移动
vector 比 list 遍历快 连续内存缓存命中率高 + CPU 顺序预取
map unordered_map
底层 红黑树 哈希表(链地址法)
复杂度 O(log n),稳定无最坏退化 平均 O(1),最坏 O(n)
有序性 有序(中序遍历) 无序
key 要求 <(或比较器) hash 函数 + ==
迭代器指向 pair<const Key, T>,key 不可改
operator[] key 不存在会插入默认值(副作用)
rehash 负载因子(元素/桶数)超阈值(默认 1.0)时扩容重散列,全迭代器失效
元素少时 可能更快(无退化、常数稳定) 哈希计算有开销

C++ 的 unordered_map 桶内不会转红黑树 —— 那是 Java 8 HashMap 的行为。 为什么用红黑树不用 AVL:平衡要求松(最长路径 ≤ 2×最短)→ 插入删除旋转少、常数小,整体统计性能好。


概念 一句话用途
实例化 编译期发生,用到哪种类型生成哪种;类模板成员函数惰性实例化
全特化 template<> + 具体类型,给某个类型专门实现
偏特化 限定一类类型(如 T*);类模板可以,函数模板不可以
函数模板的替代方案 重载 + 全特化(重载语义更清晰,特化不参与重载决议)
SFINAE 替换失败不是错误:签名替换非法就跳过该候选,继续找别的重载
enable_if enable_if_t<条件,T>:条件成立才有 type,这个重载才存在
if constexpr C++17 在同一函数里裁分支;80% 的 enable_if 场景更直观的替代
可变参数模板 递归展开(剥第一个 + 空包出口)或 C++17 折叠表达式
折叠表达式 (args + ...) 右折叠求和;(cout << ... << args) 左折叠打印
非类型模板参数 template<typename T, size_t N>,N 必须是编译期常量
定义为什么放头文件 实例化在使用处的编译期发生,看不到定义 → undefined reference
模板 vs 虚函数 模板不能是虚函数;类模板里可以有虚函数;模板可以调虚函数
CRTP 派生类把自己当模板参数传给基类,基类 static_cast<Derived*> 静态分发
编译期 vs 运行期多态 模板:可内联、零虚表开销,但代码膨胀、类型必须编译期确定

概念 一句话
值类别 左值 = 有名字可取地址;纯右值 = 临时无名;将亡值 = 可被偷资源的右值(move 结果)
右值引用变量本身 int&& r = std::move(x); 里的 r左值(它有名字)
std::move 只是 static_cast<T&&>,不移动任何东西;搬资源的是移动构造函数
std::forward 有条件的 move:T 推导为右值引用时才转右值;泛型转发必须用它
完美转发 T&& 转发引用接收 + std::forward<T>(arg) 转发;引用折叠 T&+&&→T&T&&+&&→T&&
移动后源对象 有效但未指定:可析构、可重新赋值,内容不可依赖
移动 + noexcept 不标 noexcept 且类可拷贝时,vector 扩容会退化为拷贝
lambda 底层 匿名仿函数类(重载 operator()),捕获的变量成为成员;空捕获 [] 可转函数指针
捕获方式 值捕获是快照(改外面不影响);引用捕获会看到外面,也容易悬垂
auto vs decltype auto 按值推导、丢 const/引用;decltype 原样保留;decltype(auto) 兼得
const vs constexpr const 只保证不可改;constexpr 要求编译期可求值(可做数组大小、模板参数)
nullptr vs NULL NULL 是整数 0,f(int)/f(char*) 会选错重载;nullptr 是 nullptr_t
协程 vs 线程 协程用户态协作式、切换极小、无栈(状态在堆上 coroutine frame);线程内核抢占、适合多核
concepts(C++20) 给模板参数加约束,报错友好,替代复杂的 SFINAE 写法
  • C++14:泛型 lambda [](auto a, auto b)、初始化捕获 [x = expr]、返回类型推导、decltype(auto)make_unique、constexpr 放宽(支持循环/if)。
  • C++17:结构化绑定、if constexpr、折叠表达式、inline 变量、string_viewoptional/variant/anyfilesystem、CTAD。
  • C++20:concepts、协程、ranges、三向比较 <=>std::formatconsteval/constinit、span、模块。

一句话
条件① 互斥 资源同一时刻只能被一个线程占用
条件② 占有且等待 拿着一个资源等另一个
条件③ 不可剥夺 资源不能强行抢走
条件④ 循环等待 A 等 B 持有的,B 等 A 持有的
解法① 固定顺序加锁 所有线程都先 a 后 b → 循环等待消失(最常用)
解法② 一次性拿所有锁 std::scoped_lock(a, b),内部用死锁避免算法(等价 std::lock
解法③ try_lock + 超时退让 拿不到就退让重试(std::try_lock
补充 减少锁粒度、避免嵌套锁;破坏任意一个必要条件即可
lock_guard unique_lock scoped_lock
加锁方式 构造加锁 构造加锁 一次锁多个互斥量
手动 lock/unlock
可移动
配条件变量 ✅(wait 必须用它)
场景 绝大多数加锁 需要手动控制 / 条件变量 同时锁多个(避免死锁)
典型写法 lock_guard<mutex> lg(m); unique_lock<mutex> ul(m); scoped_lock lk(a, b);
本质 RAII 封装 lock/unlock 更灵活的 RAII 锁 C++17 多锁 RAII
问题 判据
为什么必须 unique_lock wait 要原子地“释放锁 → 挂起 → 唤醒后重新上锁”,lock_guard 不能手动 unlock
condition_variable_any 可用任何满足 BasicLockable 的锁类型
wait(lock, pred) 内部 等价 while (!pred()) wait(lock);
虚假唤醒 被唤醒 ≠ 条件满足,操作系统可能空唤醒
正确写法 cv.wait(ul, []{ return !q.empty(); }); 带谓词 → 自动循环检查,免疫虚假唤醒
错误写法 不带谓词 wait,醒来后用 if 检查
数据竞争 两线程访问同一内存、至少一个写、无同步 → UB;读读不算
std::thread 三条铁律 ① 析构前必须 join 或 detach,否则 terminate ② 传引用要显式 std::ref ③ 引用对象生命周期要比线程长
内存序 语义 用在哪
relaxed 只保证原子性,不保证顺序 单纯计数器(不关心顺序)
acquire 此操作之后的读写不能被重排到它前面 消费者读
release 此操作之前的读写不能被重排到它后面 生产者发布
seq_cst 全程序全局一致顺序(默认) 默认选择,最安全也最慢
典型配对 生产者 release 写、消费者 acquire 读 → release 之前写的所有数据,acquire 之后都能看到 生产者-消费者
atomic vs mutex atomic:单变量、通常无锁、快;mutex:任意临界区、慢 计数器 vs 多行代码
线程安全单例 Magic Static:局部 static 变量,C++11 保证首次初始化线程安全 零手写锁,比 DCLP 简单
DCLP 的坑 instance = new T() 的赋值可能先于构造,别的线程拿到未构造完的对象 C++11 后不必再用
线程池 / job system 固定工作线程 + 任务队列 + mutex + 条件变量 + stop 标志;job system 拆小任务按核调度(UE TaskGraph) 复用线程,避免每系统一线程

九、设计模式与工程实践(10 章)

Section titled “九、设计模式与工程实践(10 章)”
模式 / 实践 游戏里怎么用 + 一句话
单例 全局唯一(渲染/资源/音频);C++11 用 Magic Static
单例被诟病的原因 全局状态难追踪、隐藏依赖、难测试、生命周期不可控、并发加锁拖性能
单例的替代 依赖注入(构造函数传子系统);Engine 统一持有并分发子系统
工厂 创建逻辑集中;常用“工厂 + unordered_map<TypeId, CreateFunc> 注册表”替代 switch
观察者 事件系统:UI/成就/音频订阅逻辑事件,解耦事件源与处理者
观察者四个坑 循环通知、回调里增删监听器(迭代器失效)、订阅者悬垂、热路径性能
状态 角色状态机(Idle/Attack/Dead),避免巨型 if-else;状态多了用转移表/行为树
命令 输入封装成对象:撤销/重做、回放、联机同步(只传命令)、按键重绑定、AI 与玩家共用执行逻辑
对象池 子弹/粒子/特效预分配 + 借还;归还只重置状态、不调析构;池空用扩容
RAII 构造获取资源、析构释放;lock_guard / unique_ptr / fstream 都是;异常安全根基
Pimpl 头文件只留不透明指针,改实现不用全量重编译;代价是一次间接访问
三/五法则 要么都写,要么都 =default / =delete,别写一半
对象池 vs 内存池 对象池管“对象生命周期复用”;内存池管“原始内存块复用”;常叠加使用

层级 / 项 数字 / 一句话
寄存器 ~1 cycle
L1 缓存 ~4 cycles(~1ns)
L2 缓存 ~12 cycles
L3 缓存 ~40 cycles
主内存 ~200+ cycles(~100ns),比 L1 慢 100 倍以上
缓存行 64 字节;访问一个 int 会把周围 64 字节一起搬进缓存
优化第一步 先测量(profiler):测量 → 定位(算法 vs 常数)→ 优化 → 复测
游戏特有指标 帧时间、P99 帧率(卡顿感来源)、每帧预算(60fps = 16.7ms)
手段 一句话
缓存友好三招 连续内存 + 顺序遍历;字段按访问顺序排;alignas(64) 对齐缓存行
虚函数开销 间接跳转 + 无法内联 + 分支预测差;热路径用模板/CRTP/switch/ECS 替代
内存池 Pool 预分配固定大小块 + 空闲链表,分配/释放 O(1)、无碎片
栈式/帧分配器 游标后移分配,帧末整体 reset,O(1) 释放,适合只活一帧的数据
内存池 vs 对象池 内存池管原始内存(不调构造析构);对象池管对象(借还重置状态)
分支预测 猜错清空流水线,惩罚约 20 周期;排序数据、branchless、查找表、[[likely]] 缓解
内联 只是建议,编译器决定;大函数内联增大代码体积、可能伤缓存
其他 移动语义(配 noexcept)、SIMD 向量化、多线程并行、延迟计算、空间换时间
AoS(Array of Structures) SoA(Structure of Arrays)
布局 每个对象一个结构体连续排 每个字段一个数组
代码样子 struct Particle{float x,y,z,vx,vy,vz;} 的 vector vector<float> x,y,z,vx,vy,vz;
访问单个对象 方便 要跨数组
批量遍历单个字段 浪费带宽(把无关字段也拉进缓存行) 完美(缓存行里全是目标数据)
典型场景 少量对象、对象整体使用 海量对象、按字段批量处理(粒子、ECS)
与 ECS 的关系 ECS 的组件数组天然就是 SoA

十一、游戏开发与引擎(12 / 13 章)

Section titled “十一、游戏开发与引擎(12 / 13 章)”
阶段 做什么 频率考虑
处理输入 收集键鼠/手柄/网络输入 越快越好(低延迟)
更新 Update 推进游戏逻辑:AI、物理、动画、状态 逻辑一般固定频率(30/60/120Hz)
渲染 Render 提交绘制命令给 GPU 跟随显示器刷新率(60/120/144Hz)
循环骨架 while (running) { 输入; 更新(dt); 渲染; } 所有游戏/引擎的骨架
一帧预算 60fps = 16.7ms,渲染/逻辑/物理/AI/动画各分一块 超预算就掉帧
主流混合方案 渲染可变步长 + 逻辑/物理固定步长 + 插值 兼顾流畅与稳定
可变步长 固定步长
实现 简单直接(每帧用真实 dt) 稍复杂(accumulator 累积 + 插值)
物理稳定性 差:帧率波动 → 物理抖动 好:确定性强
跨机器一致性 好(同输入 → 同结果,联机必须)
表现 帧率高时跑得快、低时慢 表现一致
用途 渲染(跟随刷新率) 物理 / 逻辑 / 联机 / 回放
为什么必须固定 确定性:联机同步、回放、物理测试都依赖“同输入同结果”
一句话
Entity 只是一个 ID(整数),没有数据也没有行为(如 entity 1024)
Component 纯数据 struct:Position{x,y}Health{100}Velocity{dx,dy}
System 纯逻辑函数,处理一类组件:移动系统处理 Position + Velocity
传统 OOP 的痛点 想加“会飞”就得新建类 → 多继承爆炸、脆弱的深继承树
优点① 缓存友好 同类组件连续存储(SoA),遍历快
优点② 灵活组合 加能力 = 加组件,不用改继承树
优点③ 易并行 不同系统处理不同数据,job system 直接调度
优点④ 无深继承 告别菱形继承和类爆炸
缺点 组件间通信要小心、调试时对象信息分散、上手门槛高、不适合“对象行为差异极大”的场景
阶段 谁执行 频率 干什么
顶点着色 GPU 并行 每顶点 模型 → 世界 → 相机 → 投影变换,算屏幕位置
图元装配 + 光栅化 GPU 固定单元 每三角形 三角形 → 像素片元(判断哪些像素被覆盖)
片元着色 GPU 并行 每像素 光照、纹理采样,算像素颜色
输出合并 GPU 固定单元 每像素 深度测试 / 混合 / 模板测试
输出 每帧 帧缓冲 → 屏幕
优化两大方向 减少着色器复杂度 + 减少绘制调用
一句话
是什么 CPU 向 GPU 提交一次“画这个物体”的命令;换材质/纹理/网格都要一次
为什么贵① CPU-GPU 通信:穿过总线、可能有同步等待
为什么贵② 状态切换:换材质/纹理 = GPU 重新配置状态(很慢)
后果 GPU 没事干等 CPU,利用率下降
性能量级 现代引擎几百到几千;移动端更敏感,几百个就吃力
减少① 合批 同材质物体合并成一个网格一次画完(静态合批;动态合批有顶点/材质限制)
减少② 纹理图集 Atlas 多张小图拼一张大图 → 同一纹理就能合批
减少③ 实例化 Instancing 同一网格不同变换一次提交 N 个(10 万颗草 1 次 DrawCall)
减少④ 减少状态切换 按材质/纹理排序渲染,减少切换次数
结构 / 概念 适用场景
网格 / 空间哈希 物体均匀分布(子弹、大量小物体),实现最简单
四叉树(2D) 2D 游戏、地形、单位查询,稀疏分布也能自适应
八叉树(3D) 3D 场景、体素、视锥剔除
BVH 动态物体(角色、刚体),实时更新友好,物理引擎核心
BSP 树 室内场景(早期 FPS)、寻路,静态场景专用
两阶段流程 broad phase 用空间分区粗筛候选对 → narrow phase 用包围体精确求交
包围体 AABB 检测最便宜;OBB 更贴合但贵;角色常用胶囊体
收益 把 O(n²) 全对全检测降到接近 O(n)
前向渲染 延迟渲染
思路 每个物体直接算光照 先渲 GBuffer(位置/法线/颜色/金属度),再统一算光照
灯光数量 灯光多时开销爆炸 支持大量光源
缺点 多光源贵 显存带宽大、MSAA 支持差、透明物体要回前向
适用 移动端 / 少光源 / 透明物体 PC / 主机、大量点光源场景
主流做法 延迟为主体 + 前向管透明
渲染优化手段 视锥剔除、遮挡剔除、LOD、合批、纹理图集/压缩
话题 一句话
UE UObject 引擎几乎所有对象的基类,提供反射、GC、序列化、编辑器集成、网络复制
UE 反射 C++ 原生没有;靠宏(UCLASS/UPROPERTY/UFUNCTION)+ UHT 扫描头文件生成元数据
UE GC 可达性分析 Mark-Sweep:从根引用标记可达,未标记的回收
UPROPERTY 的意义 没标记的引用 GC 不认,对象可能被当成垃圾回收掉
UE GC vs shared_ptr GC 天然解决循环引用但有停顿;shared_ptr 无停顿但要 weak_ptr 破环
Unity Mono IL 运行时 JIT:迭代快、支持热更;iOS 曾禁 JIT
Unity IL2CPP IL → C++ → AOT 原生码:性能好、平台全、难反编译;包大、难热更;发布正式包用它
四元数 w,x,y,z 四分量:无万向节死锁、slerp 平滑插值、乘法拼接稳定;欧拉角只给人看数值
状态同步 vs 帧同步 状态同步传对象状态(带宽大、反作弊强,MMO/射击);帧同步只传输入两端确定性模拟(带宽极小、难反作弊,RTS/格斗)
帧率 vs 刷新率 帧率 = 引擎产帧数,刷新率 = 显示器 Hz;不匹配会撕裂/卡顿,VSync 对齐但增加延迟
引擎的本质 平台隔离 + 公共能力复用

要不要用虚函数

  • 需要在运行期按对象实际类型选行为吗?
    • 不需要 → 普通函数 / 模板,别上虚函数。
    • 需要 → 调用点在热路径(每帧 × 上万对象)吗?
      • 不是 → 直接用虚函数,可读性优先。
      • 是 → 编译期能确定类型吗?
        • 能 → 模板 / CRTP / 重载(零虚表开销、可内联)。
        • 不能 → 把行为做成数据 + 统一处理(ECS / 数据驱动),或把叶子类标 final 帮编译器去虚拟化。
  • 决定用虚函数之后:多态基类析构必须 virtual;重写一律写 override

选哪个智能指针

  • 需要共享所有权吗?
    • 不需要 → unique_ptr(默认首选)。
    • 需要 → 会形成环吗?
      • 会 → 一方改 weak_ptr(谁不依赖对方存活,谁 weak:子→父、资源→管理器)。
      • 不会 → shared_ptr;但“要自定义删除器”或“对象大 + 有 weak_ptr 想尽早释放对象内存” → 用 shared_ptr(new T),否则 make_shared

选哪个容器

  • 随机访问 + 频繁尾部增删 → vector
  • 头尾都要插(当队列/栈用) → deque
  • 大量中间插入删除、要迭代器稳定 → list
  • 要有序 + 查找快 / 范围查询 → map / set
  • 只求查得快、不在乎顺序、key 好哈希 → unordered_map
  • 元素很少(几十个) → map 可能比 unordered_map 更快
  • 编译期已知大小、要内嵌不开堆 → array
  • 元素很大、移动昂贵、要求地址稳定 → vector 存指针而不是存对象

该拷贝、移动还是转发

  • 形参只是读 → const T&
  • 形参要接管资源(存进成员/容器) → 按值传 + std::move 进成员
  • 泛型转发 → T&& + std::forward<T>绝不用 std::move(会误伤左值)
  • 自己写移动构造 → 一定标 noexcept,否则 vector 扩容退化为拷贝
  • 类管理裸资源 → 三/五法则成套写,别只写一个

AoS 还是 SoA

  • 对象数量少、总是整体使用 → AoS(写起来直观)
  • 海量同类对象、按字段批量处理(粒子 / ECS) → SoA
  • 多线程各改不同字段 → 防伪共享:alignas(64) 分开,或各自补齐到缓存行

  • 问:虚函数怎么实现的? → 追:vtable 在哪、vptr 在哪、多继承几个 vptr、构造里调用会怎样、为什么基类析构必须 virtual?
  • 问:shared_ptr 怎么实现的? → 追:控制块存哪、计数线程安全吗、所指对象安全吗、make_shared 为什么快?
  • 问:循环引用怎么办? → 追:weak_ptr 原理、lock() 为什么要临时 +1、谁该用 weak?
  • 问:unique_ptr 为什么不能拷贝? → 追:能放进 vector 吗、怎么转移所有权、自定义删除器怎么写?
  • 问:vector 扩容机制? → 追:增长因子多少、为什么不用线性、摊还复杂度、扩容后哪些失效?
  • 问:构造函数和析构函数的顺序? → 追:初始化列表顺序算谁的、虚基类谁负责构造、为什么用初始化列表?
  • 问:模板实例化在什么时候? → 追:为什么定义要放头文件、函数模板为什么不能偏特化、SFINAE 与 enable_if 怎么写、C++17 怎么替代?
  • 问:std::move 做了什么? → 追:为什么不移动任何东西、move 后对象什么状态、和 forward 什么区别、移动构造为什么要 noexcept?
  • 问:死锁怎么产生?怎么避免? → 追:四个必要条件、三种解法各破坏哪个、scoped_lock 内部做了什么?
  • 问:什么是虚假唤醒? → 追:wait 为什么必须配 unique_lock、带谓词为什么能免疫、condition_variable_any 差别?
  • 问:内存序有哪几种? → 追:release/acquire 配对保证什么、计数器该用哪个、默认是哪个、为什么不都用 seq_cst?
  • 问:C++11 怎么写线程安全单例? → 追:DCLP 的坑在哪、Magic Static 为什么安全、单例有什么缺点?
  • 问:怎么减少 DrawCall? → 追:为什么贵、实例化和图集各解决什么、移动端多少算多?
  • 问:固定步长和可变步长怎么选? → 追:为什么物理必须固定、accumulator 怎么用、联机为什么依赖确定性?
  • 问:ECS 为什么性能好? → 追:和 SoA 什么关系、相比继承的代价是什么、哪些场景不适合?