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的方法,又有发送邮件的方法。结果每个报表类都得实现发邮件——哪怕它只是生成一个本地文件。后来我拆成了 ReportGeneratorReportSender 两个接口,代码清爽多了。

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个,我就会停下来想一想——是不是该拆了?尤其是那些返回值是 void 的方法,最容易混在一起。

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);
    }
}
避坑指南:我曾经见过有人把 DIP 理解成“多用接口”,结果每个类都配一个接口,连只有两个方法的工具类也搞个接口。过度设计也是病。接口是为“变化点”服务的,不是为所有类服务的。

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(); }
}
我的经验:写代码时,先问自己“能用组合吗?”如果不行,再考虑继承。组合让代码更松耦合,更容易测试——你甚至可以 mock 掉组合进来的对象。

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 告诉你通信要克制,别让一个对象变成“八爪鱼”。

下面这张图是我自己画的,把这四个原则的关系串了一下——你看完应该能记住它们各自的位置。

面向对象设计原则(下) 接口隔离原则 (ISP) 客户端不应依赖它不用的接口 → 胖接口拆小,角色分离 依赖倒置原则 (DIP) 高层不依赖低层,都依赖抽象 → 面向接口编程,依赖注入 合成复用原则 (CARP) 优先使用组合,而非继承 → has-a 优于 is-a 迪米特法则 (LoD) 最少知识,只与直接朋友通信 → 别调用朋友的方法 共同目标:降低耦合,提高内聚 每个原则从不同角度约束你的设计决策

最后说一句:这些原则不是教条。我见过有人为了遵守迪米特法则,硬生生造出几十个“中介类”,反而让代码更难懂。原则是工具,不是枷锁。理解背后的“为什么”,比记住“是什么”重要得多。


公众号:蓝海资料掘金营,微信deep3321