跳转到内容

10 · 设计模式与工程实践

定位:游戏岗面试问设计模式,重点不是“背 GoF 的 23 种”,而是**“这个模式在游戏里怎么用、有什么坑”**。单例(及其被诟病的原因)、观察者(事件系统)、状态(状态机)、命令(输入/回放)、对象池是游戏岗高频五件套。 学完标准:能手写线程安全单例并说出它的缺点;能说出观察者/状态/命令在游戏里的具体场景;能解释对象池为什么省内存;懂 RAII、Pimpl 等 C++ 工程实践。


1. 设计模式总览(先有整体地图)

Section titled “1. 设计模式总览(先有整体地图)”
分类 目的 常见模式
创建型 怎么“造对象”更合理 单例、工厂、原型、建造者、对象池(游戏特化)
结构型 怎么“组合”类和对象 适配器、装饰器、外观、组合、Pimpl
行为型 怎么“交互”和“分工” 观察者、状态、命令、策略、模板方法、访问者

面试开场白:设计模式是经验的命名与封装,不是教条。游戏里尤其强调性能——用模式的同时要考虑内存布局和缓存友好(呼应第 11 章)。


2. 单例模式(Singleton)——最常考也最被诟病

Section titled “2. 单例模式(Singleton)——最常考也最被诟病”

2.1 标准写法(C++11 Magic Static,见 09 章)

Section titled “2.1 标准写法(C++11 Magic Static,见 09 章)”
class GameManager {
public:
static GameManager& instance() {
static GameManager gm; // 线程安全 + 懒加载
return gm;
}
GameManager(const GameManager&) = delete;
GameManager& operator=(const GameManager&) = delete;
private:
GameManager() = default;
~GameManager() = default;
};
  • 全局唯一:渲染器、资源管理器、音频、输入……整个游戏只有一份
  • 方便全局访问:任何地方 XXX::instance() 直接拿

2.3 为什么游戏里经常被诟病?(重点)

Section titled “2.3 为什么游戏里经常被诟病?(重点)”
问题 说明
全局状态 任何代码都能改它,状态难追踪,bug 难排查
隐藏依赖 函数参数里看不出来它用了哪些单例 → 耦合隐蔽
难测试 单例状态跨测试残留,mock 困难
生命周期 析构顺序不可控(跨模块引用已析构单例 → 崩溃)
并发问题 多线程访问要加锁,全局锁拖慢性能

游戏界的替代思路

  1. 依赖注入:把管理器通过构造函数传给需要的系统(显式依赖)
  2. 每系统一实例:引擎核心系统(渲染/物理/音频)作为普通对象,由“引擎”统一创建和传给其他系统
  3. 单例只用于“数据确实全局且只读”的场景

面试金句:单例“好用但有毒”——它把全局状态藏起来了。引擎里更推荐显式的依赖注入或“Engine 持有所有子系统”的组合方式,只有确需全局唯一时才用单例,且优先 Magic Static 写法。


// ❌ 客户端直接 new 具体类:耦合死
Enemy* e = new Orc(); // 要加新敌人类型 → 改这里
// ✅ 工厂:创建逻辑集中,加类型只改工厂
Enemy* EnemyFactory::create(EnemyType t) {
switch (t) {
case ORC: return new Orc();
case GOBLIN: return new Goblin();
case DRAGON: return new Dragon();
}
}
变体 特点 场景
简单工厂 一个静态函数按参数返回对象 类型少、够用就好
工厂方法 虚函数让子类决定创建哪种对象 需要扩展点
抽象工厂 一组相关对象的创建(一整套产品) 不同平台/风格整套切换(如 UI 皮肤)

游戏里的常见用法:对象工厂 + 类型注册表——用 std::unordered_map<TypeId, CreateFunc> 注册创建函数,加新类型不用改 switch,与反射系统结合(呼应 13 章 UE 的反射)。


4. 观察者模式(Observer)——事件系统

Section titled “4. 观察者模式(Observer)——事件系统”

被观察者(Subject)维护一组观察者(Observer),状态变化时通知它们,观察者各自响应。解耦“事件源”和“事件处理者”。

