news 2026/10/2 3:44:37

浅谈C++设计模式的基本原则

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
浅谈C++设计模式的基本原则

前言

「设计模式的基本原则」这个说法有两层含义,容易混在一起。一层是设计模式本身的原则——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了低层实现头
组合优于继承「有一个」用组合继承层次为了复用代码而建


设计模式是词汇表,不是施工图。它的价值在于让你和同事说一句「这里是策略模式」就能达成共识。真正决定代码好坏的是那几条原则:职责是否清晰、变化是否被隔离、依赖是否指向抽象、资源是否自动释放。想清楚这些,模式往往自己就浮现出来;反过来先选模式再找问题,只会得到一堆看起来很专业、改起来很痛苦的代码。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 3:44:15

MiniMaxH3+ComfyUI本地漫剧工作流:0基础离线可控生成实战

1. 这不是“AI视频课”&#xff0c;是2026年漫剧创作者的真实工作台重建实录我用MiniMaxH3ComfyUI搭出第一条可商用漫剧流水线&#xff0c;是在去年冬天一个凌晨三点。当时手头只有RTX 3060 12G显卡、一台三年前的笔记本&#xff0c;没买任何订阅服务&#xff0c;没调用任何云端…

作者头像 李华
网站建设 2026/10/2 3:44:12

C#数值计算首选:MathNet.Numerics核心类功能与高效实操指南

写C#的数值计算&#xff0c;我几乎是条件反射地把MathNet.Numerics这个包装进项目里。这习惯大概从2017年就开始了&#xff0c;当时我在做一套工业数据预处理工具&#xff0c;需要频繁处理矩阵求逆、最小二乘拟合、正态分布抽样这类操作。中间也试过自己封装数学函数&#xff0…

作者头像 李华
网站建设 2026/10/2 3:42:01

机器视觉教室照明控制系统:从整室亮灭到按人按区

简介&#xff1a;一份完整的机器视觉教室照明控制系统工程源码包&#xff0c;面向嵌入式视觉、智能硬件方向学习者及课设/毕设开发者。系统基于YOLO算法识别教室内人体位置&#xff0c;并将画面划分为A、B、C、D四个区域&#xff0c;结合环境亮度与开放时间自动控制对应区域灯光…

作者头像 李华
网站建设 2026/10/2 3:41:42

Flutter三方库鸿蒙化适配实战:以dart_proffix_rest为例的完整踩坑记录

把dart_proffix_rest这个库真正跑到鸿蒙系统上&#xff0c;我前后花了大概两周&#xff0c;踩的坑比想象中多得多。这东西是Flutter生态里对接Proffix ERP的REST客户端库&#xff0c;Proffix ERP在德语区制造和贸易企业里用得相当广&#xff0c;API设计得很规范&#xff0c;字段…

作者头像 李华
网站建设 2026/10/2 3:41:42

AI Agent工程落地实操指南:RAG、MCP、Skill与LangGraph协同实践

1. 这不是“又一门AI课”&#xff0c;而是一份Agent工程落地的实操地图你点开这个标题&#xff0c;第一反应可能是&#xff1a;161集&#xff1f;吴恩达&#xff1f;又是那种“学完就能年薪百万”的营销话术吧&#xff1f;我试过太多类似课程——前3集讲神经元&#xff0c;第5集…

作者头像 李华
网站建设 2026/10/2 3:41:10

Paperclip:Node.js+React+OpenClaw端侧AI胶水架构实战

1. 项目概述&#xff1a;Paperclip 不是回形针&#xff0c;而是一个被严重误读的 AI 工程化枢纽“Paperclip”这个词在当前中文技术社区里&#xff0c;正经历一场典型的语义漂移——它早已不是办公桌上那个弯折金属丝的小物件&#xff0c;而是悄然演变成一个指向特定技术栈组合…

作者头像 李华