目录
"你写的代码怎么又改崩了?"
"因为产品改了需求……"
"上次也是这个理由,上上次也是!"
"没办法,我们的代码就像搭积木——随便抽一块整座楼都塌。"
——如果你听过类似对话,说明你的代码正在"违反设计原则",这篇文章就是解药。
一、为什么需要设计原则?
GoF 23 种设计模式不是凭空想出来的,它们每一个都是在反复解决同样的"坏代码味道"后,总结出的成熟套路。而 7 大设计原则,则是设计模式背后的设计模式——是所有设计模式都在共同遵守的底层思想。
7 大原则中,前 5 个合起来叫 SOLID 原则(取每个原则英文名首字母缩写),另外两个"迪米特法则"和"合成复用原则"是对 SOLID 的重要补充。整体记忆口诀:"SOLID + 少说话 + 多用组合"。
1. SRP(单一):一个类/函数只干一件事
2. OCP(开闭):扩展开放,修改关闭
3. LSP(里氏):子类必须能替换父类不崩
4. ISP(隔离):接口越小越好,别强迫实现用不上的方法
5. DIP(倒置):依赖抽象,不要依赖具体实现
6. LoD(迪米特):只和直接朋友说话,不要深挖链条
7. CARP(合成复用):优先用组合,少用继承
二、单一职责原则(Single Responsibility Principle,SRP)
定义:一个类、一个模块、一个函数,只应该有一个引起它变化的原因。
换句话说:"一类一事,一函一责"。如果一个类里同时揉了业务逻辑、文件读写、UI 展示,那么任何一个需求变动都会让这个类"牵一发而动全身"。
2.1 反例:把所有逻辑写进一个类的反面教材
class User { public: QString name; QString email; // 职责 1:业务数据校验 bool validateEmail() { /* ... */ } // 职责 2:数据库存储(耦合了 MySQL) bool saveToMySQL(const QString& db) { /* 直接拼接 SQL */ } // 职责 3:前端页面渲染(耦合了 HTML 展示) QString renderAsHtmlProfilePage() { /* ... */ } // 职责 4:发送通知邮件(耦合了 SMTP) bool sendActivationEmail(const QString& smtpServer) { /* ... */ } };
2.2 正例:按职责拆分 4 个类
// ① 纯数据类(DTO):只有一个变化原因——业务字段 struct UserDto { QString name; QString email; }; // ② 校验器:只负责校验规则 class UserValidator { public: bool validateEmail(const UserDto& u); }; // ③ 仓储层:只负责存储,依赖抽象接口 class IUserRepository { public: virtual ~IUserRepository() = default; virtual bool save(const UserDto&) = 0; }; class MySQLUserRepository : public IUserRepository { /* 具体实现 */ }; class PgUserRepository : public IUserRepository { /* 新增也不影响其他类 */ }; // ④ 通知服务:只负责通知 class INotifier { public: virtual ~INotifier() = default; virtual bool sendActivation(const UserDto&) = 0; }; class EmailNotifier : public INotifier {}; class WeComNotifier : public INotifier {};
"如果一个类里有两个完全不相关的
#include,说明它大概率违反 SRP。"比如既 include
QSqlDatabase 又 include QWebEngineView 的 User 类——这两个根本不是一个世界的东西。
三、开闭原则(Open/Closed Principle,OCP)
定义:软件实体(类、模块、函数)应该对扩展开放,对修改关闭。
换句话说:新增功能应该通过"加新代码"实现,而不是"改老代码"。因为改老代码永远有"把好代码改崩"的风险。
3.1 反例:用 if-else 堆出来的支付系统
// ❌ 新增支付方式就得进函数里加一个 case double calculateServiceFee(PaymentType type, double amount) { if (type == WECHAT) return amount * 0.003; else if (type == ALIPAY) return amount * 0.0025; else if (type == UNIONPAY) return amount * 0.004; // 下个月新增:Apple Pay / 数字人民币 / 抖音支付…… // 每次都改这里,分支越来越多,测试覆盖越来越难 }
3.2 正例:抽象接口 + 多态实现,扩展不修改
class IPaymentStrategy { public: virtual ~IPaymentStrategy() = default; virtual double calcFee(double amount) const = 0; }; class WeChatPayStrategy : public IPaymentStrategy { public: double calcFee(double amount) const override { return amount * 0.003; } }; // AliPayStrategy / UnionPayStrategy 同理省略 // 上层业务逻辑——稳定不变 double calculateServiceFee(const IPaymentStrategy& strategy, double amount) { return strategy.calcFee(amount); }
OCP 不是永远不改老代码,而是把"频繁变化的维度"隔离在抽象接口背后。支付费率、折扣算法、文件格式、数据源……这些都是"会变的东西",用抽象接口包装后,新增只是"插一个新插件"。
四、里氏替换原则(Liskov Substitution Principle,LSP)
定义:任何基类(父类)可以出现的地方,子类一定可以无差别地替换出现,且不会产生错误或异常。
直白点:"儿子必须能替爹干活,而且干得不崩"。如果代码中需要用 if (typeid(obj) == typeid(Child)) 这种判断来区分处理,基本就是违反了 LSP。
4.1 经典反例:"正方形是不是长方形"
数学上正方形 IS-A 长方形;但 OOP 中 Square 继承 Rectangle 一定违反 LSP。为什么?看代码:
class Rectangle { protected: double w, h; public: virtual ~Rectangle() = default; virtual void setWidth(double v) { w = v; } virtual void setHeight(double v) { h = v; } double area() const { return w * h; } }; class Square : public Rectangle { public: // 正方形要保持宽 == 高,所以两个 setter 都会同时改另一个值 void setWidth(double v) override { w = h = v; } void setHeight(double v) override { w = h = v; } }; // ⚠️ 调用方函数(假设它只针对 Rectangle 写的逻辑) void g(Rectangle& r) { r.setWidth(5); r.setHeight(4); bool ok = (r.area() == 20); // 对 Rectangle 成立,对 Square 不成立! // Square 被 setHeight(4) 改成 4×4 = 16!g 里的断言崩了 }
4.2 正解:不要继承,改成组合或独立类
// ✅ 让 Rectangle 和 Square 各自独立,或使用接口 Shape class Shape { public: virtual ~Shape() = default; virtual double area() const = 0; }; class Rectangle : public Shape { double w, h; public: Rectangle(double a, double b) : w(a), h(b) {} double area() const override { return w * h; } }; class Square : public Shape { double side; public: explicit Square(double s) : side(s) {} double area() const override { return side * side; } };
任何在子类中"削弱父类承诺的行为"(比如缩小输入范围、修改语义、抛新的异常、偷偷改内部状态)都算违反 LSP。继承前先问自己:"这个子类在调用方代码里,能完全替换父类对象吗?"
五、接口隔离原则(Interface Segregation Principle,ISP)
定义:客户端不应该被强迫依赖它用不到的接口方法。接口越小、越专业越好。
类比:"你做了一个有 500 功能的大插头,但我的插座只用了 5 个功能——ISP 就是把它拆成 10 个功能专一的小插头。"
5.1 反例:"万能"IMachine 接口
class IMachine { public: virtual ~IMachine() = default; virtual void print() = 0; virtual void scan() = 0; virtual void fax() = 0; }; // ❌ 但我的打印机只有 print 功能! class MyCheapPrinter : public IMachine { public: void print() override { /* OK */ } void scan() override { throw std::logic_error("This printer cannot scan!"); } void fax() override { throw std::logic_error("This printer cannot fax!"); } };
这就违反 ISP 了——MyCheapPrinter 被强迫实现了自己根本不需要的 scan() 和 fax(),调用方如果不小心调用这些方法,就会在 运行时崩溃。
5.2 正例:拆成 3 个细粒度接口
class IPrinter { public: virtual ~IPrinter() = default; virtual void print() = 0; }; class IScanner { public: virtual ~IScanner() = default; virtual void scan() = 0; }; class IFax { public: virtual ~IFax() = default; virtual void fax() = 0; }; // ✅ 便宜打印机:只需实现 IPrinter class MyCheapPrinter : public IPrinter { public: void print() override { /* OK */ } }; // ✅ 全能机:实现三个接口 class AllInOne : public IPrinter, public IScanner, public IFax { public: void print() override {} void scan() override {} void fax() override {} };
"接口要小而精,不要大而全。"
设计类接口时,判断每一个方法:"所有调用者都真的需要这个方法吗?" 只要有一个实现类会抛"不支持",就要拆接口。
六、依赖倒置原则(Dependency Inversion Principle,DIP)
定义:① 高层模块不应依赖低层模块,二者都应依赖其抽象。② 抽象不应依赖细节,细节应依赖抽象。
直白点:"你应该依赖插座(接口),而不是依赖某个品牌的具体插头。"。换句话说:代码里 new 具体类的地方越少,DIP 做得越好。
6.1 反例:高层业务直接依赖具体文件格式
// ❌ 直接依赖具体类 CSVFileReader:改 xlsx 要改 ReportService class CSVFileReader { public: QStringList readFile(const QString& path); }; class ReportService { private: CSVFileReader m_reader; // 直接依赖具体类——倒置之前 public: void generateReport(const QString& csvPath) { QStringList rows = m_reader.readFile(csvPath); // 生成报表…… } };
6.2 正例:中间插一层抽象接口 IDataReader
class IDataReader { public: virtual ~IDataReader() = default; virtual QStringList read(const QString& path) = 0; }; class CSVFileReader : public IDataReader { public: QStringList read(const QString& path) override { /*...*/ } }; class XlsxFileReader : public IDataReader { public: QStringList read(const QString& path) override { /* 新增 */ } }; // ✅ 业务层只依赖抽象 IDataReader,和具体格式解耦 class ReportService { private: IDataReader* m_reader; // 依赖抽象指针 —— 倒置之后 public: explicit ReportService(IDataReader* r) : m_reader(r) {} // 依赖注入 void generateReport(const QString& path) { QStringList rows = m_reader->read(path); // 生成报表…… } };
DIP 说"应该依赖抽象",而 依赖注入是落地 DIP 的技术手段。有三种常见注入方式:构造器注入(如上面代码)、Setter 注入、接口注入。
在 Qt 中,
std::unique_ptr/QScopedPointer + 接口 + 工厂类是最常用的 DIP 落地组合。
七、迪米特法则(Law of Demeter,LoD)
定义:一个模块/对象,应该对其他对象保持最少的了解。只和"直接朋友"说话,不要深挖链。
"直接朋友"只有 4 种:自己本身、方法入参、new 出来的对象、自己的成员。除此之外的都不算朋友。
7.1 反例:典型的"火车对话"
// ❌ 违反 LoD:obj.getA().getB().getC().doSomething() // 俗称"纸娃娃调用"——你得知道 A 有 B,B 有 C,C 有 doSomething() void printEmployeeSalary(const Company& company) { QString name = company .getHRDepartment() // 返回 HR 部门 .getPayrollManager() // 返回薪资经理 .getEmployeeById(1024) // 返回员工 .getSalary() // 返回工资 .toString(); }
这段代码看似顺滑,但问题巨大:一旦 Company 的部门结构、HR 部门的职位层级、Employee 的 API 有任何改动——比如工资变成"员工 -> 工资袋 -> 税后工资"——printEmployeeSalary 这个调用方就得跟着改整条链。
7.2 正例:Company 提供"门面方法",调用方只问直接朋友
// ✅ 符合 LoD:调用方只跟直接朋友 Company 说话 class Company { public: // 门面方法:把复杂链路封在内部,调用方只需一步 QString getEmployeeSalaryById(int id) const { return m_hrDep .payrollManager() .getEmployeeById(id) .salary() .toString(); } }; void printEmployeeSalary(const Company& company) { // 只有一步!调用方对内部结构一无所知 QString name = company.getEmployeeSalaryById(1024); }
八、合成复用原则(Composite/Aggregate Reuse Principle,CARP)
定义:优先使用组合/聚合关系来复用功能,而不是继承关系来复用。
因为继承是"白箱复用"(父类实现细节暴露给子类),组合是"黑箱复用"(被组合对象的细节对外部不可见)。组合耦合度低、灵活、不会触发 LSP 陷阱。
8.1 反例:继承滥用——鸭子飞不飞的古老难题
class Bird { public: virtual void fly() { /* 默认会飞 */ } virtual void quack() { /* 会叫 */ } }; // ❌ 企鹅继承 Bird,但是不会飞! class Penguin : public Bird { public: void fly() override { throw std::logic_error("Penguins cannot fly!"); // 违反 LSP } };
8.2 正例:把"会飞/会叫"抽成组合能力(策略),鸟类按需组装
class IFlyBehavior { public: virtual ~IFlyBehavior() = default; virtual void fly() = 0; }; class CanFly : public IFlyBehavior { public: void fly() override { /* 真正飞的代码 */ } }; class CannotFly : public IFlyBehavior { public: void fly() override { /* 什么都不做 */ } }; class IQuackBehavior { public: virtual ~IQuackBehavior() = default; virtual void quack() = 0; }; // GuaGuaQuack / SilentQuack 同理 class Bird { private: std::unique_ptr<IFlyBehavior> m_fly; // 组合能力 1 std::unique_ptr<IQuackBehavior> m_quack; // 组合能力 2 public: Bird(std::unique_ptr<IFlyBehavior> f, std::unique_ptr<IQuackBehavior> q) : m_fly(std::move(f)), m_quack(std::move(q)) {} void fly() { m_fly->fly(); } void quack() { m_quack->quack(); } }; // ✅ 构造 Penguin:组合"不会飞 + 正常叫",零继承零陷阱 Bird makePenguin() { return Bird( std::make_unique<CannotFly>(), std::make_unique<GuaGuaQuack>()); }
CARP 只是说"优先用组合",不是说"继承一律不准用"。
✅ 该继承:父子类确实是真正的 IS-A,且 LSP 完全成立(比如 Shape→Circle/Rect,Vehicle→Car/Bike)
❌ 不该继承:只是为了复用 1-2 个函数;父子类只是"部分像"但行为契约不完全一致
九、七大原则之间的关系与优先级
7 大原则定位与优先级对比
| 原则 | 它在解决什么问题 | 可以视作谁的补充 | 落地难度 |
|---|---|---|---|
| SRP 单一职责 | 类太大,牵一发动全身 | 基础——所有原则的前提 | ⭐⭐ |
| OCP 开闭 | 新需求一来就改老代码 | 灵魂——设计的终极目标 | ⭐⭐⭐⭐ |
| LSP 里氏替换 | 继承后子类"越俎代庖"改语义 | 继承版 SRP / OCP 的契约 | ⭐⭐⭐ |
| ISP 接口隔离 | 接口太大,实现类被迫写空/抛错 | 接口层面的 SRP | ⭐⭐ |
| DIP 依赖倒置 | 高层直接依赖低层细节,耦合紧 | OCP 的实现手段 | ⭐⭐⭐⭐ |
| LoD 迪米特 | 调用方深挖链,耦合了整条结构 | 组合层的 SRP / ISP | ⭐⭐ |
| CARP 合成复用 | 滥用继承,造成 LSP 陷阱 | 推荐替代继承的 DIP 落地法 | ⭐⭐⭐ |
SRP → ISP → CARP → LSP → OCP → DIP → LoD
先学会拆分(SRP/ISP),再学会用组合不滥用继承(CARP/LSP),然后再追求"扩展不修改"的 OCP 和"DIP 解耦",LoD 是日常习惯。
十、总结:原则是指南针,不是手铐
最后,必须强调一件事:设计原则是用来"做出更好权衡"的,不是用来写 100 分代码的。
真实项目里,每一次"严格遵守某个原则"的决策,都会有"代码行数多写了很多""架构层级多了一层""小需求要做很多类"的代价。在真实项目里,我们要根据这些现实因素做妥协:
- 项目规模:一次性小脚本,怎么快怎么来;百万行大工程,原则要严格
- 团队规模:1 个开发者 vs 100 个开发者,原则的执行强度完全不同
- 变更频率:万年不变的计算函数 vs 频繁加需求的业务模块
- 交付节奏:Demo 先行还是质量先行
初级程序员:"代码能跑就行,什么原则不原则"
中级程序员:"原则神圣不可侵犯!每个函数必须只有 5 行!"
高级程序员:"先跑起来再说,下一个版本按原则重构"
——三个阶段没有对错,只有阶段不同。原则的意义是让你"有重构的方向感"。
真正厉害的工程师,不是"所有原则都满分执行"的人,而是在"代码可维护性、交付速度、团队理解成本"三者之间找到最优平衡点的人。7 大原则就是这个平衡点的七颗"指北针":
SRP(拆分):一类一事
OCP(扩展):加新不改旧
LSP(继承):儿子替爹不出错
ISP(接口):接口要小要专
DIP(倒置):只认插座不认牌
LoD(寡言):只和直接朋友聊
CARP(组合):多用组合少继承