C++ SOLID 原则(上):单一职责、开闭原则与里氏替换

C++ SOLID 原则(上):单一职责、开闭原则与里氏替换

在面向对象设计和C++架构中,SOLID原则 是构建可维护、可扩展、稳定系统的第一性原理。它们不是语法规则,而是设计层面的"交通法规"。

在你之前全面学习的封装、继承、多态 (语言机制)以及工厂、策略、DI (设计模式)之间,SOLID原则正是承上启下的设计准则。如果说设计模式是"招式",SOLID原则就是"心法"。

本文将深度解析前三个核心原则------单一职责原则(SRP)开闭原则(OCP)里氏替换原则(LSP),并结合C++特性与之前讲解的扩展技术,展示它们如何共同构筑健壮的架构。


一、单一职责原则(SRP):一个类,且仅有一个变更的理由

1. 核心定义

There should never be more than one reason for a class to change.

(一个类应该有且只有一个被修改的原因。)

通俗解读:一个类只负责一项业务职责。如果该类承担了多个职责,当其中一个职责变化时,就可能导致其他不相关职责的功能被破坏。

2. C++ 反面案例:违反SRP的"上帝类"

cpp 复制代码
// ❌ 坏味道:一个类承担了数据存储、报表生成、邮件发送三个职责
class EmployeeManager {
private:
    std::vector<Employee> employees_;

public:
    // 职责1:数据库操作(存储)
    void saveToDatabase() { /* 连接SQL,插入数据 */ }
    
    // 职责2:业务报表(格式化)
    void generateReport() { 
        std::cout << "=== Employee Report ===" << std::endl;
        for (auto& e : employees_) { /* 打印格式 */ }
    }
    
    // 职责3:通知(邮件发送)
    void sendEmailNotifications() {
        // 连接SMTP,发送邮件...
    }
};

问题 :若数据库密码变更,需修改 saveToDatabase,但可能误伤报表逻辑;若测试时不想发邮件,则无法复用此类。

3. 正确重构:按职责拆分为独立模块(呼应"模块化设计")

cpp 复制代码
// ✅ 职责1:仅负责数据持久化
class EmployeeRepository {
public:
    void save(const Employee& emp) { /* 只处理DB */ }
    std::vector<Employee> loadAll() { /* ... */ }
};

// ✅ 职责2:仅负责报表展示
class EmployeeReportGenerator {
public:
    std::string generate(const std::vector<Employee>& employees) {
        // 只负责格式化,不关心数据从哪来,也不发送
        std::ostringstream oss;
        for (const auto& e : employees) oss << e.name() << "\n";
        return oss.str();
    }
};

// ✅ 职责3:仅负责通知
class EmailNotifier {
public:
    void sendReport(const std::string& content, const std::string& to) {
        // 只负责邮件协议,不关心内容来源
    }
};

// 高层组合(依赖注入)
class ReportService {
private:
    EmployeeRepository& repo_;
    EmployeeReportGenerator& generator_;
    EmailNotifier& notifier_;
public:
    void processAndSend() {
        auto emps = repo_.loadAll();
        auto content = generator_.generate(emps);
        notifier_.sendReport(content, "boss@company.com");
    }
};

4. SRP 在扩展技术中的体现

相关技术 体现
模块化设计 SRP是模块划分的根本依据。每个动态库(插件)只能有一个核心职责。
策略模式 每个具体策略类只封装一种算法(如 ZipCompression 只负责压缩)。
依赖注入 DI容器通过构造函数显式声明依赖,强制将"装配"职责从业务类中分离。

二、开闭原则(OCP):对扩展开放,对修改关闭

1. 核心定义

Software entities (classes, modules, functions) should be open for extension, but closed for modification.

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

通俗解读 :当需求变化时,我们应该新增代码 (派生新类、新插件)来实现,而不是修改现有稳定的代码(尤其是底层核心类)。

2. C++ 反面案例:依赖具体类型的"硬编码分支"

cpp 复制代码
// ❌ 坏味道:每新增一种形状,都要修改这个函数(违反OCP)
double calculateTotalArea(const std::vector<void*>& shapes, int types[]) {
    double total = 0;
    for (int i = 0; i < shapes.size(); ++i) {
        if (types[i] == 0) { // Circle
            Circle* c = (Circle*)shapes[i];
            total += 3.14 * c->radius * c->radius;
        } else if (types[i] == 1) { // Rectangle
            Rectangle* r = (Rectangle*)shapes[i];
            total += r->w * r->h;
        }
        // 新增三角形?必须在这里加 else if!修改了稳定代码!
    }
    return total;
}

