云计算百科
云计算领域专业知识百科平台

【设计模式精讲】2. 设计原则基石:SOLID 与几条关键原则

【设计模式精讲】设计原则基石:SOLID 与几条关键原则

【摘要】:改一个“报表加水印”的需求,为什么会牵连渲染、导出、存储和测试?问题往往不在语法,而在变化没有被限制在清晰边界内。本文以 C++ 坏代码与改进代码对照,讲清 SOLID 五条原则:单一职责关注变化原因,开闭原则关注扩展点,里氏替换维护行为合同,接口隔离控制依赖面,依赖倒置让业务规则面向抽象;再补充合成复用与迪米特法则。除了“应该怎么做”,文章也会说明每条原则的适用边界、常见误读和相互取舍,避免把原则变成机械拆类或过度设计的借口。

【关键词】:SOLID、单一职责、开闭原则、里氏替换、依赖倒置、C++

1. 改一个需求,为什么炸了七个文件

产品经理说:“报表加个水印,很简单的吧?”

你在 Report 类里改了渲染逻辑,导出 PDF 的代码挂了;修好 PDF,二十个单元测试又红了;改测试时还发现隔壁组依赖这个类的内部调用顺序,他们的模块也被牵连。你没有写错语法,每一行甚至都说得过去,错的是变化可以沿着结构随意扩散。

设计原则要回答的,就是“怎样让变化停在合理的位置”。其中最常被放在一起讨论的是 SOLID:单一职责、开闭、里氏替换、接口隔离和依赖倒置。再加上合成复用与迪米特法则,就构成了阅读后续模式篇的基础工具箱。

模式是可复用的招式,原则是判断招式是否合适的方向。

原则不是法律。它们有时互相拉扯,也会带来额外抽象成本。本文的代码为教学片段;重复的 #include 与无关实现体会适当省略,类型关系则保持完整。

2. S——单一职责原则(SRP)

一句话:一个模块应围绕一个可独立变化的职责组织。

“只做一件事”容易被误解成一个类只能有一个方法。更准确的判断是:哪些需求会由不同的人、在不同时间、因为不同原因而改变?采集规则、渲染格式和存储方式显然不是同一个变化来源,却被塞进了同一个类:

#include <string>

struct Data {};

// ❌ 三种变化挤在一个类里
class Report {
public:
Data collect();
std::string toHtml(const Data& data);
void save(const std::string& content,
const std::string& path);
};

拆分以后,每个类仍然可以有多个相互配合的方法,但它们围绕同一类变化:

class ReportCollector {
public:
Data collect();
};

class HtmlRenderer {
public:
std::string render(const Data& data);
};

class ReportSaver {
public:
void save(const std::string& content,
const std::string& path);
};

加水印只影响 HtmlRenderer;换数据源不会碰存储代码。这里的目标不是“类越小越好”,而是让不同变化拥有不同落点。如果两个职责总是一起改变,强行拆开反而会制造无意义的跳转。

3. O——开闭原则(OCP)

一句话:对预期的扩展开放,让稳定的核心尽量不因新增变体而修改。

第 1 篇的 calcPrice 把订单类型和算法选择写进同一个函数,每增加一种折扣都要修改已有分支。下面的面积计算也有同样问题:

struct BadShape {
enum class Kind { Circle, Rect };
Kind kind;
double r, w, h;
};

double area(const BadShape& s) {
if (s.kind == BadShape::Kind::Circle)
return 3.14159265358979 * s.r * s.r;
if (s.kind == BadShape::Kind::Rect)
return s.w * s.h;
return 0;
}

如果“形状种类会增加”是明确的变化方向,可以把面积计算做成扩展点:

class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
};

class Circle final : public Shape {
public:
explicit Circle(double r) : r_(r) {}
double area() const override {
return 3.14159265358979 * r_ * r_;
}
private:
double r_;
};

class Rect final : public Shape {
public:
Rect(double w, double h) : w_(w), h_(h) {}
double area() const override { return w_ * h_; }
private:
double w_, h_;
};

新增形状时,核心面积接口不必改变,但注册、工厂或组装位置仍可能需要接入新类型。OCP 从来不是“老代码一行都不能改”,也不表示新实现只会影响新路径;共享抽象和集成边界仍要回归测试。它强调的是:先识别真正的变化轴,再稳定已验证的核心。变化尚未出现时就为所有可能性预留接口,同样是过度设计。

还有一种常见误读:只要使用虚函数就符合 OCP。事实上,抽象方向选错以后,继承只会把错误固定得更深。假如系统经常增加的不是形状,而是“导出、统计、绘制”等操作,那么不断增加 Shape 派生类并不能解决新操作需要修改所有类型的问题。开闭原则必须围绕真实变化轴设计,不能脱离业务历史凭空判断。

