Skip to content

行为型模式(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 列表表达,必要时用链式组合或洋葱模型实现前后置。

相关 ​

糌粑的面经笔记 · VitePress 版