3. 正确重构:依赖抽象接口(呼应"继承与接口" + "多态")

cpp 复制代码
// ✅ 抽象接口(稳定,永不修改)
class IShape {
public:
    virtual ~IShape() = default;
    virtual double area() const = 0; // 契约稳定
};

// ✅ 具体扩展(只需新增类,无需修改旧代码)
class Circle : public IShape {
    double radius_;
public:
    double area() const override { return 3.14 * radius_ * radius_; }
};

class Rectangle : public IShape {
    double w_, h_;
public:
    double area() const override { return w_ * h_; }
};

// ✅ 新增三角形(仅添加新文件,不触碰现有类)
class Triangle : public IShape {
    double base_, height_;
public:
    double area() const override { return 0.5 * base_ * height_; }
};

// 核心算法(依赖抽象,永久关闭修改)
double calculateTotalArea(const std::vector<IShape*>& shapes) {
    double total = 0;
    for (const auto* s : shapes) total += s->area(); // 运行时多态
    return total;
}

4. OCP 在扩展技术中的终极应用

相关技术 体现
插件机制 OCP是插件的最高信条。宿主程序(Host)对插件接口开放,但对自身核心逻辑修改关闭。新功能只需新增 .dll 实现接口。
工厂模式 工厂返回基类指针。新增产品时,只需添加新工厂派生类,调用方代码(Client)完全无需修改。
策略模式 Context 持有 IStrategy 接口。新增策略只写新类,Contextexecute() 逻辑永恒不变。
模板与泛型 STL算法(如 sort)对比较器(Comparator)开放,但对排序主逻辑关闭。自定义比较器无需修改 sort 源码。

关键细节 :OCP 的实现必须依赖于 抽象(接口/纯虚类)。没有抽象,就没有真正的"关闭"。


三、里氏替换原则(LSP):子类必须能替换父类

1. 核心定义

Subtypes must be substitutable for their base types.

(派生类对象必须能够完全替换基类对象,且程序行为不发生改变。)

通俗解读 :如果某个函数接受 Base*,传入 Derived* 后,函数不应该崩溃、产生错误结果或违反基类的预期约定(前置条件、后置条件、不变量)。

2. C++ 经典反例:正方形继承矩形(违背LSP,引发生物学悖论)

cpp 复制代码
class Rectangle {
protected:
    int width_, height_;
public:
    virtual void setWidth(int w) { width_ = w; }
    virtual void setHeight(int h) { height_ = h; }
    int getWidth() const { return width_; }
    int getHeight() const { return height_; }
    int area() const { return width_ * height_; }
};

// ❌ 看似合理的继承,实则破坏LSP
class Square : public Rectangle {
public:
    void setWidth(int w) override { width_ = height_ = w; }
    void setHeight(int h) override { width_ = height_ = h; }
};

// 测试函数(基类的隐式契约:宽度和高度可独立变化)
void resizeRectangle(Rectangle& r) {
    int w = r.getWidth();
    r.setHeight(w * 2); // 预期:高度变2倍,宽度不变
    // 断言面积是否变为原宽 * (2*原宽) = 2倍面积
    assert(r.area() == w * (2 * w)); 
}

int main() {
    Rectangle rect; 
    resizeRectangle(rect); // 正常通过

    Square sq;
    resizeRectangle(sq);   // ❌ 崩溃!Square的宽高同时变了,面积变成了4倍,断言失败!
}

结论Square 不是一个 Rectangle(行为上不兼容)。在数学上成立,在软件工程中不成立。

3. 正确做法:组合或提取真正的抽象

方案一(推荐):不要用继承,使用组合。

cpp 复制代码
class Square {
private:
    double side_;
public:
    void setSide(double s) { side_ = s; }
    double area() const { return side_ * side_; }
};
// Square 不继承 Rectangle,各用各的,互不干扰。

方案二 :提取更高层抽象接口(两者共同实现 IShape)。

