二、面向对象设计原则(SOLID)

说到设计原则,我得先坦白一件事。刚入行那几年,我总觉得这些原则是书本上用来唬人的。直到有一次,我接手了一个遗留系统——那个代码库,怎么说呢,就像一团打了死结的毛线球。改一个地方,崩三个地方。从那以后,我才真正明白:原则不是教条,是保命符

SOLID 是五个原则的缩写。它们不是孤立的,而是环环相扣的。你想想看,一个系统如果每个类都职责清晰,自然就容易扩展;能扩展了,子类替换父类才不会出问题;接口设计得够细,依赖关系才能灵活。嗯,咱们一个一个来看。

核心观点:SOLID 原则的本质是管理变化。软件唯一不变的就是变化,这些原则就是帮你把变化的影响控制在最小范围。

SOLID 设计原则 S - 单一职责原则 一个类,一个职责 O - 开闭原则 对扩展开放,对修改关闭 L - 里氏替换原则 子类可替换父类 I - 接口隔离原则 接口要小而专 D - 依赖倒置原则 依赖抽象,不依赖具体 目标:高内聚、低耦合、易扩展、可维护 五个原则相互配合,缺一不可

2.1 单一职责原则(SRP)

单一职责原则,说白了就是:一个类只做一件事。如果一件事需要多个步骤,那就拆成多个类。

我见过最夸张的例子,是一个类叫 OrderManager,里面包含了订单校验、价格计算、库存扣减、发送邮件、生成报表…… 总共三千多行。你想想看,改个邮件模板,都得把整个类重新编译一遍。这合理吗?

判断标准:如果你无法用一句话说清楚这个类的职责,那它大概率违反了 SRP。比如「这个类负责管理订单」——太模糊了。应该说「这个类负责计算订单总价」。

来看个例子。违反 SRP 的写法:

class OrderProcessor {
public:
    void process(const Order& order) {
        // 1. 校验订单
        if (!validate(order)) throw std::runtime_error("Invalid order");
        // 2. 计算价格
        double total = calculateTotal(order);
        // 3. 保存到数据库
        saveToDB(order);
        // 4. 发送通知
        sendEmail(order);
    }
private:
    bool validate(const Order& o) { /* ... */ }
    double calculateTotal(const Order& o) { /* ... */ }
    void saveToDB(const Order& o) { /* ... */ }
    void sendEmail(const Order& o) { /* ... */ }
};

重构之后,各司其职:

class OrderValidator {
public:
    bool validate(const Order& order);
};

class PriceCalculator {
public:
    double calculate(const Order& order);
};

class OrderRepository {
public:
    void save(const Order& order);
};

class NotificationService {
public:
    void sendConfirmation(const Order& order);
};

class OrderProcessor {
public:
    OrderProcessor(OrderValidator* v, PriceCalculator* c,
                   OrderRepository* r, NotificationService* n)
        : validator(v), calculator(c), repo(r), notifier(n) {}
    void process(const Order& order) {
        if (!validator->validate(order)) throw ...;
        double total = calculator->calculate(order);
        repo->save(order);
        notifier->sendConfirmation(order);
    }
private:
    OrderValidator* validator;
    PriceCalculator* calculator;
    OrderRepository* repo;
    NotificationService* notifier;
};

注意:不要过度拆分。如果一个类的职责确实紧密相关(比如一个数据类同时提供 getter 和 setter),强行拆分反而增加复杂度。原则是工具,不是镣铐。

2.2 开闭原则(OCP)

开闭原则说的是:对扩展开放,对修改关闭。意思是,你想加新功能,应该通过写新代码来实现,而不是改老代码。

我曾经在一个项目中,看到同事为了加一种新的支付方式,改了五个文件。改完以后,微信支付和支付宝都崩了。为什么?因为老代码里写满了 if (type == WECHAT) ... else if (type == ALIPAY) ...。每加一种新支付,就得改这个 if-else 链。

正确的做法是用多态:

class PaymentStrategy {
public:
    virtual ~PaymentStrategy() = default;
    virtual bool pay(double amount) = 0;
};

class WechatPay : public PaymentStrategy {
public:
    bool pay(double amount) override {
        // 微信支付逻辑
        return true;
    }
};

class Alipay : public PaymentStrategy {
public:
    bool pay(double amount) override {
        // 支付宝逻辑
        return true;
    }
};

// 新增信用卡支付,不需要改任何已有代码
class CreditCardPay : public PaymentStrategy {
public:
    bool pay(double amount) override {
        // 信用卡逻辑
        return true;
    }
};

class PaymentProcessor {
public:
    void setStrategy(PaymentStrategy* s) { strategy = s; }
    void processPayment(double amount) {
        strategy->pay(amount);
    }
private:
    PaymentStrategy* strategy;
};

你看,加一种新支付方式,只需要新增一个类,完全不用动 PaymentProcessor。这就是「对扩展开放,对修改关闭」。

核心技巧:多用抽象类或接口,少用具体类。把变化点封装起来,让系统在扩展时保持稳定。

