设计模式中的原则
本文是【GoF设计模式】系列的前置篇
前言
本篇可以当做设计模式学习前的入门,也可以当成设计模式学习后的复习。
先看一段典型的"面条代码":
// 一个臃肿的 UserService:校验、持久化、通知、日志全堆在一起publicclassUserService{publicvoidregister(Useruser){if(user.getName()==null||user.getName().isEmpty()){thrownewIllegalArgumentException("用户名不能为空");}Connectionconn=DriverManager.getConnection("jdbc:mysql://localhost/db","root","root");// 保存、发邮件、记日志全在这里:换数据库要改源码,也无法单独测试}}这段代码职责过多、强依赖具体实现、难以替换和测试。随着代码增长,这类结构会越来越难维护。设计原则正是从这些痛点中提炼出的经验总结,能让代码组织得更灵活、可维护、可测试。
面向对象设计中有几个经典原则,前五个常被合称为SOLID:
| 缩写 | 原则 | 核心要义 |
|---|---|---|
| S | 单一职责原则(SRP) | 一个类只承担一个职责 |
| O | 开闭原则(OCP) | 对扩展开放,对修改关闭 |
| L | 里氏代换原则(LSP) | 子类必须能替换父类 |
| I | 接口隔离原则(ISP) | 不依赖不需要的接口 |
| D | 依赖倒转原则(DIP) | 依赖抽象,不依赖细节 |
此外还有迪米特法则(LoD)和合成/聚合复用原则(CARP)。下面逐一介绍。
单一职责原则(SRP)
单一职责原则(Single Responsibility Principle):就一个类而言,应该仅有一个引起它变化的原因。
如果一个类承担的职责过多,就等于把这些职责耦合在一起,一个职责的变化可能削弱或抑制它完成其他职责的能力,导致脆弱的设计。软件设计的重要内容之一,就是发现职责并把它们相互分离。
判断一个类是否职责过多,可以看是否能想到多个动机去改变它;或用一句话描述它的职责,若出现"和""或"等连接词,往往说明违反了该原则。
❌违反 SRP 的设计:
// 一个类承担了数据管理、权限验证、消息通知三个职责publicclassUserService{publicvoidsaveUser(Useruser){/* 保存用户 */}publicbooleancheckPermission(Useruser,Stringresource){/* 检查权限 */}publicvoidsendEmail(Useruser,Stringmessage){/* 发送邮件 */}}✅遵循 SRP 的设计:
// 按职责拆分,每个类只负责一件事publicclassUserService{publicvoidsaveUser(Useruser){/* 保存用户 */}}publicclassPermissionService{publicbooleancheckPermission(Useruser,Stringresource){/* 检查权限 */}}publicclassNotificationService{publicvoidsendEmail(Useruser,Stringmessage){/* 发送邮件 */}}| 优点 | 缺点 |
|---|---|
| 降低类的复杂度,每个类只负责一项职责 | 增加类的数量,系统复杂度上升 |
| 提高代码可读性与可维护性 | 过度拆分会增加类间依赖 |
| 修改一个职责不影响其他职责 | 需要合理判断职责边界 |
开闭原则(OCP)
开闭原则(Open-Closed Principle):软件实体(类、模块、函数等)应该可以扩展,但是不可修改。
即对扩展开放、对修改封闭。无论模块多么封闭,都会存在无法封闭的变化,设计人员需要猜测最可能发生的变化,再构造抽象来隔离它们。开闭原则是面向对象设计的核心,遵循它能带来可维护、可扩展、可复用、灵活性好等好处。但拒绝不成熟的抽象和抽象本身一样重要,不要对每个部分都刻意抽象。
实现开闭原则的常见方式:
- 抽象与多态:用接口或抽象类定义抽象层,客户端依赖抽象而非具体实现
- 参数化:把变化的部分抽象为参数(策略对象、回调)
- 配置化:可变部分提取到配置文件,运行时读取配置决定实现
- 元数据驱动:用注解驱动框架行为,新增逻辑靠加注解而非改框架
❌违反 OCP 的设计:
// 每次新增支付方式都要修改这里的 if/elsepublicclassPaymentProcessor{publicvoidprocessPayment(Stringtype,BigDecimalamount){if("alipay".equals(type)){/* 支付宝 */}elseif("wechat".equals(type)){/* 微信 */}}}✅遵循 OCP 的设计:
// 通过抽象扩展,新增支付方式只需新增类,不改老代码publicinterfacePaymentStrategy{voidpay(BigDecimalamount);}publicclassAlipayStrategyimplementsPaymentStrategy{publicvoidpay(BigDecimalamount){/* 支付宝 */}}publicclassPaymentProcessor{privatePaymentStrategystrategy;publicPaymentProcessor(PaymentStrategystrategy){this.strategy=strategy;}publicvoidprocessPayment(BigDecimalamount){strategy.pay(amount);}}| 优点 | 缺点 |
|---|---|
| 提高系统稳定性,减少回归测试 | 需要预判变化,增加设计难度 |
| 提高代码复用性与可维护性 | 增加抽象层,提高复杂度 |
| 新功能通过扩展实现,降低修改风险 | 过度抽象会让代码难懂 |
依赖倒转原则(DIP)
依赖倒转原则(Dependency Inversion Principle):
- 高层不应依赖低层,两者都依赖抽象
- 抽象不应依赖细节,细节应依赖抽象
依赖倒转可以说是面向对象设计的标志:如果编写时考虑的都是针对抽象编程而非细节编程,即所有依赖关系都终止于抽象类或接口,就是面向对象的设计,反之就是过程化设计。传统过程化设计中高层依赖低层,依赖倒转把这个关系"倒转"过来,让两者都依赖抽象,从而解耦。
实现方式是高层模块定义接口、低层模块实现接口,再用依赖注入把低层模块注入高层模块,做到面向接口编程而非面向实现编程。
❌违反 DIP 的设计:
// 高层直接 new 低层具体实现,换数据库要改源码publicclassUserService{privateMySQLUserDaouserDao=newMySQLUserDao();publicUsergetUser(intid){returnuserDao.findById(id);}}✅遵循 DIP 的设计:
// 高层依赖抽象,低层实现抽象,通过构造器注入publicinterfaceUserDao{UserfindById(intid);}publicclassMySQLUserDaoimplementsUserDao{publicUserfindById(intid){/* MySQL 查询 */}}publicclassUserService{privateUserDaouserDao;publicUserService(UserDaouserDao){this.userDao=userDao;}publicUsergetUser(intid){returnuserDao.findById(id);}}| 优点 | 缺点 |
|---|---|
| 降低类间耦合,提高系统稳定性 | 增加抽象层,提高复杂度 |
| 提高可扩展性,便于替换具体实现 | 需要较好的抽象能力 |
| 便于并行开发与单元测试(轻松 Mock 依赖) | 依赖注入框架增加学习成本 |
里氏代换原则(LSP)
里氏代换原则(Liskov Substitution Principle):子类型必须能替换父类型。
只有当子类可以替换父类、软件单位的功能不受影响时,父类才能真正被复用,子类也能在父类基础上增加新行为。里氏代换是继承复用的基石,只有子类能完全替换父类时,继承才是合理的设计。
经典反例是"正方形继承矩形":正方形重写setWidth/setHeight强制宽高相等,导致在期望矩形的地方替换为正方形时面积计算出错。正确做法是让矩形和正方形共同实现一个Shape接口,而非用继承强行关联。Java 集合框架中任何使用List接口的地方都能无缝替换为ArrayList、LinkedList,就是该原则的体现。
❌违反 LSP 的设计:
// 正方形继承矩形,重写方法强制宽高相等,替换后面积计算出错publicclassRectangle{protectedintwidth,height;publicvoidsetWidth(intw){width=w;}publicvoidsetHeight(inth){height=h;}publicintgetArea(){returnwidth*height;}}publicclassSquareextendsRectangle{publicvoidsetWidth(intw){width=w;height=w;}publicvoidsetHeight(inth){width=h;height=h;}}✅遵循 LSP 的设计:
// 矩形和正方形共同实现 Shape 接口,不再用继承强行关联publicinterfaceShape{intgetArea();}publicclassRectangleimplementsShape{privateintwidth,height;publicRectangle(intw,inth){width=w;height=h;}publicintgetArea(){returnwidth*height;}}publicclassSquareimplementsShape{privateintside;publicSquare(intside){this.side=side;}publicintgetArea(){returnside*side;}}| 优点 | 缺点 |
|---|---|
| 保证继承复用的正确性 | 限制继承的灵活性 |
| 提高可维护性,子类不破坏父类行为 | 需仔细设计继承关系 |
| 便于统一测试父类行为 | 可能导致类层次变深 |
迪米特法则(LoD)
迪米特法则(Law of Demeter),又叫最少知识原则:如果两个类不必直接通信,就不应直接相互作用,需要调用时通过第三者转发。
它强调每个类都应尽量降低成员的访问权限,一个对象应该对其他对象有尽可能少的了解,只与"直接朋友"通信,不跟"陌生人"说话。
- 直接朋友:对象自身、成员对象、方法参数、方法内创建的对象
- 陌生人:通过方法返回值间接获得的对象等
a.getB().doSomething()这种链式调用属于"和陌生人说话",违反该法则。Java 三层架构中 Controller 只调用 Service、Service 只调用 Repository,就是迪米特法则的典型应用。
❌违反 LoD 的设计:
// 房客直接与房东、银行等多个“陌生人”交互publicclassTenant{publicvoidrentRoom(){Landlordlandlord=newLandlord();Bankbank=newBank();landlord.negotiate();// 直接调用房东bank.transfer();// 直接调用银行}}✅遵循 LoD 的设计:
// 房客只与中介(直接朋友)交互,由中介转发调用publicclassTenant{privateAgentagent;publicvoidrentRoom(){agent.rentRoom();}}publicclassAgent{privateLandlordlandlord;privateBankbank;publicvoidrentRoom(){landlord.negotiate();bank.transfer();}}| 优点 | 缺点 |
|---|---|
| 降低类间耦合,提高模块独立性 | 可能产生大量"中介"类 |
| 提高可读性与可维护性 | 过度使用会使结构复杂 |
| 修改一个类影响范围更小 | 可能增加方法调用层数 |
接口隔离原则(ISP)
接口隔离原则(Interface Segregation Principle):客户端不应该依赖它不需要的接口,一个类对另一个类的依赖应建立在最小接口上。
要建立单一的接口,不要建立庞大臃肿的接口,接口中的方法应尽量少,只包含客户端需要的方法。接口过大时,实现类被迫实现用不到的方法,产生冗余代码。这里的"隔离"不是物理隔离,而是通过拆分接口让客户端只依赖需要的方法,与不需要的方法隔离。
与单一职责原则的区别:
| 对比维度 | 单一职责原则(SRP) | 接口隔离原则(ISP) |
|---|---|---|
| 关注点 | 类的职责(业务功能) | 接口的依赖范围 |
| 判断依据 | 引起变化的原因 | 客户端需要的方法 |
| 拆分对象 | 类 | 接口 |
两者经常配合:SRP 保证类职责单一,ISP 保证接口粒度合适。典型例子是 Java 集合的Iterable/Collection/List/RandomAccess接口层次,客户端可按需依赖最小接口。
❌违反 ISP 的设计:
// 臃肿接口:机器人被迫实现用不到的 eat、sleeppublicinterfaceWorker{voidwork();voideat();voidsleep();}publicclassRobotWorkerimplementsWorker{publicvoidwork(){/* 工作 */}publicvoideat(){}// 空实现,机器人不吃饭publicvoidsleep(){}// 空实现,机器人不睡觉}✅遵循 ISP 的设计:
// 拆分接口,机器人只实现需要的接口publicinterfaceWorkable{voidwork();}publicclassRobotWorkerimplementsWorkable{publicvoidwork(){/* 工作 */}}| 优点 | 缺点 |
|---|---|
| 降低接口耦合,客户端只依赖需要的方法 | 接口数量增多,复杂度上升 |
| 提高内聚性,接口职责单一 | 拆分过细可能导致类爆炸 |
| 减少冗余实现,扩展不影响已有接口 | 需合理判断接口粒度 |
合成/聚合复用原则(CARP)
合成/聚合复用原则(Composite/Aggregate Reuse Principle):优先用合成/聚合,少用类继承。
继承关系在编译时就固定下来,无法在运行时改变;子类与父类紧密耦合,父类的任何变化都会波及子类,限制了灵活性和复用性。合成/聚合则保持每个类的封装性,让类继承层次保持较小规模。
合成与聚合都是关联的特殊种类:聚合是弱的"拥有"关系(部分可独立存在,如学生与班级),合成是强的"拥有"关系(部分与整体同生命周期,如人与心脏)。
❌违反 CARP 的设计:
// 用继承复用引擎:汽车 IS-A 引擎?逻辑不对,且引擎变化会波及汽车publicclassCarextendsEngine{// 引擎的任何改动都会影响汽车}✅遵循 CARP 的设计:
// 用合成复用引擎:汽车 HAS-A 引擎,运行时可切换publicinterfaceEngine{voidstart();}publicclassCar{privateEngineengine;publicCar(Engineengine){this.engine=engine;}publicvoidstart(){engine.start();}}| 对比维度 | 继承 | 合成/聚合 |
|---|---|---|
| 耦合度 | 高(编译时绑定) | 低(运行时可替换) |
| 灵活性 | 低 | 高 |
| 复用性 | 受限于父类实现 | 可组合多个对象 |
| 封装性 | 破坏封装 | 保持封装 |
| 适用场景 | IS-A 关系(狗是动物) | HAS-A 关系(汽车有引擎) |
| 优点 | 缺点 |
|---|---|
| 降低类间耦合度 | 可能产生大量小类 |
| 提高灵活性与可扩展性 | 对象组合可能让结构复杂 |
| 支持运行时动态组合、保持封装 | 初期设计成本较高 |
原则与设计模式的对应
设计原则是设计模式的"灵魂",每个 GoF 模式背后都对应着一条或多条原则。理解这层映射,能在遇到问题时快速定位该用哪个模式:
| 原则 | 典型对应的设计模式 | 体现方式 |
|---|---|---|
| 单一职责(SRP) | 门面模式、桥接模式 | 按职责拆分类与接口 |
| 开闭(OCP) | 策略、装饰、观察者、模板方法 | 通过新增类扩展功能,不改已有代码 |
| 里氏代换(LSP) | 策略、模板方法、状态 | 子类安全替换父类,多态有意义 |
| 接口隔离(ISP) | 门面模式、适配器模式 | 为客户端提供最小接口 |
| 依赖倒转(DIP) | 工厂方法、抽象工厂、依赖注入 | 高层依赖抽象,低层实现抽象 |
| 迪米特法则 | 中介者、外观模式 | 通过中介/门面减少直接交互 |
| 合成/聚合复用(CARP) | 桥接、装饰、策略、代理、组合 | 用组合代替继承 |
原则之间的关系
这些原则并非孤立存在,而是相互支撑、形成完整的面向对象设计思想体系:
开闭原则(核心目标) ↑ ┌──────────────┼──────────────┐ │ │ │ 单一职责原则 依赖倒转原则 里氏代换原则 (类的拆分) (抽象层解耦) (继承的正确使用) │ │ │ └──────────────┼──────────────┘ ↓ 接口隔离原则(接口拆分) ↓ 迪米特法则(降低耦合) ↓ 合成/聚合复用原则(复用方式)开闭原则是核心目标,让系统易于扩展、难以修改;单一职责通过职责拆分为开闭原则创造条件;依赖倒转通过抽象层解耦实现开闭原则;里氏代换保证继承的正确性,使多态有意义;接口隔离通过拆分臃肿接口让依赖更精确;迪米特法则通过减少通信降低耦合;合成/聚合复用通过组合提高灵活性和复用性。
这些原则有时也会相互掣肘,需要权衡:
- 接口隔离拆接口 vs 合成复用导致类爆炸:只有当不同客户端确实只用到接口的一部分时才拆分
- 依赖倒转抽象层 vs 简单性:抽象的前提是变化真实存在或可预见,变体只有一种时直接依赖具体实现反而更清晰
- 迪米特减少通信 vs 中介类泛滥:中介只在需要降低"跨层耦合"时引入,不为内部协作加壳
原则不是强制规定,不要为了遵循原则而过度设计。先让代码简单正确地跑起来,等到变化真正出现时再重构引入抽象——重复三次的代码才考虑抽象。
记忆口诀
- 拆分靠 SRP:一个类只做一件事
- 扩展靠 OCP:加功能不改老代码
- 换实现靠 DIP:都依赖抽象,运行时替换
- 继承看 LSP:子类能替父类,多态才安全
- 接口靠 ISP:用不到的方法别塞过来
- 通信靠 LoD:只跟直接朋友说话
- 复用靠 CARP:能用组合就别用继承
一句话总纲:先简单正确,再按需抽象;拆得清职责,换得动实现,扩得起新功能。
