前言
设计模式(design pattern)是对反复出现的对象协作问题的可复用解法。经典著作《设计模式》收录了 23 种,本文挑出日常 C++ 项目里出现频率最高的 11 种:创建型的单例、工厂方法、抽象工厂、建造者、原型;结构型的适配器、装饰器、代理;行为型的观察者、策略、模板方法。这个挑选是经验性的,不是标准规定——不同资料列出的「常用 11 种」并不一致。
需要先纠正一个常见误解:设计模式不是「背下来就能用」的代码模板。同一种模式在不同语言里落地方式差别很大,C++ 里还要考虑值语义、所有权、模板替代方案。例如策略模式在 C++ 里常常用一个std::function或模板参数就够了,不必为每种策略写一个类;单例模式在现代 C++ 里要特别小心销毁顺序与线程安全。生搬硬套类比生搬硬套函数更容易让代码变复杂。
本文每个模式给出意图、适用场景和一段最小示例,代码基准为 C++17,示例只保留能说明结构的部分,省略日志、错误处理等细节。文末给出一张选择对照表。
一、创建型模式(5 种)
1. 单例(Singleton)
意图:保证一个类只有一个实例,并提供全局访问点。C++11 起,函数内局部静态变量的初始化是线程安全的(标准保证「初始化只发生一次」,主流实现用加锁或原子指令实现),因此 Meyers 单例是推荐写法:
// 适用于 C++17
class Config {
public:
static Config& instance() {
static Config inst;
return inst;
}
int port = 8080;
private:
Config() = default;
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
};删除拷贝构造与拷贝赋值,防止Config c = Config::instance();产生第二个对象。缺点也很明确:它本质是全局状态,难以替换、难以测试,析构顺序在跨翻译单元时容易出问题。能用依赖注入(把对象传进构造函数)就不要用单例。
2. 工厂方法(Factory Method)
意图:把「创建哪个具体类型」推迟到子类。调用方只依赖抽象产品与抽象工厂:
// 适用于 C++17
#include <memory>
#include <string>
struct Product {
virtual ~Product() = default;
virtual std::string name() const = 0;
};
struct ProductA : Product {
std::string name() const override { return "A"; }
};
struct Factory {
virtual ~Factory() = default;
virtual std::unique_ptr<Product> create() const = 0;
};
struct FactoryA : Factory {
std::unique_ptr<Product> create() const override {
return std::make_unique<ProductA>();
}
};基类析构必须是virtual,否则通过Factory*删除派生对象是未定义行为。返回std::unique_ptr表达所有权转移,比返回裸指针清晰。
3. 抽象工厂(Abstract Factory)
意图:创建一族相互关联的对象,保证它们风格一致。与工厂方法的区别是「一个工厂造多个产品」:
// 适用于 C++17
#include <memory>
struct Button { virtual ~Button() = default; virtual void paint() const = 0; };
struct CheckBox { virtual ~CheckBox() = default; virtual void paint() const = 0; };
struct GUIFactory {
virtual ~GUIFactory() = default;
virtual std::unique_ptr<Button> make_button() const = 0;
virtual std::unique_ptr<CheckBox> make_checkbox() const = 0;
};新增一整套主题只需新增一个工厂实现;但新增一种产品(例如Slider)要改所有工厂,这是它的固有代价。
4. 建造者(Builder)
意图:把复杂对象的「构造过程」与「表示」分离,用链式调用逐步配置:
// 适用于 C++17
#include <map>
#include <string>
#include <utility>
struct Request {
std::string url;
std::map<std::string, std::string> headers;
int timeout_ms = 1000;
};
class RequestBuilder {
public:
RequestBuilder& url(std::string value) {
request_.url = std::move(value);
return *this;
}
RequestBuilder& header(std::string key, std::string value) {
request_.headers.emplace(std::move(key), std::move(value));
return *this;
}
RequestBuilder& timeout(int ms) {
request_.timeout_ms = ms;
return *this;
}
Request build() const { return request_; }
private:
Request request_;
};当构造函数参数超过四五个、且多数有默认值时,建造者比一长串位置参数可读得多。若参数很少,直接聚合初始化更简单。
5. 原型(Prototype)
意图:通过克隆已有对象来创建新对象,避免知道具体类型,也避免重复昂贵的初始化:
// 适用于 C++17
#include <memory>
struct Shape {
virtual ~Shape() = default;
virtual std::unique_ptr<Shape> clone() const = 0;
virtual void draw() const = 0;
};
struct Circle : Shape {
double radius = 1.0;
std::unique_ptr<Shape> clone() const override {
return std::make_unique<Circle>(*this);
}
void draw() const override {}
};注意返回类型必须是std::unique_ptr<Shape>,不能写成std::unique_ptr<Circle>——虚函数的返回类型协变只支持指针和引用,模板实例之间没有协变关系。
二、结构型模式(3 种)
6. 适配器(Adapter)
意图:把一个类的接口转换成调用方期望的另一个接口。下面的适配器把只接受 C 字符串的老接口包装成std::string接口:
// 适用于 C++17
#include <string>
class LegacyPrinter {
public:
void print_old(const char* text) { (void)text; }
};
class Printer {
public:
virtual ~Printer() = default;
virtual void print(const std::string& text) = 0;
};
class PrinterAdapter : public Printer {
public:
explicit PrinterAdapter(LegacyPrinter& legacy) : legacy_(legacy) {}
void print(const std::string& text) override {
legacy_.print_old(text.c_str());
}
private:
LegacyPrinter& legacy_;
};适配器持有引用还是值,取决于被适配对象的生命周期。引用成员要求被适配对象活得比适配器久,跨作用域传递时容易悬垂。
7. 装饰器(Decorator)
意图:在不修改原类的前提下,动态地给对象叠加职责。关键是装饰器与被装饰对象实现同一接口,并持有一个内部对象:
// 适用于 C++17
#include <memory>
#include <string>
class Notifier {
public:
virtual ~Notifier() = default;
virtual void send(const std::string& message) = 0;
};
class LoggingNotifier : public Notifier {
public:
explicit LoggingNotifier(std::unique_ptr<Notifier> inner)
: inner_(std::move(inner)) {}
void send(const std::string& message) override {
// 这里可以先记录日志
inner_->send(message);
}
private:
std::unique_ptr<Notifier> inner_;
};装饰器可以层层嵌套,顺序由组合方式决定。与继承相比,它避免了「每个功能组合都写一个子类」的组合爆炸。
8. 代理(Proxy)
意图:为另一个对象提供替身,以控制访问。常见变体有远程代理、保护代理、虚代理(延迟加载)。下面是最简单的虚代理:
// 适用于 C++17
#include <memory>
#include <string>
#include <utility>
class Image {
public:
virtual ~Image() = default;
virtual void display() = 0;
};
class RealImage : public Image {
public:
explicit RealImage(std::string path) : path_(std::move(path)) {}
void display() override { /* 加载并显示 */ }
private:
std::string path_;
};
class ImageProxy : public Image {
public:
explicit ImageProxy(std::string path) : path_(std::move(path)) {}
void display() override {
if (!real_) {
real_ = std::make_unique<RealImage>(path_);
}
real_->display();
}
private:
std::string path_;
std::unique_ptr<RealImage> real_;
};代理与装饰器的结构几乎一样,区别在意图:装饰器是「增加行为」,代理是「控制访问」。结构相同而意图不同,正是设计模式容易被误用的地方。
三、行为型模式(3 种)
9. 观察者(Observer)
意图:定义一对多依赖,当主题状态变化时通知所有观察者。示例用裸指针保存观察者,因此必须保证观察者在主题之前注销或销毁:
// 适用于 C++17
#include <algorithm>
#include <vector>
class Observer {
public:
virtual ~Observer() = default;
virtual void on_event(int value) = 0;
};
class Subject {
public:
void subscribe(Observer* observer) { observers_.push_back(observer); }
void unsubscribe(Observer* observer) {
observers_.erase(
std::remove(observers_.begin(), observers_.end(), observer),
observers_.end());
}
void notify(int value) {
for (Observer* observer : observers_) {
observer->on_event(value);
}
}
private:
std::vector<Observer*> observers_;
};通知过程中若观察者调用unsubscribe,正在遍历的vector会被修改,迭代器失效。稳妥做法是先拷贝一份观察者列表再遍历,或者用「标记删除 + 通知后清理」。
10. 策略(Strategy)
意图:把可互换的算法封装成独立对象,让算法的变化独立于使用它的客户端:
// 适用于 C++17
#include <memory>
class Discount {
public:
virtual ~Discount() = default;
virtual double apply(double price) const = 0;
};
class NoDiscount : public Discount {
public:
double apply(double price) const override { return price; }
};
class HalfPrice : public Discount {
public:
double apply(double price) const override { return price * 0.5; }
};
class Order {
public:
explicit Order(std::unique_ptr<Discount> discount)
: discount_(std::move(discount)) {}
double total(double price) const { return discount_->apply(price); }
private:
std::unique_ptr<Discount> discount_;
};如果策略无状态、逻辑很短,用std::function<double(double)>更轻;如果策略需要在编译期确定并参与内联,用模板参数代替虚函数可以消除间接调用,但会增加实例化数量。
11. 模板方法(Template Method)
意图:在基类固定算法骨架,把可变步骤留给子类。骨架函数通常是非虚的公有函数,调用一组受保护的虚函数:
// 适用于 C++17
class DataExporter {
public:
virtual ~DataExporter() = default;
void run() {
open();
write_header();
write_body();
close();
}
protected:
virtual void open() = 0;
virtual void write_header() = 0;
virtual void write_body() = 0;
virtual void close() = 0;
};run()不是虚函数,子类无法改变流程顺序,只能填充步骤,这就是「好莱坞原则」:不要调用我们,我们会调用你。缺点是流程一旦固定,运行时无法调整;需要运行时组合时应当用策略。
四、如何选择
| 模式 | 一句话意图 | 典型信号 | 主要代价 |
|---|
| 单例 | 全局唯一实例 | 配置、日志器 | 全局状态、难测试 |
| 工厂方法 | 子类决定创建类型 | 创建逻辑会扩展 | 类数量增加 |
| 抽象工厂 | 创建一族相关对象 | 多套主题/平台 | 新增产品要改所有工厂 |
| 建造者 | 分步构造复杂对象 | 构造参数多 | 多一个类 |
| 原型 | 克隆现有对象 | 初始化昂贵 | 深拷贝要自己实现 |
| 适配器 | 转换接口 | 接入旧代码/第三方库 | 多一层间接 |
| 装饰器 | 动态叠加职责 | 功能可组合 | 对象层次变深 |
| 代理 | 控制对对象的访问 | 延迟加载、权限 | 多一层间接 |
| 观察者 | 状态变化通知多方 | 事件、UI 刷新 | 生命周期与重入问题 |
| 策略 | 可互换算法 | 多种计费/排序规则 | 虚调用或模板膨胀 |
| 模板方法 | 固定流程、可变步骤 | 导出、解析流水线 | 流程不能运行时改变 |
选择时先问「变化点在哪里」,再问「这个变化需要运行期决定还是编译期决定」。运行期用虚函数或std::function,编译期用模板。若一个问题用普通函数和if就能表达清楚,不要为了「用上模式」而引入类层次。
常见坑点
- ❌ 多态基类析构不写
virtual,却通过基类指针delete。
✅ 基类析构声明为virtual;否则是未定义行为,派生部分不会析构。
- ❌ 单例用
new且从不释放,或依赖跨翻译单元的静态对象初始化顺序。
✅ 用函数内局部静态对象;需要提前销毁时明确管理生命周期,不要假设初始化顺序。
- ❌ 原型
clone()返回std::unique_ptr<Circle>试图协变覆盖基类的std::unique_ptr<Shape>。
✅ 返回类型必须一致;模板实例之间不存在协变。
- ❌ 观察者通知时直接遍历容器,观察者在回调里注销自己。
✅ 先拷贝列表再通知,或延迟删除;遍历中修改vector会使迭代器失效。
- ❌ 装饰器和代理混用概念,给代理叠加了业务功能。
✅ 代理控制访问,装饰器增加职责;结构相同但意图要分清。
- ❌ 策略对象用裸指针保存,调用方销毁后继续使用。
✅ 用std::unique_ptr表达独占所有权,或明确文档化借用语义。
- ❌ 模板方法把
run()也设为virtual,子类可以改流程。
✅ 骨架函数保持非虚;只把步骤设为受保护虚函数。
- ❌ 为了「符合模式」给只有一种实现的接口加抽象基类。
✅ 模式服务于变化;没有第二种实现时,具体类加普通函数更简单。
总结
| 分类 | 模式 | 关键机制 |
|---|
| 创建型 | 单例、工厂方法、抽象工厂、建造者、原型 | 控制对象的创建时机与类型 |
| 结构型 | 适配器、装饰器、代理 | 控制对象之间的组合与访问 |
| 行为型 | 观察者、策略、模板方法 | 控制算法与协作的分派方式 |
这 11 种模式的共同点是「把变化隔离到一处」:创建型隔离类型选择,结构型隔离接口与组合,行为型隔离算法与流程。真正决定代码质量的不是记住多少模式名,而是能否判断变化点在哪、以及这个变化是否值得付出额外的类与间接层。现代 C++ 还提供了模板、std::function、lambda、std::variant等工具,很多传统模式可以用更轻的方式实现;先写最直接的代码,等变化出现再重构,通常比预先套用模式更稳妥。