2.3 里氏替换原则(LSP)

里氏替换原则,名字听着吓人,其实意思很简单:子类应该能替换父类,而且程序行为不变

我见过一个经典的反例。有个 Rectangle 类,有 setWidth()setHeight()。然后有人写了个 Square 继承它,重写了这两个方法,让宽高始终相等。结果呢?所有依赖 Rectangle 的代码,传入 Square 后就出 bug 了。因为调用者以为可以分别设置宽高,但正方形不允许。

class Rectangle {
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; }
private:
    int width = 0;
    int height = 0;
};

// 违反 LSP:正方形不能独立设置宽高
class Square : public Rectangle {
public:
    void setWidth(int w) override {
        Rectangle::setWidth(w);
        Rectangle::setHeight(w);  // 副作用!
    }
    void setHeight(int h) override {
        Rectangle::setWidth(h);
        Rectangle::setHeight(h);
    }
};

教训:继承不是用来「复用代码」的,而是用来表达「is-a」关系的。如果子类不能完全替代父类的行为,那就别用继承。用组合或者接口。

2.4 接口隔离原则(ISP)

接口隔离原则,说白了就是:接口要小而专,不要大而全。一个类不需要的方法,就不该出现在它的接口里。

我记得有个项目,定义了一个 Worker 接口,里面有 work()eat()sleep()。然后有个 Robot 类实现了它——但机器人不需要吃饭睡觉啊!于是 eat()sleep() 只能写成空实现或者抛异常。这就是典型的「接口污染」。

正确的做法是拆分成多个小接口:

// 拆分成多个专用接口
class Workable {
public:
    virtual void work() = 0;
};

class Eatable {
public:
    virtual void eat() = 0;
};

class Sleepable {
public:
    virtual void sleep() = 0;
};

// 人类需要工作、吃饭、睡觉
class Human : public Workable, public Eatable, public Sleepable {
public:
    void work() override { /* 工作 */ }
    void eat() override { /* 吃饭 */ }
    void sleep() override { /* 睡觉 */ }
};

// 机器人只需要工作
class Robot : public Workable {
public:
    void work() override { /* 工作 */ }
};

实践建议:接口的方法数量控制在 3-5 个以内。如果一个接口超过 7 个方法,大概率需要拆分。

2.5 依赖倒置原则(DIP)

依赖倒置原则,核心就两句话:

  • 高层模块不应该依赖低层模块,两者都应该依赖抽象。
  • 抽象不应该依赖细节,细节应该依赖抽象。

用人话说:面向接口编程,不要面向实现编程

来看个反面例子。一个 NotificationService 直接依赖了 EmailSender

class EmailSender {
public:
    void send(const std::string& msg) {
        // 发送邮件
    }
};

class NotificationService {
public:
    void notify(const std::string& msg) {
        emailSender.send(msg);  // 直接依赖具体类
    }
private:
    EmailSender emailSender;
};

如果哪天想改用短信通知,就得改 NotificationService。这违反了开闭原则。用依赖倒置来修复:

// 抽象接口
class MessageSender {
public:
    virtual ~MessageSender() = default;
    virtual void send(const std::string& msg) = 0;
};

// 具体实现
class EmailSender : public MessageSender {
public:
    void send(const std::string& msg) override {
        // 发送邮件
    }
};

class SmsSender : public MessageSender {
public:
    void send(const std::string& msg) override {
        // 发送短信
    }
};

// 高层模块依赖抽象
class NotificationService {
public:
    explicit NotificationService(MessageSender* sender)
        : sender(sender) {}
    void notify(const std::string& msg) {
        sender->send(msg);
    }
private:
    MessageSender* sender;  // 依赖抽象,不依赖具体
};

依赖注入:实现 DIP 的常用手段。把依赖通过构造函数、setter 或接口参数传进来,而不是在类内部 new 出来。这样,换一种实现只需要换一个参数,不用改类本身。

2.6 五个原则的协作关系

这五个原则不是孤立的。我总结一下它们怎么配合:

原则 解决的问题 与其他原则的关系
SRP 类职责过多,修改影响范围大 职责清晰后,更容易实现 OCP
OCP 修改老代码引入回归 bug 需要 LSP 保证子类替换安全
LSP 继承滥用导致行为异常 正确的继承关系是 OCP 的基础
ISP 接口臃肿,实现类被迫实现无用方法 小接口更容易满足 SRP
DIP 高层直接依赖低层,耦合紧密 依赖抽象后,OCP 和 ISP 自然成立

我的个人习惯:写代码前先画个简单的类图,看看依赖方向。如果发现箭头从高层指向低层具体类,就停下来想想——能不能加个接口把它反过来?这个习惯帮我避免了很多后期重构的麻烦。

嗯,SOLID 原则就讲到这里。这些原则不是一天就能掌握的,需要在实践中慢慢体会。你写代码的时候,多问自己一句:「如果需求变了,我改哪里?影响多大?」——答案会告诉你,该不该用这些原则。


公众号:蓝海资料掘金营,微信 deep3321