← 返回博客列表
😆 开场段子
"你写的代码怎么又改崩了?"
"因为产品改了需求……"
"上次也是这个理由,上上次也是!"
"没办法,我们的代码就像搭积木——随便抽一块整座楼都塌。"
——如果你听过类似对话,说明你的代码正在"违反设计原则",这篇文章就是解药。

一、为什么需要设计原则?

GoF 23 种设计模式不是凭空想出来的,它们每一个都是在反复解决同样的"坏代码味道"后,总结出的成熟套路。而 7 大设计原则,则是设计模式背后的设计模式——是所有设计模式都在共同遵守的底层思想。

7大原则 → 23种模式 → 具体代码(层级关系) 第一层:7大设计原则(思想) SRP · OCP · LSP · ISP · DIP · LoD · CARP —— 稳定不变 第二层:23种设计模式(套路) 创建型 · 结构型 · 行为型 —— 有限集合 项目A 代码 变化无穷 项目B 代码 变化无穷 项目C 代码 变化无穷 代码千变万化,但底层设计原则稳定不变——先懂原则,再学模式事半功倍
图 1:7大原则、23种模式、具体代码三层关系

7 大原则中,前 5 个合起来叫 SOLID 原则(取每个原则英文名首字母缩写),另外两个"迪米特法则"和"合成复用原则"是对 SOLID 的重要补充。整体记忆口诀:"SOLID + 少说话 + 多用组合"。

🎯 一句话记住 7 大原则
1. SRP(单一):一个类/函数只干一件事
2. OCP(开闭):扩展开放,修改关闭
3. LSP(里氏):子类必须能替换父类不崩
4. ISP(隔离):接口越小越好,别强迫实现用不上的方法
5. DIP(倒置):依赖抽象,不要依赖具体实现
6. LoD(迪米特):只和直接朋友说话,不要深挖链条
7. CARP(合成复用):优先用组合,少用继承

二、单一职责原则(Single Responsibility Principle,SRP)

SRP SOLID · 拆分派

定义:一个类、一个模块、一个函数,只应该有一个引起它变化的原因。

换句话说:"一类一事,一函一责"。如果一个类里同时揉了业务逻辑、文件读写、UI 展示,那么任何一个需求变动都会让这个类"牵一发而动全身"。

2.1 反例:把所有逻辑写进一个类的反面教材

❌ 坏味道:User 类既管业务字段又管存储又管展示
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) { /* ... */ }
};
❌ 违反 SRP:一个类承担 4 种职责,任何变动都可能伤及无辜 class User ① validateEmail ② saveToMySQL ③ renderHtml ④ sendEmail 需求改:数据校验规则变 → 需求改:换 PostgreSQL 需求改:换 Vue 前端 需求改:换企业微信通知
图 2:违反 SRP 的"上帝类"——4 个变化方向互相干扰

2.2 正例:按职责拆分 4 个类

✅ 正确做法: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 {};
🎯 SRP 记忆口诀
"如果一个类里有两个完全不相关的 #include,说明它大概率违反 SRP。"
比如既 include QSqlDatabase 又 include QWebEngineView 的 User 类——这两个根本不是一个世界的东西。

三、开闭原则(Open/Closed Principle,OCP)

OCP SOLID · 扩展派

定义:软件实体(类、模块、函数)应该对扩展开放,对修改关闭。

换句话说:新增功能应该通过"加新代码"实现,而不是"改老代码"。因为改老代码永远有"把好代码改崩"的风险。

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 正例:抽象接口 + 多态实现,扩展不修改

✅ 符合 OCP:新增支付方式只加新类,不改老代码 interface IPaymentStrategy calcFee(amount): double WeChatPay × 0.3% AliPay × 0.25% UnionPay × 0.4% + Apple Pay 只加新类 ✓ 调用方(calculateServiceFee 的调用者)不需要任何修改
图 3:OCP 的策略模式——依赖抽象接口,扩展只需新增派生类
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 的灵魂是"抽象"
OCP 不是永远不改老代码,而是把"频繁变化的维度"隔离在抽象接口背后。支付费率、折扣算法、文件格式、数据源……这些都是"会变的东西",用抽象接口包装后,新增只是"插一个新插件"。

四、里氏替换原则(Liskov Substitution Principle,LSP)

LSP SOLID · 继承派

定义:任何基类(父类)可以出现的地方,子类一定可以无差别地替换出现,且不会产生错误或异常。