// 游戏里的事件系统简化版
class EventSystem {
std::unordered_map<EventType, std::vector<std::function<void(const Event&)>>> listeners_;
public:
void subscribe(EventType t, std::function<void(const Event&)> cb) {
listeners_[t].push_back(std::move(cb));
}
void publish(EventType t, const Event& e) {
for (auto& cb : listeners_[t]) cb(e); // 通知所有订阅者
}
};
// 用法:UI 订阅"玩家死亡"事件,逻辑层发布
eventSystem.subscribe(EVENT_PLAYER_DEATH, [](const Event&) { ui.showGameOver(); });
// 游戏逻辑里:eventSystem.publish(EVENT_PLAYER_DEATH, {});
  • UI:血条订阅“玩家扣血”事件,逻辑层不用认识 UI
  • 成就系统:订阅各种事件统计触发
  • 音频/特效:订阅“击杀”事件播放音效
  • 解耦:战斗逻辑 → 事件系统 → 表现层(UI/音频/粒子互不相识)
  1. 循环通知:A 通知 B,B 又通知 A → 死循环。要加通知深度限制/事件去重。
  2. 回调里改容器:通知过程中订阅者增删监听器 → 迭代器失效。需要延迟增删(先标记后处理)。
  3. 悬垂回调:订阅者对象析构了但没退订,事件触发时回调访问已销毁对象 → 悬垂。用 weak_ptr/lifetime token 或显式退订。
  4. 性能:每帧大量事件的函数指针调用/分配开销,热路径慎用(游戏里可改“按位事件标记 + 每系统轮询”)。

把对象的状态抽象成类,行为随状态切换。游戏里最经典的应用:角色状态机

// 角色状态:待机 → 攻击 → 死亡
class CharacterState {
public:
virtual ~CharacterState() = default;
virtual void enter(Character& c) {}
virtual void update(Character& c, float dt) = 0;
virtual void exit(Character& c) {}
};
class IdleState : public CharacterState {
void update(Character& c, float dt) override {
if (c.inputAttack()) c.changeState(std::make_unique<AttackState>());
}
};
class AttackState : public CharacterState {
void update(Character& c, float dt) override {
if (c.attackFinished()) c.changeState(std::make_unique<IdleState>());
}
};
class Character {
std::unique_ptr<CharacterState> state_ = std::make_unique<IdleState>();
public:
void changeState(std::unique_ptr<CharacterState> s) {
state_->exit(*this);
state_ = std::move(s);
state_->enter(*this);
}
void update(float dt) { state_->update(*this, dt); }
};
优点
状态切换逻辑清晰(每个状态一个类) 状态多了类爆炸(用状态机框架/数据驱动)
避免巨型 if-else 状态间跳转规则多了难维护(用状态转移表)
每个状态可独立测试 注意状态切换时机(update 中间切换要小心)

游戏里升级版:状态机 + 动画(UE 的 AnimGraph 就是可视化状态机)、行为树(AI)、分层状态机(底层状态 + 高层状态叠加,如“倒地”期间仍可“受击”)。


6. 命令模式(Command)——输入与可撤销

Section titled “6. 命令模式(Command)——输入与可撤销”

把“操作”封装成对象。游戏里最经典的用法:把玩家输入变成命令对象,进队列统一处理——支持撤销、回放、网络同步、按键重绑定。

class Command {
public:
virtual ~Command() = default;
virtual void execute() = 0;
virtual void undo() {}
};
class MoveCommand : public Command {
Actor& actor_; int dx_, dy_;
public:
MoveCommand(Actor& a, int dx, int dy) : actor_(a), dx_(dx), dy_(dy) {}
void execute() override { actor_.move(dx_, dy_); }
void undo() override { actor_.move(-dx_, -dy_); }
};
// 输入处理:按键 → 命令对象
Command* handleInput() {
if (pressed(KEY_UP)) return new MoveCommand(*player, 0, -1);
if (pressed(KEY_LEFT)) return new MoveCommand(*player, -1, 0);
return nullptr;
}
  1. 可撤销/重做:操作历史栈(undo 栈 + redo 栈)
  2. 回放:录命令序列,重放 = 再执行一遍(录像/比赛回放)
  3. 联机同步:只传命令,两端确定性执行(守望先锋/格斗游戏)
  4. 按键重绑定:命令和按键解耦,改键不用改代码
  5. AI 输入:AI 也“产生命令”,和玩家共用同一套执行逻辑

面试金句:命令模式把“做什么”从“什么时候做”里拆出来——输入/网络/AI 只是命令的来源,执行逻辑只有一份。


7. 对象池(Object Pool)——游戏性能命脉

Section titled “7. 对象池(Object Pool)——游戏性能命脉”

游戏中高频创建/销毁的东西:子弹、粒子、敌人、飘字、技能特效。如果每颗子弹都 new/delete

  • new 要经过通用堆分配器(加锁、查空闲块,可能系统调用)→ 慢(每帧上百次)
  • 频繁分配释放 → 内存碎片(堆碎片化)
  • 缓存不友好(对象散落各处)

对象池:启动时(或按需)预分配一批对象,用的时候从池里“借”,用完“还”回池里,全程不触碰堆分配器。

