Skip to content

元数据卡

  • 前置知识:第2章(ADT);第5章(重构)
  • 预计时间:45 分钟
  • 核心难度:进阶
  • 完成标志:能用 SOLID 原则评价一段设计的好坏

你的进度

你在工匠之都的工坊里从小件打到大件:单个齿轮 → 齿轮组 → 整台机器。你发现一些问题反复出现:继承层级该多深、零件接口该多细、依赖关系该怎么组织。

这些问题没有唯一答案。但工匠之都的祖师爷们被同样的问题困扰了几十年,总结出几条原则——不遵守也能活,但不遵守的工坊迟早变成没人敢碰的破烂堆。 你的任务

SOLID 是设计原则中最出名的五个字母。但很多人只记得名字,没理解背后的权衡。这章不讲"天哪设计模式多么伟大",而是让你的代码在实际改动中切身体会:为什么好的设计能降低修改成本。你会先感受一段违反原则的代码有多痛,再去看原则怎么止痛。

本章分层

  • 必读:SOLID 五个原则、封装/继承/多态、组合优于继承
  • 选读:Law of Demeter(最少知识原则)
  • 进阶:SOLID 的适用边界——何时可以不遵守

本章不会要求你掌握

  • 23 种 GoF 设计模式(那是下三章的事)

破局 · 溯源

你加入了一个对抗赛系统项目。前一个开发者离职了,他的模块负责"计算比赛奖品"。你看了一眼代码——一个类 PrizeCalculator,一千行,里面 if-else 三十层。改为"冠军得金币",你改了 20 分钟。中间还引入了一个 bug——"亚军奖品是两瓶治疗药水"的规则忘了加。

你知道设计有问题,但说不清楚问题出在哪。

带着这个案例,我们回到设计原则。

第一层:单一职责原则(SRP)

The Single Responsibility Principle

一个类应该只有一个变化的原因。

你的 PrizeCalculator 处理了:奖品类型判断、奖品价值计算、随机掉落率、队伍奖励分摊。四个事情,四个变化原因——奖品类型变了改一次、掉落率改了改一次、分摊逻辑变了改一次、每次改都可能引入 bug。

修复:拆成四个类。

java
// 单一职责:每个类只负责一个变化维度

// 奖品类型
public class PrizeTypeMatcher {
    public PrizeType match(Match match) { ... }
}

// 奖品价值计算
public class PrizeValueCalculator {
    public int calculate(PrizeType type, int baseValue) { ... }
}

// 随机掉落
public class DropRateCalculator {
    public boolean shouldDrop(int rate) { ... }
}

// 队伍分摊
public class TeamSplitter {
    public Map<String, Integer> split(int total, List<String> members) { ... }
}

不一定要拆到每个类只有一个方法——拆的目的是把不同变化速度的逻辑隔离开。奖品价值计算规则可能每赛季改一次,但队内分摊逻辑可能永远不变。把它们放在一个类里,改价值时就得看分摊逻辑,风险放大。

第二层:开闭原则(OCP)

Open/Closed Principle

对扩展开放,对修改关闭。

你的奖品系统现在要支持"特殊的节日活动:冠军得双倍金币"。你发现 PrizeValueCalculator 里需要加一个 if:

java
if (isHolidayEvent) { value *= 2; }

改了已有的代码。这就是违反了开闭原则——新增一个活动类型,你修改了现有的类。理想的做法是扩展而不是修改。

java
// 开闭原则的例子
public interface PrizeBonusRule {
    int apply(int baseValue, Match match);
}

public class HolidayBonusRule implements PrizeBonusRule {
    @Override
    public int apply(int baseValue, Match match) {
        return match.isHoliday() ? baseValue * 2 : baseValue;
    }
}

public class StreakBonusRule implements PrizeBonusRule {
    @Override
    public int apply(int baseValue, Match match) {
        return match.getWinnerStreak() >= 3 ? baseValue + 50 : baseValue;
    }
}

新增一个规则,你写一个新的 PrizeBonusRule 实现,不需要修改已有的计算器。这就是"对扩展开放,对修改关闭"。

第三层:里氏替换原则(LSP)

Liskov Substitution Principle

派生类必须能够替换它们的基类。

当你在代码里用一个 Square 继承 Rectangle 时——你听过这个经典场景吧。

java
// ch07/lsp/Rectangle.java
class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
    public int getArea() { return width * height; }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int w) {
        super.setWidth(w);
        super.setHeight(w);  // 正方形的宽度==高度
    }

    @Override
    public void setHeight(int h) {
        super.setWidth(h);
        super.setHeight(h);
    }
}

看起来合理。但调用方不这么想:

java
void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(10);
    assert r.getArea() == 50;  // Square 进来就 fail
}

调用方根据 Rectangle 的约定,假设 width 和 height 独立——Square 打破了了契约。这是违反 LSP 的典型结果:子类削弱了父类的前提条件

在实际中,LSP 更常见的体现是:做接口设计时,所有实现必须满足接口的文档约定。如果你的接口写"这个方法永远不返回 null",任何一个实现返回了 null,就违反了 LSP。

第四层:接口隔离原则(ISP)

Interface Segregation Principle

客户端不应该被强迫依赖它们不使用的接口。

你的比赛系统有一个接口:

java
interface TournamentService {
    void register(String playerId);
    void start();
    void getResults();
    void sendNotification(String msg);  // 通知功能
    void calculateRankings();
}

