在日常的 Java 面向对象开发中,我们经常会用到各种设计模式,如工厂模式、策略模式、代理模式等等。但你是否深入思考过:这些设计模式的底层逻辑、设计理念究竟是什么?是否存在一套指导标准,来衡量“一个设计是好的”还是“不够合理”?答案是肯定的。在软件工程中,有一套被广泛认可并遵循的面向对象设计的六大原则(Design Principles),它们被誉为设计模式背后的“金科玉律”。
本篇文章将详细讲解这六大原则:开闭原则、单一职责原则、里氏替换原则、迪米特法则、接口隔离原则、依赖倒置原则,通过通俗的语言、清晰的实例来理解它们的内涵和实际意义。
一、OCP 开闭原则(Open Closed Principle)
定义:一个软件实体(类、模块、函数)应该对扩展开放,对修改关闭。
通俗解释:
“对扩展开放”:可以添加新的功能;“对修改关闭”:不应修改已有的代码。
一、开闭原则(OCP)
错误示例:
public class UserDaoImpl {
public void saveUser() {
// 原始 JDBC 实现
System.out.println("保存用户使用 JDBC 实现");
}
}
后续修改为 JNDI 实现:
public class UserDaoImpl {
public void saveUser() {
// 修改为 JNDI 实现
System.out.println("保存用户使用 JNDI 实现");
}
}
问题:修改了已有类,可能影响原有调用和测试,违反开闭原则。
正确示例:
public interface UserDao {
void saveUser();
}
public class JdbcUserDaoImpl implements UserDao {
public void saveUser() {
System.out.println("保存用户使用 JDBC 实现");
}
}
public class JndiUserDaoImpl implements UserDao {
public void saveUser() {
System.out.println("保存用户使用 JNDI 实现");
}
}
好处:扩展新功能只需新增类,而不必修改已有代码,符合 OCP。
错误示例:
假设我们有一个 UserDao 接口及其实现类 UserDaoImpl,采用 JDBC 实现插入操作。后来需求变了,要改用 JNDI。很多初学者会直接修改 UserDaoImpl 类,删除原来的 JDBC 代码,写上 JNDI 的实现。
这种做法违背了开闭原则,因为你修改了已有代码,可能会引发新旧逻辑的不兼容和测试风险。
正确做法:
应该新增一个类 UserDaoJndiImpl,实现同样的接口 UserDao,而不是修改原有的 UserDaoImpl 类。这样可以在使用时通过配置或者工厂类来选择使用哪个实现。
拓展建议:
虽然继承也是一种扩展方式,但不推荐通过继承原有实现类来改写方法逻辑,因为这可能导致 里氏替换原则被破坏。更推荐的方式是新增实现类。
二、SRP 单一职责原则(Single Responsibility Principle)
定义:一个类应该只有一个引起它变化的原因。
通俗解释:
一个类只做一件事,聚焦于单一功能。
二、单一职责原则(SRP)
错误示例:
public class VideoUserService {
public void playVideo(String userType) {
if ("guest".equals(userType)) {
System.out.println("游客播放视频:展示广告");
} else if ("vip".equals(userType)) {
System.out.println("VIP 播放视频:免广告高清");
}
// 更多角色...
}
}
问题:一个类承担多个角色逻辑,难以维护和扩展。
正确示例:
public interface VideoUserService {
void playVideo();
}
public class GuestUserService implements VideoUserService {
public void playVideo() {
System.out.println("游客播放视频:展示广告");
}
}
public class VipUserService implements VideoUserService {
public void playVideo() {
System.out.println("VIP 播放视频:免广告高清");
}
}
好处:每个类专注一个职责,新增角色只需添加实现类。
错误示例:
视频网站中的服务类,里面通过 if-else 判断用户是访客、普通用户、会员或 VIP,然后执行不同的播放逻辑。这样的设计耦合了所有角色的处理逻辑,后续每加一个角色,都要修改这个类。
正确做法:
定义一个接口 VideoUserService,并为每种用户角色创建独立的实现类:如 GuestUserService、NormalUserService、VipUserService,每个类只负责处理对应角色的业务逻辑。
这不仅符合 SRP,也方便扩展新角色(如 SVIP),而无需修改现有逻辑,间接也遵循了开闭原则。
衍生模式:
这种策略根据对象类型进行不同行为处理的方式,本质上是“策略模式”的一个典型实现。
三、LSP 里氏替换原则(Liskov Substitution Principle)
定义:子类对象可以替代父类对象,程序行为不发生改变。
通俗解释:
子类能出现的地方,父类也能正常使用;子类扩展父类功能,但不能违背父类原有行为的预期。
错误示例:
public class CashCard {
public void withdraw(double amount) {
System.out.println("账户扣除:" + amount);
}
}
public class CreditCard extends CashCard {
@Override
public void withdraw(double amount) {
System.out.println("增加信用额度负债:" + amount);
}
}
问题:虽然是继承关系,但语义不一致,导致调用方混淆。
正确示例:
public abstract class BankCard {
public abstract void deposit(double amount);
public abstract void withdraw(double amount);
}
public class CashCard extends BankCard {
public void deposit(double amount) {
System.out.println("存入现金:" + amount);
}
public void withdraw(double amount) {
System.out.println("取出现金:" + amount);
}
}
public class CreditCard extends BankCard {
public void deposit(double amount) {
System.out.println("还款金额:" + amount);
}
public void withdraw(double amount) {
System.out.println("信用透支:" + amount);
}
}
好处:语义清晰,子类替代父类时行为一致。
错误示例:
信用卡继承储蓄卡,看起来合理。但储蓄卡的提现是从账户扣款,而信用卡的提现是增加负债(贷款);储蓄是存款,而信用卡的“存款”其实是还款。这两者在行为语义上完全不同。
直接继承会导致代码语义混乱,违背 LSP。
正确做法:
抽象出一个父类 BankCard,定义通用的行为如 deposit() 和 withdraw()。然后:
CashCard 实现账户真实资金操作;CreditCard 实现透支和还款逻辑(如 loan() 和 repayment());
各自行为清晰,互不干扰。
总结 LSP 四条原则:
子类可以实现父类的抽象方法,但不能重写非抽象方法;子类可以增加自己的独有方法;子类重写方法时,输入参数范围应更宽松;子类输出/返回值应与父类相等或更严格。
作用:
保证行为稳定性;避免继承导致行为冲突;增强程序健壮性;降低未来维护成本。
四、LOD 迪米特法则(Law of Demeter)——最少知道原则
定义:一个对象应当对其他对象有尽可能少的了解。
通俗解释:
类之间低耦合;尽量只和“直接朋友”通信,不要过多依赖其他类的内部结构。
错误示例:
public class Student {
public int getScore() {
return 90;
}
}
public class Principal {
public void report(Student student) {
int score = student.getScore(); // 直接访问细节
System.out.println("学生成绩为:" + score);
}
}
问题:校长类直接依赖学生,耦合过多。
正确示例:
public class Student {
private int score = 90;
public int getScore() {
return score;
}
}
public class Teacher {
private Student student = new Student();
public int getAverageScore() {
return student.getScore();
}
}
public class Principal {
public void report(Teacher teacher) {
int avg = teacher.getAverageScore();
System.out.println("学生平均成绩为:" + avg);
}
}
好处:校长只和老师通信,不依赖学生内部细节,符合最少知道原则。
错误示例:
校长类直接访问学生列表,遍历并统计每个学生成绩,职责太重,知道太多。
正确做法:
由老师类封装学生成绩的总分/平均分等处理逻辑,校长类只调用老师提供的接口,如 getAverageScore()。这样实现:
校长不需知道学生详情;老师负责处理内部细节;耦合关系大幅降低。
五、ISP 接口隔离原则(Interface Segregation Principle)
定义:一个类不应该依赖它不需要的接口。
通俗解释:
与其让类实现一个大接口,不如将接口细化成多个更小、更专一的接口;实现类按需选择实现哪个接口。
错误示例:
public interface Animal {
void fly();
void run();
void swim();
}
public class Dog implements Animal {
public void fly() {} // 鸭子实现,不需要
public void run() {
System.out.println("狗在跑");
}
public void swim() {}
}
问题:Dog 不会飞,却被强制实现 fly(),造成接口污染。
正确示例:
public interface RunnableAnimal {
void run();
}
public interface SwimmableAnimal {
void swim();
}
public class Dog implements RunnableAnimal, SwimmableAnimal {
public void run() {
System.out.println("狗在跑");
}
public void swim() {
System.out.println("狗会游泳");
}
}
好处:实现类只依赖所需接口,避免臃肿。
示例解析:
Java Servlet 中的监听器设计就体现了这一原则:
ServletContextListener 只负责上下文生命周期;HttpSessionListener 只负责 Session;ServletRequestListener 只负责请求。
这样,每个类只实现自己关心的接口,避免实现冗余方法。
实际好处:
避免“臃肿接口”;提高代码的灵活性与可读性;避免修改一个接口影响大量实现类。
六、DIP 依赖倒置原则(Dependency Inversion Principle)
定义:
高层模块不应该依赖底层模块,二者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。
通俗解释:
面向接口编程;高层业务逻辑不依赖具体实现,而是依赖抽象接口。
错误示例:
public class LotteryService {
public void draw(String type) {
if ("random".equals(type)) {
System.out.println("随机抽奖");
} else if ("weight".equals(type)) {
System.out.println("权重抽奖");
}
}
}
问题:高层模块依赖具体实现,耦合严重。
正确示例:
public interface ILotteryStrategy {
void draw();
}
public class RandomLotteryStrategy implements ILotteryStrategy {
public void draw() {
System.out.println("随机抽奖");
}
}
public class WeightLotteryStrategy implements ILotteryStrategy {
public void draw() {
System.out.println("权重抽奖");
}
}
public class LotteryService {
private ILotteryStrategy strategy;
public LotteryService(ILotteryStrategy strategy) {
this.strategy = strategy;
}
public void doDraw() {
strategy.draw();
}
}
使用:
LotteryService service = new LotteryService(new RandomLotteryStrategy());
service.doDraw();
好处:面向接口,解耦实现;方便扩展和测试。
错误示例:
抽奖系统直接在逻辑类中写了“随机抽奖”和“权重抽奖”的代码,并通过 if-else 控制行为。这种做法紧耦合,不便于扩展。
正确做法:
抽象一个接口 ILotteryStrategy;具体实现类 RandomLotteryStrategy 和 WeightLotteryStrategy 实现该接口;抽奖服务类接收 ILotteryStrategy 作为参数;外部根据策略选择具体实现传入服务类。
优势:
解耦业务逻辑与策略实现;新增策略无需改动现有逻辑;灵活、易扩展、方便测试。
总结:六大设计原则速记
原则名称英文缩写核心思想开闭原则OCP对扩展开放,对修改关闭单一职责SRP一个类只做一件事里氏替换LSP子类可替换父类且行为一致迪米特法则LOD / Demeter只和“直接朋友”通信,最少知道接口隔离ISP接口应小而专一依赖倒置DIP高层依赖抽象,面向接口编程
写在最后
六大设计原则并不是某个工具框架强制规定的,而是在长期的面向对象设计与工程实践中,逐渐被开发者总结出来的一套“通用开发指导思想”。它们是架构的基础、设计模式的根、也是提升代码质量的核心路径。
在日常编码过程中,可能你没有刻意遵循,但只要代码结构合理、模块清晰、逻辑独立,那很可能你已经在“潜移默化”中遵循了这些原则。
真正写出优雅、可维护、可扩展的代码,绝不是多用几个设计模式,而是深入理解并践行这些设计原则。这才是掌握设计模式的本质。