cpp 复制代码
class IShape { virtual double area() const = 0; };
class Rectangle : public IShape { /* 宽高独立 */ };
class Square : public IShape { /* 边长 */ };
// 只要保证 area() 计算正确,LSP 就成立。
// 注意:此时不再有 setWidth/setHeight 的共享契约,避免了行为冲突。

4. LSP 如何守卫"继承与接口"的底线

相关技术 体现
继承设计 LSP是判断"是否该使用公有继承"的唯一标准。如果不满足LSP,强制继承只会带来无尽的Bug(如上述矩形/正方形)。
策略模式 所有具体策略实现同一个接口。替换策略时,必须保证不改变 Context 的业务预期(如排序结果必须升序)。
依赖注入 注入的 Mock 对象(测试用)必须完全符合接口契约,否则单元测试会失败。

四、三者关系:互为依托的"铁三角"

这三个原则不是平行独立的,而是层层递进的逻辑链条

text 复制代码
单一职责(SRP) → 确保类足够小、职责纯,修改原因单一。
       ↓
里氏替换(LSP) → 确保继承体系正确,派生类不会破坏基类契约。
       ↓
开闭原则(OCP) → 基于SRP的独立类和LSP的正确继承,才能实现"新增代码"而非"修改代码"。
  • 没有SRP:一个类承担了过多职责,若为了OCP新增功能,该类会膨胀成"万能类",最终崩溃。
  • 没有LSP:继承关系错误,多态调用(OCP的核心机制)会产生难以追踪的运行时错误,OCP便成了空中楼阁。
  • SRP + LSP = OCP 的基础:只有每个类职责单一、继承关系正确,我们才能放心地通过添加新子类(扩展)来应对需求变化,而不动现有稳定代码。

五、C++ 工程实践检查清单

原则 代码异味(Smell) 重构手段
SRP 类名包含 ManagerHandlerUtil 且函数超过10个;修改一个功能导致另一个功能出Bug。 拆分为 XXXRepositoryXXXServiceXXXPresenter,使用依赖注入组合它们。
OCP 新增功能时需要频繁修改现有的 switch/if-else;静态库每次都要重新链接。 提取纯虚接口(IXXX),使用工厂模式 创建对象,使用策略模式替换算法。
LSP 派生类中抛出基类没有声明的异常;派生类重写方法时,减弱了基类的前置条件(检查变少)或增强了后置条件(要求更高)。 使用组合代替继承;若必须继承,确保派生类不修改基类的非虚接口(NVI,Non-Virtual Interface)契约。

六、总结:从"码农"到"架构师"的思维跃迁

封装、继承、多态 教你怎么用C++写代码 ;而SOLID原则 (SRP、OCP、LSP)教你为什么要这么组织代码

  • SRP 告诉你边界在哪(粒度)。
  • LSP 告诉你地基是否牢固(正确性)。
  • OCP 告诉你如何迎接未来(弹性)。

当你设计一个插件系统 时:接口(抽象)必须满足 LSP,每个插件类必须恪守 SRP,而主框架通过依赖接口实现对扩展开放(OCP)。当你运用工厂+策略时:工厂负责实例化(满足OCP),策略类无状态且行为单一(SRP),所有策略实现同一接口且互相可替换(LSP)。

三者合一,才能让你之前学习的模块化、插件、模板、DI等高级技术真正发挥"1+1>2"的架构威力,构建出经得起时间考验的C++系统。

相关推荐
ysa0510301 小时前
【板子】欧拉序
c++·笔记·算法·深度优先·图论
不负岁月无痕2 小时前
STL -- C++ 红黑树封装 Map 和 Set
java·c语言·开发语言·c++·面试
初学者,亦行者2 小时前
算法设计与分析:动态规划 - TSP问题和0-1背包问题
c++·算法·动态规划
十五年专注C++开发2 小时前
C++之std::numeric_limits介绍
开发语言·c++·limits
拳里剑气3 小时前
C++算法:优先级队列
开发语言·c++·算法·优先级队列
从零开始的代码生活_3 小时前
C++ 模板入门:函数模板、类模板与实例化机制
开发语言·c++·后端
远离UE412 小时前
UE5 compute shader 原子加
开发语言·c++·ue5
C+-C资深大佬12 小时前
C++ 显式类型转换详解:static_cast、dynamic_cast、const_cast、reinterpret_cast
开发语言·c++
luj_176814 小时前
心形曲线轨迹控制三大关键技术
c语言·开发语言·c++·经验分享·算法