3. 面向对象设计原则(下):接口隔离、依赖倒置、合成复用与迪米特法则
好,我们接着聊剩下的四个原则。如果说上一讲的开闭原则和里氏替换是“道”,那今天这几个就是“术”——更具体、更落地。我在代码评审里,十次有八次能看到违反这些原则的痕迹。咱们一个一个说。
3.1 接口隔离原则(ISP)
定义:客户端不应该被迫依赖它不使用的接口。
说白了,就是别把一堆不相干的方法塞进同一个接口里。你想想看,一个接口里既有“下单”又有“导出Excel”,那实现这个接口的类就得硬着头皮实现那个“导出Excel”——哪怕它根本不需要。
3.1.1 反面教材
// 一个万能接口,什么都有
public interface Worker {
void work();
void eat();
void sleep();
}
// 机器人被迫实现 eat() 和 sleep()
public class Robot implements Worker {
public void work() { /* 干活 */ }
public void eat() { throw new UnsupportedOperationException(); }
public void sleep() { throw new UnsupportedOperationException(); }
}
我在项目中遇到过类似的情况。一个老系统里有个 ReportGenerator 接口,里面既有生成PDF的方法,又有发送邮件的方法。结果每个报表类都得实现发邮件——哪怕它只是生成一个本地文件。后来我拆成了 ReportGenerator 和 ReportSender 两个接口,代码清爽多了。
3.1.2 正确做法
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public interface Sleepable {
void sleep();
}
public class HumanWorker implements Workable, Eatable, Sleepable {
public void work() { /* 干活 */ }
public void eat() { /* 吃饭 */ }
public void sleep() { /* 睡觉 */ }
}
public class RobotWorker implements Workable {
public void work() { /* 干活 */ }
}
3.2 依赖倒置原则(DIP)
定义:高层模块不应该依赖低层模块,两者都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象。
嗯,这句话有点绕。我换个说法:别让业务逻辑直接依赖数据库、文件、第三方库这些东西。 你应该依赖接口,然后让具体实现去适配接口。
3.2.1 为什么重要?
我曾经接手过一个项目,业务逻辑里到处都是 new SqlConnection()、new FileStream()。后来要换数据库,从 SQL Server 换到 PostgreSQL,几乎改了一半的代码。这就是典型的“高层依赖了低层”。
3.2.2 代码对比
// 违反 DIP:业务类直接依赖具体数据库
public class OrderService {
private SqlDatabase db = new SqlDatabase();
public void save(Order order) {
db.insert(order);
}
}
// 符合 DIP:依赖抽象接口
public class OrderService {
private IOrderRepository repo;
public OrderService(IOrderRepository repo) {
this.repo = repo;
}
public void save(Order order) {
repo.save(order);
}
}
3.3 合成复用原则(CARP)
定义:尽量使用对象组合(has-a)而不是类继承(is-a)来实现复用。
继承是个好东西,但用多了容易出问题。你想想看,继承是在编译期就绑死了父子关系,子类能访问父类的内部细节——这违反了封装。而组合是在运行期动态绑定的,更灵活。
3.3.1 继承的陷阱
// 用继承实现复用
public class Stack<E> extends Vector<E> {
public void push(E e) { add(e); }
public E pop() { return remove(size()-1); }
}
// 问题:Stack 继承了 Vector 的所有方法,包括 insertElementAt()
// 用户可以绕过 push/pop 直接操作内部数组,破坏了栈的语义
我记得在 Java 的早期版本里,Stack 就是这么实现的。结果呢?你可以在栈中间插入元素——这还算栈吗?
3.3.2 组合的正确姿势
public class Stack<E> {
private List<E> elements = new ArrayList<>();
public void push(E e) { elements.add(e); }
public E pop() { return elements.remove(elements.size()-1); }
public boolean isEmpty() { return elements.isEmpty(); }
}
3.4 迪米特法则(LoD)
定义:一个对象应该对其他对象保持最少的了解。也叫“最少知识原则”。
通俗点说:别跟陌生人说话。 你只跟你的直接朋友通信,别去调用朋友的朋友的方法。
3.4.1 典型反例
// 违反迪米特法则
public class Customer {
public Wallet getWallet() { return wallet; }
}
public class Wallet {
public float getTotalMoney() { return money; }
}
// 在业务代码中:
customer.getWallet().getTotalMoney(); // 知道得太多了!
这样写的问题是什么?一旦 Wallet 的内部结构变了(比如不再用 getTotalMoney() 而是用 getBalance()),所有调用方都得改。
3.4.2 正确做法
public class Customer {
private Wallet wallet;
public float getMoney() {
return wallet.getTotalMoney(); // 封装内部细节
}
}
// 调用方只需要:
customer.getMoney(); // 简单,干净
a.b().c().d()),大概率是违反了迪米特法则。这时候就该考虑封装了。
3.5 四个原则的关系与总结
这四个原则其实是从不同角度告诉你同一件事:降低耦合,提高内聚。
- ISP 告诉你接口要小,别让实现类背负不必要的负担。
- DIP 告诉你依赖要面向抽象,别让高层代码被底层细节绑架。
- CARP 告诉你组合优于继承,别让父子关系锁死你的设计。
- LoD 告诉你通信要克制,别让一个对象变成“八爪鱼”。
下面这张图是我自己画的,把这四个原则的关系串了一下——你看完应该能记住它们各自的位置。
最后说一句:这些原则不是教条。我见过有人为了遵守迪米特法则,硬生生造出几十个“中介类”,反而让代码更难懂。原则是工具,不是枷锁。理解背后的“为什么”,比记住“是什么”重要得多。
公众号:蓝海资料掘金营,微信deep3321