template <typename T>
class ObjectPool {
std::vector<std::unique_ptr<T>> objects_; // 持有所有对象,析构时统一释放
std::vector<T*> freeList_; // 空闲对象指针(栈)
public:
explicit ObjectPool(size_t n) {
for (size_t i = 0; i < n; ++i) {
auto up = std::make_unique<T>(); // 预分配,并把所有权交给 objects_
freeList_.push_back(up.get());
objects_.push_back(std::move(up));
}
}
T* acquire() {
if (freeList_.empty()) return nullptr; // 池空:可选扩容或拒绝
T* obj = freeList_.back();
freeList_.pop_back();
return obj;
}
void release(T* obj) {
obj->reset(); // 归还前重置状态(T 需提供 reset/clear)
freeList_.push_back(obj);
}
};
  1. 归还时重置状态(位置/速度/生命值归零),不调用析构
  2. 池空策略:扩容(一次性多分配)或阻塞等待(游戏用扩容居多)
  3. 对象池里对象地址固定;若用连续数组 + placement new 或内存池,还能做到连续内存(上面示例是逐个分配,不保证连续)
  4. 缺点:空闲对象占用内存(用“按需增长”平衡)

关联考点:对象池和内存池的区别?对象池管理“对象生命周期复用”;内存池管理“原始内存块复用”(见 11 章),游戏里常叠加使用。


8. 其他值得知道的模式(一句话级)

Section titled “8. 其他值得知道的模式(一句话级)”
模式 一句话 游戏场景
原型 Prototype 用“克隆自己”创建新对象 敌人复制、技能预制体
建造者 Builder 分步构造复杂对象 角色捏脸/装备系统
组件 Component 用组合替代继承 ECS 思想前身(GameObject 挂组件)
策略 Strategy 算法族可替换 移动方式、AI 决策切换
访问者 Visitor 对对象集合执行操作而不改类 序列化/日志遍历节点树
外观 Facade 给复杂子系统一个简单入口 引擎 API 封装
模板方法 骨架固定,步骤可覆盖 游戏循环、加载流程
中介者 Mediator 对象间不直接通信,走中介 对战系统(英雄间交互)

9. C++ 工程实践(面试爱考的“非模式”考点)

Section titled “9. C++ 工程实践(面试爱考的“非模式”考点)”

9.1 RAII(Resource Acquisition Is Initialization)

Section titled “9.1 RAII(Resource Acquisition Is Initialization)”

资源(内存、锁、文件、GPU 句柄)在构造时获取,析构时自动释放。核心是依赖栈对象在正常离开作用域和异常栈展开时析构一定被调用(进程被强杀/std::terminate 等异常路径除外)。

// 智能指针、lock_guard、fstream 都是 RAII
std::lock_guard<std::mutex> lg(m); // 锁的获取=构造,释放=析构,异常安全
std::ifstream f("a.txt"); // 文件句柄同理

面试金句:RAII 是 C++ 处理资源最核心的哲学——让析构函数替你兜底,异常安全的根基。新手写 new 配手动 delete 就是违背 RAII。

9.2 Pimpl(Pointer to Implementation,编译防火墙)

Section titled “9.2 Pimpl(Pointer to Implementation,编译防火墙)”
// Widget.h —— 只暴露不透明指针,头文件不包含任何实现细节
class Widget {
struct Impl; // 前置声明
std::unique_ptr<Impl> pImpl_;
public:
Widget();
~Widget();
Widget(Widget&&) noexcept;
};
// Widget.cpp —— 实现细节全在这里
struct Widget::Impl { std::vector<int> data; /* ... */ };
Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}

好处:改实现不重新编译所有包含者(编译速度)、隐藏实现、减小编译依赖。代价:一次间接访问、需手动写析构/移动(unique_ptr 不完整类型)。

  • 自定义了析构/拷贝构造/拷贝赋值任一 → 其他两个大概率也要自定义(或全部 delete)
  • C++11 加移动构造/移动赋值 → 五法则
  • 简单规则:要么都写,要么都 =default / =delete,别写一半(一半会引来深拷贝/浅拷贝 bug,见 04 章)
  • const 能加就加(成员函数、参数、局部变量)
  • 避免裸 new/delete,用智能指针;传参按 const T&,返回按值
  • 头文件最小化(前置声明 + Pimpl)
  • 编译器警告全开:-Wall -Wextra -Werror
  • 单元测试 + 静态分析(clang-tidy、ASAN)
  • 命名规范统一(引擎普遍 CamelCase 类、camelCase 成员、m_ 前缀可选)

10. 高频面试题 Q&A(合上书能讲)

Section titled “10. 高频面试题 Q&A(合上书能讲)”

Q1:单例模式为什么在游戏里被诟病?怎么替代? 全局状态难追踪、隐藏依赖、难测试、生命周期不可控、并发加锁拖性能。替代:依赖注入(构造函数传入子系统)、引擎持有子系统统一管理,只在“确实全局唯一”时用单例。

