前言
「设计模式的基本原则」这个说法有两层含义,容易混在一起。一层是设计模式本身的原则——GoF(Gang of Four)在《设计模式》一书里归纳的「面向对象设计原则」,比如「针对接口编程,而不是针对实现编程」「优先使用对象组合,而不是类继承」。另一层是后来由 Robert C. Martin 系统化的SOLID 五原则,它是评判「一个设计好不好」的更细的尺子。两层讲的是一件事:模式是结果,原则是原因。
必须先泼一盆冷水:背下 23 个模式的类图和名字,不等于会设计。见过太多项目,一个只有三个类的模块硬生生套上工厂、策略、观察者、装饰器,结果代码量翻了三倍,新人半个月读不懂。模式的初衷是「给反复出现的问题起个名字,方便交流」,不是「必须套用的模板」;原则的意义恰恰是帮你判断什么时候不该用模式。
本文先讲 SOLID,再讲几条模式背后共通的取向,然后讲 C++ 特有的表达手段,最后讲模式分类与误用。代码以 C++17 为基准。
一、SOLID 五原则
| 缩写 | 全称 | 一句话 |
|---|---|---|
| S | 单一职责原则(Single Responsibility Principle) | 一个类只因为一个原因而改变 |
| O | 开闭原则(Open-Closed Principle) | 对扩展开放,对修改关闭 |
| L | 里氏替换原则(Liskov Substitution Principle) | 子类型必须能替换父类型 |
| I | 接口隔离原则(Interface Segregation Principle) | 不强迫客户依赖它不用的接口 |
| D | 依赖倒置原则(Dependency Inversion Principle) | 依赖抽象,不依赖具体实现 |
1.1 单一职责
「一个原因」指的是一个变化来源。判断标准不是「这个类有多少方法」,而是「什么需求变化会逼你改它」。
#include <string> #include <vector> // ❌ 一个类同时管「数据」和「持久化」和「格式化」,三个变化来源 class BadReport { public: void add_row(const std::string& row) { rows_.push_back(row); } void save_to_file(const std::string& path) { (void)path; /* 文件 IO */ } std::string to_html() const { std::string out; for (auto& r : rows_) out += r; return out; } private: std::vector<std::string> rows_; }; // ✅ 拆开:数据与渲染各自独立变化 class Report { public: void add_row(const std::string& row) { rows_.push_back(row); } const std::vector<std::string>& rows() const { return rows_; } private: std::vector<std::string> rows_; }; class ReportRenderer { public: std::string to_html(const Report& r) const; // 渲染细节放实现文件 };拆分的收益不是「好看」,而是「改输出格式时不用重新测文件写入逻辑」。
1.2 开闭原则
新增一种行为时,应该新增代码而不是修改已有代码。C++ 里最常见的实现手段是虚函数和std::function。
#include <iostream> #include <memory> #include <vector> // 多态方式:新增形状只需新增一个派生类 class Shape { public: virtual ~Shape() = default; virtual double area() const = 0; }; class Circle : public Shape { public: explicit Circle(double r) : r_(r) {} double area() const override { return 3.141592653589793 * r_ * r_; } private: double r_; }; class Square : public Shape { public: explicit Square(double s) : s_(s) {} double area() const override { return s_ * s_; } private: double s_; }; double total_area(const std::vector<std::unique_ptr<Shape>>& shapes) { double sum = 0.0; for (const auto& s : shapes) sum += s->area(); return sum; } int main() { std::vector<std::unique_ptr<Shape>> v; v.push_back(std::make_unique<Circle>(1.0)); v.push_back(std::make_unique<Square>(2.0)); std::cout << total_area(v) << '\n'; }「开闭」不是绝对的:完全不修改已有代码是不可能的,它只是一个方向。真正的判断标准是修改的局部性——新需求带来的改动是否被限制在少数几个文件里。
1.3 里氏替换
子类型必须能在不破坏调用方预期的情况下替换父类型。经典反例是「正方形继承长方形」:
class Rectangle { public: virtual ~Rectangle() = default; virtual void set_width(double w) { w_ = w; } virtual void set_height(double h) { h_ = h; } double area() const { return w_ * h_; } protected: double w_ = 0, h_ = 0; }; // ❌ 正方形继承长方形:set_width 会连带改高度,破坏了「改宽不影响高」的隐含契约 class Square : public Rectangle { public: void set_width(double w) override { w_ = w; h_ = w; } void set_height(double h) override { w_ = h; h_ = h; } };一份「先set_width(5)再set_height(4),期望面积 20」的代码,换成Square就得到 16。这不是编译错误,是契约被破坏——LSP 管的就是这类问题。正确做法是让两者都继承自Shape,各自实现area(),而不是让正方形继承长方形的可变接口。
判断 LSP 是否被违反,问三个问题:子类有没有增强前置条件?有没有削弱后置条件?有没有抛出父类不抛的异常?
1.4 接口隔离
不要强迫调用方依赖它用不到的方法。C++ 中常见做法是把「大接口」拆成若干小接口,让实现类只实现相关的部分。
// ❌ 胖接口:只会打印的类也被迫实现 scan 和 fax class BadMultiFunction { public: virtual ~BadMultiFunction() = default; virtual void print() = 0; virtual void scan() = 0; virtual void fax() = 0; }; // ✅ 拆成小接口,按需实现 class IPrinter { public: virtual ~IPrinter() = default; virtual void print() = 0; }; class IScanner { public: virtual ~IScanner() = default; virtual void scan() = 0; }; class SimplePrinter : public IPrinter { // 只实现自己会的 public: void print() override {} };1.5 依赖倒置
高层模块不应该依赖低层模块,两者都应该依赖抽象。这也是「依赖注入」(Dependency Injection)的理论基础。
#include <iostream> #include <memory> #include <string> // 抽象:高层和低层都依赖它 class ILogger { public: virtual ~ILogger() = default; virtual void log(const std::string& msg) = 0; }; // 低层实现 class ConsoleLogger : public ILogger { public: void log(const std::string& msg) override { std::cout << "[console] " << msg << '\n'; } }; class NullLogger : public ILogger { public: void log(const std::string&) override {} // 空实现,方便测试 }; // 高层模块:只依赖 ILogger,构造时注入具体实现 class OrderService { public: explicit OrderService(ILogger& logger) : logger_(logger) {} void place_order(int id) { logger_.log("下单 " + std::to_string(id)); } private: ILogger& logger_; }; int main() { ConsoleLogger console; OrderService svc(console); // 注入 ConsoleLogger NullLogger quiet; OrderService svc2(quiet); // 换实现不用改 OrderService 一行代码 svc.place_order(1); svc2.place_order(2); }注意这里注入的是引用而不是std::unique_ptr,语义是「OrderService不拥有 logger,只借用」。需要拥有时改用std::unique_ptr<ILogger>并在构造函数里std::move进来——这个选择本身就在表达所有权意图。
二、模式背后共通的几条取向
除了 SOLID,还有几条反复出现在各种模式里的取向:
| 原则 | 含义 | 对应模式举例 |
|---|---|---|
| 优先组合而非继承 | 用「持有」代替「是」 | 策略、装饰器、桥接 |
| 针对接口编程 | 依赖抽象类型而非具体类型 | 工厂方法、抽象工厂 |
| 封装变化点 | 把易变的部分隔离出来 | 策略、状态 |
| 信息隐藏 | 只暴露必要接口 | 外观、代理 |
| 降低耦合、提高内聚 | 模块内部紧密相关,模块之间尽量少连 | 中介者、观察者 |
这里要特别说清「优先组合而非继承」。继承是 C++ 里耦合最强的关系:派生类依赖基类的实现细节,基类一改,所有派生类都要重新审视;组合只依赖对方公开的接口,耦合弱得多。经验法则:
- 「是一种」(is-a)且需要多态替换 → 用公有继承。
- 「有一个」或「用一个」(has-a / uses-a) → 用组合。
- 只为了复用实现代码 → 用组合,别用继承。
C++ 还有个额外的坑:公有继承 + 非虚析构 = 通过基类指针删除派生对象时是 UB。这几乎是「继承要谨慎」最硬的论据。
三、C++ 特有的表达手段
C++ 有别的语言没有的工具,用对了能比套模式更干净。
| 手段 | 用途 | 备注 |
|---|---|---|
| RAII | 资源获取即初始化 | C++ 最核心的惯用法,比任何模式都重要 |
| 模板 / CRTP | 编译期多态 | 无虚函数开销,但会增大二进制体积 |
std::function | 运行期行为注入 | 比定义一个策略类层次更轻 |
std::variant | 封闭集合的多态 | 需要 C++17 |
| 智能指针 | 表达所有权 | unique_ptr独占、shared_ptr共享 |
RAII 值得单独强调。与其用「工厂模式 + 手动释放」管理资源,不如让析构函数负责释放:
#include <cstdio> #include <stdexcept> // RAII 封装文件句柄,出作用域自动关闭 class FileHandle { public: explicit FileHandle(const char* path, const char* mode) { fp_ = std::fopen(path, mode); if (!fp_) throw std::runtime_error("打开文件失败"); } ~FileHandle() { if (fp_) std::fclose(fp_); } FileHandle(const FileHandle&) = delete; // 独占所有权 FileHandle& operator=(const FileHandle&) = delete; FileHandle(FileHandle&& o) noexcept : fp_(o.fp_) { o.fp_ = nullptr; } std::FILE* get() const { return fp_; } private: std::FILE* fp_ = nullptr; }; int main() { try { FileHandle f("demo.txt", "w"); std::fputs("hello\n", f.get()); } catch (const std::exception&) { // 异常抛出时 f 的析构仍会执行,文件不会泄漏 } }这段代码一个设计模式都没用,但它解决的问题(资源泄漏)恰恰是很多模式想解决的。能用 RAII 解决的,不要上模式。
std::variant提供了一种「封闭集合的多态」,适合类型集合已知且不需要扩展的场景:用std::visit配合if constexpr在编译期分派,避免了虚函数开销。它需要 C++17 及以上;C++17 之前的替代写法是「带标签的struct+switch」,或者退回虚函数层次。需要 C++20 的写法还可以用concept约束模板参数,C++17 下则只能用std::enable_if表达同样意图。
四、模式的分类与误用
GoF 把 23 个模式分成三类:
| 类别 | 数量 | 关注点 | 例子 |
|---|---|---|---|
| 创建型(Creational) | 5 | 对象怎么创建 | 工厂方法、抽象工厂、单例、建造者、原型 |
| 结构型(Structural) | 7 | 对象怎么组合 | 适配器、桥接、组合、装饰器、外观、享元、代理 |
| 行为型(Behavioral) | 11 | 对象怎么交互、职责怎么分配 | 观察者、策略、命令、状态、模板方法等 |
误用的典型症状:
- 为了模式而模式。只有一种算法也要抽「策略接口」,只有一种产品也要写「抽象工厂」。判据:第二次出现变化时再抽象。
- 单例滥用。单例把全局状态伪装成对象,让测试无法隔离。C++11 起函数局部静态变量的初始化由标准保证线程安全,所以「懒汉单例」可以直接写成函数局部静态变量,但这解决不了全局状态本身的问题。
- 模式层次过深。装饰器套装饰器套装饰器,出错时调试栈看不懂。
- 用继承表达「复用代码」而不是「是一种」。这就是前面说的耦合陷阱。
常见坑点
坑点 1:为将来可能的需求提前抽象。
// ❌ 只有一种数据库,却先写好抽象工厂 + 三个产品族 // class IDatabaseFactory { ... }; class MySqlFactory { ... };// ✅ 先用具体实现,等第二种实现真的出现再抽象 class UserRepository { public: void save(int id); // 内部直接用具体实现 };坑点 2:多态基类忘了虚析构。
// ❌ 通过基类指针删除派生对象是 UB // class Shape { public: ~Shape() {} };// ✅ 多态基类必须虚析构 class Shape { public: virtual ~Shape() = default; };坑点 3:对象切片(slicing)。
// ❌ 按值传基类,派生类部分被切掉,虚派发也失效 void draw(Shape s); // 只能传 Shape,Circle 的额外成员丢失 std::vector<Shape> v; // 同理// ✅ 传引用或指针;容器装智能指针 void draw(const Shape& s); std::vector<std::unique_ptr<Shape>> v;坑点 4:构造函数里调用虚函数。
// ❌ 基类构造期间派生类尚未构造,虚调用不会派发到派生类 struct Base { Base() { init(); } virtual void init() {} }; struct Derived : Base { void init() override {} }; // 这里不会被调用// ✅ 两阶段初始化:对象构造完再由调用方显式调用 struct Base2 { Base2() = default; virtual void init() {} }; struct Derived2 : Base2 { void init() override {} }; // 调用方:Derived2 d; d.init();坑点 5:单例在多线程下的初始化。
// ❌ 手写的懒汉单例,两个线程可能同时进入 if,构造两次 // static Singleton* inst = nullptr; // if (!inst) inst = new Singleton();// ✅ C++11 起,函数局部静态变量的初始化由标准保证线程安全(Magic Static) Singleton& instance() { static Singleton inst; return inst; }坑点 6:把std::function用在性能敏感的路径上。
std::function有类型擦除开销,对小函数对象还可能发生堆分配(取决于实现)。在调用极频繁的热路径上,改用虚函数或模板参数往往更合适。具体开销随实现与优化等级变化,请自行基准测试。
坑点 7:接口返回裸指针表达所有权。
// ❌ 调用方不知道要不要 delete,泄漏或 double free 二选一 // Widget* create_widget();// ✅ 用智能指针把所有权写进类型 std::unique_ptr<Widget> create_widget(); // 独占 std::shared_ptr<Widget> shared_widget(); // 共享 Widget* borrow_widget(); // 明确是借用,不负责释放坑点 8:继承一个没有虚析构的第三方类。
// ❌ 如果基类是你无法修改的库类型,公有继承 + 多态删除有风险 // class MyLogger : public ThirdPartyLogger { ... };// ✅ 改成组合 class MyLogger { public: void log(const std::string& s) { inner_.log(s); } private: ThirdPartyLogger inner_; };总结
| 原则 | 一句话 | 反面症状 |
|---|---|---|
| 单一职责 | 一个类一个变化原因 | 改一处需求要动三个文件 |
| 开闭 | 扩展新代码而非改旧代码 | 每加一种类型就改switch |
| 里氏替换 | 子类可无痛替换父类 | 子类改了父类的隐含契约 |
| 接口隔离 | 不依赖用不到的方法 | 空实现的「假方法」一堆 |
| 依赖倒置 | 依赖抽象而非具体 | 高层include了低层实现头 |
| 组合优于继承 | 「有一个」用组合 | 继承层次为了复用代码而建 |
设计模式是词汇表,不是施工图。它的价值在于让你和同事说一句「这里是策略模式」就能达成共识。真正决定代码好坏的是那几条原则:职责是否清晰、变化是否被隔离、依赖是否指向抽象、资源是否自动释放。想清楚这些,模式往往自己就浮现出来;反过来先选模式再找问题,只会得到一堆看起来很专业、改起来很痛苦的代码。