元数据卡
- 前置知识:第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。
修复:拆成四个类。
// 单一职责:每个类只负责一个变化维度
// 奖品类型
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:
if (isHolidayEvent) { value *= 2; }改了已有的代码。这就是违反了开闭原则——新增一个活动类型,你修改了现有的类。理想的做法是扩展而不是修改。
// 开闭原则的例子
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 时——你听过这个经典场景吧。
// 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);
}
}看起来合理。但调用方不这么想:
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
客户端不应该被强迫依赖它们不使用的接口。你的比赛系统有一个接口:
interface TournamentService {
void register(String playerId);
void start();
void getResults();
void sendNotification(String msg); // 通知功能
void calculateRankings();
}如果你的 RegisterOnlyClient 只用了 register,它仍然依赖了 sendNotification 和 calculateRankings。下次你改 sendNotification 的签名,RegisterOnlyClient 也得重新编译。
修复:拆分接口。
interface RegistrationService { void register(String playerId); }
interface MatchService { void start(); Results getResults(); }
interface NotificationService { void send(String msg); }
interface RankingService { void calculate(); }调用方只依赖自己需要的接口:
class RegistrationController {
private final RegistrationService service; // 只依赖一个
}第五层:依赖反转原则(DIP)
Dependency Inversion Principle
高层模块不应该依赖底层模块。两者都应该依赖于抽象。
抽象不应该依赖于细节。细节应该依赖于抽象。你的 TournamentController 直接用了 PostgresTournamentRepository:
class TournamentController {
private PostgresTournamentRepository repo = new PostgresTournamentRepository();
// 高层次的控制器直接依赖了低层次的具体数据库实现
}如果某天你从 PostgreSQL 切到 MongoDB,这个控制器必须改代码。
依赖反转的做法:
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 三板斧
封装:把数据和行为包在一起,对外只暴露你允许的操作。
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 关系。但继承是设计里最强的耦合关系——子类依赖父类的所有细节,包括私有字段的实现。所以组合优于继承(见下)。
多态:同一接口的不同实现。你的 PrizeBonusRule 有 HolidayBonusRule 和 StreakBonusRule,调用方不关心是哪个实现。
第七层:组合优于继承
继承的问题是脆弱。父类加一个字段、改一个方法签名——所有子类受影响。
// 继承
class Mage extends Adventurer {
public void castSpell() { super.mana -= 10; }
}// 组合
class Mage {
private final Adventurer adventurer;
private final SpellBook spellBook;
public void castSpell() {
spellBook.use();
}
}组合让类之间的耦合更松散:Mage 有 Adventurer,而不是 Mage 是 Adventurer。当你需要改 Adventurer 的行为时,组合不会牵连到 Mage 的内部逻辑。
第八层:最少知识原则(Law of Demeter)
一个对象只与它的直接朋友通信。不要调用"链式方法"。// 违反:链式调用了太多不直接的对象
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 设计模式,根据你的问题场景精准释放。下一章开始:创建型模式。