4. L——里氏替换原则(LSP)

一句话:派生类替换基类后,使用者依赖的行为合同仍然成立。

继承表达的不是名字上的“像”,而是行为上的“可替换”。经典反例是让可变正方形继承可变长方形:

class Rectangle {
public:
virtual ~Rectangle() = default;
virtual void setW(int w) { w_ = w; }
virtual void setH(int h) { h_ = h; }
int area() const { return w_ * h_; }
protected:
int w_ = 0, h_ = 0;
};

class Square : public Rectangle {
public:
void setW(int w) override { w_ = h_ = w; }
void setH(int h) override { w_ = h_ = h; }
};

void resize(Rectangle& r) {
r.setW(3);
r.setH(4);
// Rectangle 的使用者预期面积为 12,
// Square 在这里却得到 16。
}

这里破坏的是“设置宽度不改变高度、设置高度不改变宽度”的行为合同。合同通常包含三部分:派生类不能提出更苛刻的前置条件,必须兑现基类承诺的结果,还要维持基类公开的不变量。

解决方法不一定是换一种继承技巧。可以让 Rectangle 与 Square 成为独立类型,共享一个只提供 area() 的抽象;也可以把几何对象设计成不可变值,通过构造函数一次性建立合法状态。判断 is-a 时,应以调用方可观察的行为为准,而不是照搬现实世界的分类。

LSP 也不只约束返回值。派生类如果突然要求参数不能为空、抛出基类从未承诺的异常,或者悄悄改变资源所有权,同样会破坏替换性。最实用的检查方法,是把原本只针对基类编写的测试原封不动地运行在每个派生类上;任何为了某个子类而加入的特殊分支,都值得重新审视这段继承关系。

5. I——接口隔离原则(ISP)

一句话:不要强迫使用者依赖它用不到的能力。

struct Doc {};

// ❌ 老打印机被迫实现扫描和传真
struct Machine {
virtual ~Machine() = default;
virtual void print(const Doc&) = 0;
virtual void scan(const Doc&) = 0;
virtual void fax(const Doc&) = 0;
};

按角色拆分后,设备只选择真正支持的能力:

struct IPrinter {
virtual ~IPrinter() = default;
virtual void print(const Doc&) = 0;
};

struct IScanner {
virtual ~IScanner() = default;
virtual void scan(const Doc&) = 0;
};

struct MultiMachine : IPrinter, IScanner {
void print(const Doc&) override;
void scan(const Doc&) override;
};

C++ 没有专门的 interface 关键字;在本文的运行时多态写法中,接口通常用以纯虚函数为主、带虚析构函数的抽象基类表达。模板约束和 C++20 Concepts 也能表达编译期接口。无论采用哪种机制,ISP 关注的都是依赖面:派生类出现成片空实现,或调用方因无关方法而重新编译,都是接口可能过胖的信号。

接口也不是拆得越碎越好。如果每个方法都对应一个独立基类,调用方需要同时拼装许多细粒度对象,协作关系反而更难理解。合理粒度通常围绕一个使用者角色:打印客户端依赖打印能力,扫描流程依赖扫描能力,多功能设备可以同时兑现两份合同。

6. D——依赖倒置原则(DIP)

一句话:高层业务规则和低层实现细节都面向稳定抽象。

报警业务如果直接创建 MySQL 日志器,就把“何时报警”与“日志写到哪里”焊在了一起:

class MySQLLogger {
public:
void write(const std::string& text);
};

class BadAlarm {
public:
void trigger() {
MySQLLogger log;
log.write("alarm!");
}
};

修正后的类型关系如下,MySQLLogger 明确实现 ILogger,组装处可以安全地把具体对象交给 Alarm:

#include <memory>
#include <string>
#include <utility>

struct ILogger {
virtual ~ILogger() = default;
virtual void write(const std::string& text) = 0;
};

class MySQLLogger final : public ILogger {
public:
void write(const std::string& text) override;
};

class Alarm {
public:
explicit Alarm(std::shared_ptr<ILogger> log)
: log_(std::move(log)) {}

void trigger() { log_->write("alarm!"); }
private:
std::shared_ptr<ILogger> log_;
};

auto alarm = std::make_shared<Alarm>(
std::make_shared<MySQLLogger>());

“倒置”说的是依赖方向从“高层指向具体低层”变为“高层与低层共同依赖抽象”。构造函数把对象从外部传入,则是依赖注入,它是实现 DIP 的常用手段,但两者不是同一个概念。

示例使用 shared_ptr 表示共享所有权,并不意味着注入都应该使用它。ILogger& 表示 Alarm 不拥有日志器,调用方必须保证其寿命;unique_ptr<ILogger> 表示独占所有权;只有确实需要共同延长生命周期时才使用 shared_ptr。抽象关系和所有权关系都应该明确。