如果你的 RegisterOnlyClient 只用了 register,它仍然依赖了 sendNotificationcalculateRankings。下次你改 sendNotification 的签名,RegisterOnlyClient 也得重新编译。

修复:拆分接口。

java
interface RegistrationService { void register(String playerId); }
interface MatchService { void start(); Results getResults(); }
interface NotificationService { void send(String msg); }
interface RankingService { void calculate(); }

调用方只依赖自己需要的接口:

java
class RegistrationController {
    private final RegistrationService service; // 只依赖一个
}

第五层:依赖反转原则(DIP)

Dependency Inversion Principle

高层模块不应该依赖底层模块。两者都应该依赖于抽象。
抽象不应该依赖于细节。细节应该依赖于抽象。

你的 TournamentController 直接用了 PostgresTournamentRepository

java
class TournamentController {
    private PostgresTournamentRepository repo = new PostgresTournamentRepository();
    // 高层次的控制器直接依赖了低层次的具体数据库实现
}

如果某天你从 PostgreSQL 切到 MongoDB,这个控制器必须改代码。

依赖反转的做法:

java
interface TournamentRepository {
    void save(Tournament t);
    Optional<Tournament> findById(String id);
}

class TournamentController {
    private final TournamentRepository repo;  // 依赖抽象

    public TournamentController(TournamentRepository repo) {  // 依赖注入
        this.repo = repo;
    }
}

class PostgresTournamentRepository implements TournamentRepository { ... }
class MongoTournamentRepository implements TournamentRepository { ... }

控制器不再知道底层用的是 PostgreSQL 还是 MongoDB。它只依赖 TournamentRepository 接口——具体实现由外部注入(构造器参数、Spring DI、手动 new)。

你可能会疑惑:"这不就是 Java 的 interface 吗?我一直在用。"但 DIP 的关键在于谁拥有接口的定义。正确的做法是:TournamentRepository 接口定义在领域层(高层),PostgresTournamentRepository(低层)去实现它。不是反过来——低层暴露接口让高层去实现。

第六层:封装、继承、多态——OO 三板斧

封装:把数据和行为包在一起,对外只暴露你允许的操作。

java
class Player {
    private int health;
    
    public void takeDamage(int damage) {
        if (damage < 0) throw new IllegalArgumentException();
        this.health = Math.max(0, this.health - damage);
        // 封装保证了 health 永远不为负
    }
}

继承:is-a 关系。但继承是设计里最强的耦合关系——子类依赖父类的所有细节,包括私有字段的实现。所以组合优于继承(见下)。

多态:同一接口的不同实现。你的 PrizeBonusRuleHolidayBonusRuleStreakBonusRule,调用方不关心是哪个实现。

第七层:组合优于继承

继承的问题是脆弱。父类加一个字段、改一个方法签名——所有子类受影响。

java
// 继承
class Mage extends Adventurer {
    public void castSpell() { super.mana -= 10; }
}
java
// 组合
class Mage {
    private final Adventurer adventurer;
    private final SpellBook spellBook;

    public void castSpell() {
        spellBook.use();
    }
}

组合让类之间的耦合更松散:MageAdventurer,而不是 MageAdventurer。当你需要改 Adventurer 的行为时,组合不会牵连到 Mage 的内部逻辑。

第八层:最少知识原则(Law of Demeter)

一个对象只与它的直接朋友通信。不要调用"链式方法"。
java
// 违反:链式调用了太多不直接的对象
player.getInventory().getWeapon().getDamage();   // 坏

// 好:player 提供了一个方法
player.getWeaponDamage();  // 内部实现,调用方不管

违反 Law of Demeter 的后果是耦合——getInventory() 返回的类型变了,所有链式调用的地方都得改。


常见陷阱

陷阱一:在 getter 里塞业务逻辑。 "封装就是写 getter/setter"——这让你的类变成一个纯数据容器。真正的封装是行为(方法),不是数据(getter)。

陷阱二:所有设计纯靠直觉。 "我代码写得又快又对,不需要这些原则。" 三个月后的 bug 会让你回来读这章的。

陷阱三:SRP 拆到每个类只有一个方法。 这是一个常见的错误理解——"单一职责"不是"一个类只能做一件事",而是"一个类只有一个变化的理由"。

陷阱四:组合优于继承,所以永远不用继承。 那不对。当子类 is-a 父类并且不会改变父类的行为契约时,继承是合适的。ArrayList extends AbstractList 就是合理的继承。


通关挑战

  • 热身:从你的项目里找一段违反单一职责原则的代码(一个类做了至少两件不同的事),拆成两个类。
  • 挑战:重构一段违反开闭原则的代码(加功能需要改已有代码),使用接口/抽象类实现扩展点。
  • 观察:找一个开源项目,找它的核心接口,分析它是否符合接口隔离原则(一个接口有多少方法,调用方需要所有方法吗?)

旅人笔记

SOLID 不是法律条文,是你在设计时用来问自己的五个问题:这个类需要知道这么多事吗?能不加修改就扩展新功能吗?子类能替换父类吗?接口的方法调用者都需要吗?依赖的方向对吗?——五个问题跑一遍,大多数设计问题就浮出水面了。


下一站预告

原则是剑法,但你还缺招式。接下来的三章讲具体的招式——23 种 GoF 设计模式,根据你的问题场景精准释放。下一章开始:创建型模式。

Built with VitePress | Software Systems Atlas