Appearance
行为型模式(Modern C++ 写法)
行为型模式解决的是"对象之间怎么协作":谁调用谁、谁通知谁、如何把"变化的部分"抽出来。GoF 的经典解法大量依赖虚基类 + 继承 + 动态分派;而在 C++11/17/20 之后,
std::function、std::variant、std::visit、concepts、ranges 让我们能用更少继承、更少间接、更易组合的方式达成同样目的。本页逐个模式给出"老写法 → 现代写法"的对照与取舍。
零、总纲:先问"变化点在哪"
一句话:所有行为型模式的本质都是"把会变的那部分隔离开",手段可以是运行期多态、也可以是编译期组合。
展开:选型时按三步走——(1) 变化点在类型还是值/数据?(2) 需要在运行期决定,还是编译期就能确定?(3) 是否处在热路径,是否在乎间接调用与分配?类型级变化 + 需运行期决定,用 std::function 或虚接口;类型级变化 + 编译期确定,用模板/策略参数(零开销);数据级变化(同一族"种类"),优先 std::variant + std::visit,因为它免继承、可穷尽检查、还能互相赋值。
小例子
cpp
// 同一个"通知"需求,三种实现代价不同
struct IFace { virtual void on(int) = 0; }; // 运行期多态:有 vtable 与间接调用
std::function<void(int)> cb; // 类型擦除:有间接调用,可存任意可调用体
template <class Sink> struct Emit { // 编译期策略:可内联、零间接开销
static void notify(Sink& s, int v) { s.on(v); }
};演示"同样的行为、三种不同的代价与灵活度",选型取决于变化点与是否在热路径。
1. 策略 Strategy
一句话:把算法抽成可替换的策略;能用编译期模板就别用运行期指向。
老写法 → 现代写法
- 老:抽象基类声明
virtual double run() = 0,派生FastImpl/SlowImpl,持有std::unique_ptr<Strategy>,每次调用一次虚分派 + 一次堆分配。 - 现代:三选一按需——① 需要运行期切换、策略多且共享,保留虚接口;② 只关心"一个可调用行为",改用
std::function;③ 策略在编译期就确定、且在热路径,直接用模板参数(policy),调用可被内联、零开销。
小例子
cpp
#include <vector>
#include <algorithm>
// 编译期策略:无需继承、无需指针,sort 可整体内联
struct Asc { static void apply(std::vector<int>& v) { std::sort(v.begin(), v.end()); } };
struct Desc { static void apply(std::vector<int>& v) { std::sort(v.rbegin(), v.rend()); } };
template <class Policy>
struct Sorter {
void sort(std::vector<int>& v) const { Policy::apply(v); } // 静态分派
};
// 用法:Sorter<Asc>{}.sort(v); —— 编译期已定,无虚表、无间接跳转同一份"排序策略"逻辑,用模板参数实现后没有虚函数开销,编译器能把 apply 直接内联进 sort。
面试怎么问:策略模式和模板参数化有什么区别? → 前者是运行期多态、灵活可热插拔但有间接开销;后者是编译期多态、零开销但类型必须编译期确定。
2. 观察者 Observer
一句话:用 std::function 存回调、用 weak_ptr 防悬垂、用 std::erase_if 清理失效订阅者。
老写法 → 现代写法
- 老:手写
std::vector<Observer*>存裸指针,被观察者析构时必须记得unsubscribe,否则通知时访问野指针;回调靠继承接口,新增事件类型就要改接口。 - 现代:回调用
std::function或直接存std::weak_ptr;通知时lock()失败即视为已失效,std::erase_if顺手剔除,做到"订阅者可随意析构、被观察者不必感知"。
小例子
cpp
#include <functional>
#include <memory>
#include <vector>
struct Observer { virtual void on(int) = 0; virtual ~Observer() = default; };
class Subject {
std::vector<std::weak_ptr<Observer>> subs_;
public:
void subscribe(const std::shared_ptr<Observer>& o) { subs_.push_back(o); }
void notify(int v) { // C++20:边遍历边清理失效观察者
std::erase_if(subs_, [v](std::weak_ptr<Observer>& w) {
if (auto sp = w.lock()) { sp->on(v); return false; } // 存活 → 保留
return true; // 过期 → 移除
});
}
};演示用 weak_ptr 表达"弱引用订阅":观察者析构后自动被剔除,彻底避免悬垂指针。
面试怎么问:观察者模式里如何防止"被观察者通知已销毁的观察者"? → 存 weak_ptr(或由订阅者自己持有并注销),通知前 lock() 校验,失效项即时清除。
3. 访问者 Visitor
一句话:用 std::variant + std::visit + overloaded 重载集替代双分派,零继承。
老写法 → 现代写法
- 老:每个元素类加一个
accept(Visitor&),Visitor 基类为每种子类型声明一个visit(X&)重载——新增一种元素就要改动所有 Visitor,双分派写起来冗长。 - 现代:若"种类"有限且已知,直接
using Shape = std::variant<Circle, Square, ...>;用std::visit单次分派到重载集overloaded。新增操作只需加一个 lambda;若漏掉某分支,编译器直接报错(穷尽检查)。
小例子
cpp
#include <variant>
#include <cstdio>
struct Circle { double r; };
struct Square { double side; };
// 重载集:把多个 lambda 合成一个可调用对象(C++17 推导指引)
template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
using Shape = std::variant<Circle, Square>;
double area(const Shape& s) {
return std::visit(overloaded{
[](const Circle& c) { return 3.14159 * c.r * c.r; },
[](const Square& q) { return double(q.side * q.side); },
}, s); // 漏写任一分支 → 编译期报错
}演示"数据即种类":用 variant 承载有限集合,用 visit + 重载集实现无继承的访问者,且分支被编译器强制穷尽。
面试怎么问:想给一组类型加"新操作"而不改这些类型,怎么做? → 传统用 Visitor 双分派;现代若类型是封闭集合,用 std::variant + std::visit 更简洁,还能获得编译期穷尽检查。
4. 命令 Command
一句话:把"一次操作"封成对象(std::function 对),从而可排队、可撤销、可重放。
老写法 → 现代写法
- 老:为每种操作定义一个
Command派生类,字段靠成员,撤销逻辑分散在各派生类的undo()里,样板代码多。 - 现代:一个
Command结构体持有一对std::function——apply与undo;命令入队、执行、压入撤销栈,undo时反向弹出即可。lambda 捕获所需状态,"撤销什么"与"执行什么"成对出现,天然一致。
小例子
cpp
#include <functional>
#include <vector>
struct Command {
std::function<void()> apply;
std::function<void()> undo;
};
std::vector<Command> redo_stk, undo_stk;
void run(Command c) {
c.apply(); // 执行
undo_stk.push_back(std::move(c)); // 记录,供撤销
}
void undo() {
if (undo_stk.empty()) return;
Command c = std::move(undo_stk.back());
undo_stk.pop_back();
c.undo(); // 逆向恢复
redo_stk.push_back(std::move(c));
}演示用 function 对封装"可执行 + 可逆":撤销与执行绑在一起,队列/撤销栈就是普通容器。
面试怎么问:编辑器/数据库的 undo/redo 怎么实现? → 把每个操作封成携带 apply 与 undo 的命令对象,执行时压入撤销栈、撤销时弹出并调用 undo,redo 栈反向即可。
5. 状态 State
一句话:把"对象在不同状态下行为不同"显式化——要么状态类,要么 variant + 表驱动。
老写法 → 现代写法
- 老:每个状态写成一个派生类,状态迁移就是在
next()里new下一个状态并替换this->state_,堆分配 + 虚调用,迁移关系散落各处难总览。 - 现代:状态是封闭有限集合时,用
std::variant存当前状态,转移函数用std::visit(或查表)统一处理;迁移关系集中可见,状态自带数据,无堆分配。
小例子
cpp
#include <variant>
#include <cstdio>
struct Idle {};
struct Running { int ticks; };
struct Done {};
using State = std::variant<Idle, Running, Done>;
State step(const State& s) {
return std::visit([](auto cur) -> State {
using T = decltype(cur);
if constexpr (std::is_same_v<T, Idle>) return Running{0};
else if constexpr (std::is_same_v<T, Running>) return Done{};
else return cur; // Done 是终态
}, s);
}演示把状态机表达成"状态取值 + 转移函数":所有迁移集中在一处,状态数据随 variant 携带。
面试怎么问:状态模式相比一大堆 if/else 好在哪? → 把每种状态的行为与迁移局部化、可穷尽检查;状态少时可退化为 variant + 查表,避免继承与堆分配。
6. 模板方法 → NVI(非虚接口)
一句话:公开接口非虚、可定制点保护虚——接口稳定,扩展可控。
老写法 → 现代写法
- 老:把
virtual方法直接设成 public,子类可任意重写并绕过前置/后置逻辑,基类无法保证"钩子一定在框架内执行"。 - 现代:NVI(Non-Virtual Interface)——public 成员是非虚的,负责固定的前后置与调度;真正可定制的是
protected virtual的do_xxx()。这样接口对外稳定、变更不影响调用方,且基类能强制"进入/退出"的公共步骤。
小例子
cpp
class Pipeline {
public:
void run() { // 非虚:固定流程,子类不可改
setup();
work(); // 唯一可定制点
teardown();
}
virtual ~Pipeline() = default;
protected:
virtual void work() = 0; // 子类只实现这一步
void setup() { /* 前置,不可改 */ }
void teardown() { /* 后置,异常安全清理 */ }
};演示 NVI:对外只暴露稳定的 run(),把"可变的实现细节"限制在 protected 缺口里。
面试怎么问:为什么有的库把 public 接口设为非虚、却提供 protected virtual? → NVI:固定框架流程、只开放定制点,保证前置/后置必然执行,接口更稳定、可演进。
7. 迭代器 / 范围(Iterator + Range)
一句话:把"如何遍历"与"做什么"解耦;现代直接用 ranges 管道替代手写循环。
老写法 → 现代写法
- 老:手写
for循环 + 索引/迭代器,配合临时vector接中间结果,容易写出越界、易漏std::back_inserter。 - 现代:用 C++20 ranges——
views::filter、views::transform、views::take惰性组合,std::ranges::算法直接吃整个 range;中间过程零分配、按需计算,代码即意图。
小例子
cpp
#include <ranges>
#include <vector>
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8};
// 取偶数、平方、前 3 个 —— 无中间容器、无显式循环
auto out = v | std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; })
| std::views::take(3);
int sum = 0;
for (int x : out) sum += x; // 惰性求值,遍历时才计算演示 ranges 管道:读起来是"过滤 → 变换 → 取前 3",且不产生中间临时容器。
回链:Ranges 与视图、迭代器
面试怎么问:views::filter 会不会立刻拷贝数据? → 不会,视图是惰性的、只持有对底层序列的引用,真正的计算发生在遍历时。
8. 责任链 / 中间件(简)
一句话:把处理拆成可插拔的一段段 handler,串成链条依次或短路处理。
老写法 → 现代写法
- 老:硬编码一串
if/else、或用继承写Handler链表,每个 Handler 持next指针,插入/删除都要改结构。 - 现代:用
std::vector<std::function<...>>或一个std::function组合器表达链条(日志、鉴权、网络协议栈都是这个形状);顺序即容器顺序,增删一节只是改容器,短路用返回布尔控制。
小例子
cpp
#include <functional>
#include <string>
#include <vector>
// 每个中间件返回 true 表示"继续向下传递",false 表示"短路结束"
using Handler = std::function<bool(std::string&)>;
void dispatch(const std::vector<Handler>& chain, std::string& msg) {
for (const auto& h : chain) {
if (!h(msg)) return; // 某一节决定拦截,链条终止
}
}
std::vector<Handler> stack = {
[](std::string& m) { m += "|auth"; return true; },
[](std::string& m) { return !m.empty(); }, // 空则短路
[](std::string& m) { m += "|log"; return true; },
};演示责任链/中间件:一串可组合的 handler 顺序执行,用返回值控制是否继续,增删节点只改容器。
面试怎么问:Web 框架的中间件是怎么串起来的? → 一串按顺序执行的 handler,每个决定"放行"或"拦截";可用 std::function 列表表达,必要时用链式组合或洋葱模型实现前后置。
相关
设计思想总览:五大思想簇(资源/原子失败/零开销/正确性前置/工程)抽象不能有代价:运行期多态 vs 编译期多态的成本对照面向对象总览:虚函数、多态、构造析构的考点底座现代 C++ 核心惯用法:本页所用技巧(策略参数、类型擦除、CRTP)的惯用法版本可调用对象:std::function、lambda、函数指针的取舍