直白点:"儿子必须能替爹干活,而且干得不崩"。如果代码中需要用 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 里的断言崩了
}
❌ Square 继承 Rectangle 会在 g() 中把断言改崩 Rectangle 5 × 4 = 20 ✓ vs Square 被改成 4×4 = 16 ✗ 调用方 g(r) 的操作序列: 1. r.setWidth(5) 2. r.setHeight(4) 3. assert(r.area() == 20) Square 替换后断言失败! 结论:数学上的 IS-A ≠ OOP 里的继承关系。LSP 要求"行为契约"(前置/后置条件)也被继承。
图 4:经典 LSP 反例——Square 继承 Rectangle 改变了 setter 的语义契约

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 与"继承"深度绑定
任何在子类中"削弱父类承诺的行为"(比如缩小输入范围、修改语义、抛新的异常、偷偷改内部状态)都算违反 LSP。继承前先问自己:"这个子类在调用方代码里,能完全替换父类对象吗?"

五、接口隔离原则(Interface Segregation Principle,ISP)

ISP SOLID · 拆分派

定义:客户端不应该被强迫依赖它用不到的接口方法。接口越小、越专业越好。

类比:"你做了一个有 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 {}
};
🎯 ISP 记忆口诀
"接口要小而精,不要大而全。"
设计类接口时,判断每一个方法:"所有调用者都真的需要这个方法吗?" 只要有一个实现类会抛"不支持",就要拆接口。

六、依赖倒置原则(Dependency Inversion Principle,DIP)

DIP SOLID · 倒置派

定义:① 高层模块不应依赖低层模块,二者都应依赖其抽象。② 抽象不应依赖细节,细节应依赖抽象。

直白点:"你应该依赖插座(接口),而不是依赖某个品牌的具体插头。"。换句话说:代码里 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

✅ DIP:依赖方向"倒置"——业务层不再直接碰细节实现 高层模块 ReportService (业务逻辑:稳定不常变) → 依赖 抽象接口 IDataReader (中间层:不随业务/细节变动) ← 被依赖 CSVFileReader (细节实现:会变) XlsxFileReader (新增扩展:不影响 ReportService) DatabaseReader (未来扩展:依然不影响业务) DIP 的"倒置":依赖方向从"高层→低层"变成"高层→抽象←低层"
图 5:DIP 前后依赖方向对比——抽象层在中间承接双方
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 的最佳拍档:依赖注入(DI)
DIP 说"应该依赖抽象",而 依赖注入是落地 DIP 的技术手段。有三种常见注入方式:构造器注入(如上面代码)、Setter 注入、接口注入。

在 Qt 中,std::unique_ptr/QScopedPointer + 接口 + 工厂类是最常用的 DIP 落地组合。

七、迪米特法则(Law of Demeter,LoD)

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 这个调用方就得跟着改整条链。

❌ 违反 LoD:调用方 dig 了 4 层链路,任何一层变化都要改调用方 printEmployeeSalary 调用方 (知道了 4 层结构) Company 1. getHRDep HRDept 2. getMgr PayrollMgr 3. getEmp Employee 4. getSalary 正确做法:在 Company 上新增 getEmployeeSalaryById(id) 方法,调用方只问 Company 一个"直接朋友"。
图 6:纸娃娃式"火车对话"违反 LoD,调用方耦合了整条链路

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)

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 正例:把"会飞/会叫"抽成组合能力(策略),鸟类按需组装

✅ CARP:组合优于继承——能力可插拔,不会"继承来不能 fly 的尴尬" 接口 IFlyBehavior fly(): void 接口 IQuackBehavior quack(): void CanFly 会飞的鸟用 CannotFly 企鹅、鸵鸟用 GuaGua 鸭 Silent 哑巴鸟用 class Bird(组合体) 拥有 IFlyBehavior + IQuackBehavior 两个成员(可插拔)
图 7:CARP 的策略模式——把"飞/叫"抽成可插拔组合能力,企鹅用 CannotFly 即可
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 分代码的。

真实项目里,每一次"严格遵守某个原则"的决策,都会有"代码行数多写了很多""架构层级多了一层""小需求要做很多类"的代价。在真实项目里,我们要根据这些现实因素做妥协:

😆 收尾段子
初级程序员:"代码能跑就行,什么原则不原则"
中级程序员:"原则神圣不可侵犯!每个函数必须只有 5 行!"
高级程序员:"先跑起来再说,下一个版本按原则重构"
——三个阶段没有对错,只有阶段不同。原则的意义是让你"有重构的方向感"。

真正厉害的工程师,不是"所有原则都满分执行"的人,而是在"代码可维护性、交付速度、团队理解成本"三者之间找到最优平衡点的人。7 大原则就是这个平衡点的七颗"指北针":

🎯 一句话回顾 7 大原则
SRP(拆分):一类一事
OCP(扩展):加新不改旧
LSP(继承):儿子替爹不出错
ISP(接口):接口要小要专
DIP(倒置):只认插座不认牌
LoD(寡言):只和直接朋友聊
CARP(组合):多用组合少继承