7. 两条关键补充:合成复用与迪米特

7.1 合成复用:优先把对象当零件组合

为了复用 FileLogger::write() 而继承文件日志器,会把时间戳日志器固定成“文件日志器的一种”,并受其全部接口和行为合同约束。更灵活的做法是依赖 ILogger,把任意日志实现组合进来:

class FileLogger : public ILogger {
public:
void write(const std::string& text) override;
};

class TimestampLogger final : public ILogger {
public:
explicit TimestampLogger(
std::unique_ptr<ILogger> inner)
: inner_(std::move(inner)) {}

void write(const std::string& text) override {
inner_->write("[ts] " + text);
}
private:
std::unique_ptr<ILogger> inner_;
};

这里不会发生对象切片,还能在组装时选择文件、数据库或测试日志器。注意,组合本身不自动等于可替换:如果成员按值写死为 FileLogger,仍然只能使用这一种实现。只有依赖抽象并通过引用或智能指针注入,替换能力才真正成立。第 12 篇装饰器和第 16 篇桥接会反复使用这条思路。

7.2 迪米特法则:少了解对象的内部导航结构

// ❌ 调用方知道订单内部连续三层结构
std::string city =
order.customer().address().city();

// ✅ 由订单提供面向业务意图的查询
std::string city = order.customerCity();

迪米特法则常被概括为“只和直接朋友说话”。真正需要警惕的不是链式语法,而是调用方依赖了多个对象之间的内部连接方式。流式 API、值对象查询或标准库迭代器即使有多个点,也不必然违规。判断标准是:中间结构变化时,调用方是否被迫跟着改;封装方法是否真的表达稳定业务意图,而不是简单地搬运一条链。

8. 原则之间如何取舍

原则会带来收益,也会带来成本。为了 OCP 抽出接口,可能增加类型数量;为了 SRP 拆分类,可能让一次简单调用跨越多个对象;为了 DIP 引入间接层,可能影响调试与性能。设计时可以问:变化是否真实发生过?影响是否跨越了无关模块?抽象能否用清楚的名称和测试守住?

如果答案是否定的,保持简单通常更好。如果变化频繁且传播范围不断扩大,就应该主动建立边界。原则的价值不在把所有代码改造成“标准答案”,而在让每个取舍都有可以讨论的理由。

原则核心问题典型相关模式
SRP 一个模块承担了几类变化? 外观、代理
OCP 新变体是否总要修改核心? 工厂方法、装饰器、策略
LSP 派生类是否守住行为合同? 模板方法、工厂方法
ISP 使用者是否依赖无关能力? 适配器、桥接
DIP 业务规则是否依赖具体细节? 工厂、观察者、策略
合成复用 能否用组合替代实现继承? 装饰器、桥接
迪米特 是否泄露了内部协作路径? 外观、中介者

这些是典型关联,不是一一对应关系。一个模式通常同时体现多条原则,也可能为了现实约束有意识地牺牲其中一条。

9. 把原则用于一次真实重构

面对一段已经工作的旧代码,不必一次性把七条原则全部“套”上去。更稳妥的顺序是先找变化,再做最小切割。

第一步,查看最近几次需求和缺陷,标出总被一起修改的文件与函数。它们比想象中的未来需求更能说明真实变化轴。第二步,为当前行为补上测试,尤其是调用方依赖的输入、输出、异常和生命周期约定。没有行为护栏,结构重排很容易把重构变成功能修改。

第三步,只隔离最疼的一类变化:可能是把渲染从报表中拆出,也可能是把具体日志器从报警业务中移走。第四步,检查新边界的依赖方向和所有权,确认抽象没有泄露具体实现,引用与智能指针表达了真实生命周期。第五步,再用本篇七条原则复盘:职责是否围绕变化组织,派生类能否替换,接口是否包含无关能力,调用方是否知道过多内部路径。

这种顺序有两个好处:每一步都有现实问题作为依据,也能随时停在仍然简单的状态。设计原则最适合用来缩小修改范围,而不是把所有代码推向最抽象的形态。

本篇小结

  • SRP 管变化原因,OCP 管扩展点,LSP 管继承合同,ISP 管依赖面,DIP 管依赖方向。
  • 合成复用强调通过抽象组合能力;迪米特法则强调不要泄露内部导航结构。
  • 原则是分析和沟通设计取舍的工具,不是越多越好的评分表。
  • 下一篇将学习 UML,把这些职责、依赖和协作关系画成团队都能读懂的图。
赞(0)
未经允许不得转载:网硕互联帮助中心 » 【设计模式精讲】2. 设计原则基石:SOLID 与几条关键原则
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!