Q2:C++11 后单例怎么写线程安全? Magic Static:局部 static 变量,标准保证首次初始化线程安全,零手写锁,比 DCLP 简单。

Q3:观察者模式在游戏里干什么?有什么坑? 做事件系统解耦(UI/成就/音频订阅逻辑事件)。坑:循环通知、回调中修改监听容器、订阅者悬垂、热路径性能。

Q4:状态模式和命令模式分别解决什么问题? 状态:对象行为随状态切换(角色状态机,避免巨型 if-else);命令:把操作封装成对象(输入队列、撤销、回放、联机同步、按键重绑)。

Q5:为什么要用对象池?原理? 避免高频 new/delete 的开销与内存碎片,预分配 + 借还复用。归还时重置状态不析构,地址固定可预分配连续内存(缓存友好)。

Q6:什么是 RAII?举例。 资源构造获取、析构释放。lock_guard、unique_ptr、fstream 都是。是异常安全与资源管理的基石。

Q7:Pimpl 解决什么问题? 编译防火墙:头文件不暴露实现,改实现不用全量重编译,还隐藏实现细节。代价是间接访问一次。

Q8:三/五法则是什么? 自定义析构/拷贝构造/拷贝赋值/移动构造/移动赋值要成套处理,别只写一个,否则浅拷贝或悬垂。

Q9:简单工厂和抽象工厂区别? 简单工厂按参数返回一种对象;抽象工厂创建“一族相关对象”(整套产品线,如一套 UI 主题)。游戏里常用工厂 + 类型注册表(map 注册创建函数)替代 switch。

Q10:设计模式用多了有什么坏处? 过度设计:增加抽象层数 → 性能开销、代码难懂。游戏里优先“简单直接 + 性能优先”,模式是为解决真实问题服务,不是炫技。


11. 本章自测(10 题,限时 20 分钟,先自己答再看答案)

Section titled “11. 本章自测(10 题,限时 20 分钟,先自己答再看答案)”

1. 手写 C++11 线程安全单例(含禁拷贝、私有构造)。

2. 单例的四个缺点是什么?

我的原答:游戏里怎么替代?

3. 观察者模式在游戏里至少举 2 个应用场景。

4. 状态模式相比 if-else 状态机的优点?

5. 命令模式支持"撤销"的原理是什么?

6. 对象池为什么能减少内存碎片?

我的原答:归还对象时要不要析构?

7. 举 3 个 RAII 的实际例子。

8. Pimpl 为什么能加速编译?

9. 三/五法则的内容?

我的原答:只写析构不写拷贝构造会出什么问题?

10. 工厂模式加新类型时,用 switch 和用注册表有什么差别?

📖 答案(先自己答完再展开)
  1. static X& instance() { static X x; return x; },拷贝/赋值 =delete,构造私有。
  2. 全局状态、隐藏依赖、难测试、生命周期/并发问题;依赖注入、Engine 统一持有子系统。
  3. UI 血条订阅扣血事件;成就系统订阅击杀事件;音效订阅事件(任举 2 个)。
  4. 每个状态独立类、转移逻辑清晰、可独立测试、避免巨型 if-else。
  5. 每个命令有 execute 和 undo,历史栈保存已执行命令,undo 弹栈调用 undo()。
  6. 对象反复用同一块内存,不触发堆分配器,地址稳定不产生碎片;归还只重置状态不析构。
  7. unique_ptr、lock_guard、fstream、vector 内部(自动释放堆内存)等。
  8. 头文件只有不透明指针声明,实现细节移到 .cpp;改实现不改变头文件 → 包含它的文件不用重编译。
  9. 析构/拷贝/移动成套处理;只写析构(如释放资源)编译器仍生成浅拷贝 → double free。
  10. switch 加类型要改工厂代码;注册表加类型只需注册一行(配合反射/宏),扩展性更好、解耦。

## 12. 进阶追问(答不上来就回来复习)
  • 观察者模式的“事件总线”在多线程下怎么保证安全?(消息队列 + 单消费者)
  • 状态模式的状态转移表(table-driven)怎么写?怎么处理“状态机里再嵌套状态机”?
  • 命令模式的命令对象怎么避免频繁 new?(命令对象池复用)
  • 对象池 + ECS 的关系?(ECS 的组件数组本身就是“隐式对象池”)
  • 单例 + 多线程:全局实例的初始化顺序问题(跨翻译单元初始化顺序 static init order fiasco)?
  • 对比:组件模式 vs ECS 的区别?(组件模式对象是“带组件的实体”,ECS 是数据与逻辑彻底分离)