编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 面向对象设计

  • 常见设计原则

    • README
    • 面向对象六大原则
      • 1.工作中真实案例
        • 1.1 上帝类之诞生记
        • 1.2 烂代码深层根源
        • 1.3 本篇待解之难题
      • 2.六大原则总览
      • 3.单一职责原则
        • 3.1 原则定义解读
        • 3.2 图片加载实现法
        • 3.3 为何必须拆开来
      • 4.开闭原则之详解
        • 4.1 原则定义解读
        • 4.2 磁盘缓存的添加
        • 4.3 抽象隔离变化
      • 5.里氏替换原则
        • 5.1 原则定义解读
        • 5.2 LSP看缓存体系
        • 5.3 违反 LSP 的案例
      • 6.依赖倒置原则
        • 6.1 原则定义解读
        • 6.2 通俗快速理解
        • 6.3 为何称之为倒置
        • 6.4 图片加载的倒置实战
      • 7.接口隔离原则
        • 7.1 原则定义解读
        • 7.2 CloseUtils的最小接口哲学
        • 7.3 ImageLoader的ISP实战
      • 8.迪米特法则
        • 8.1 原则定义解读
        • 8.2 ImageLoader中的LOD实战
      • 9.SOLID的由来
        • 9.1 设计原则的起源
        • 9.2 SOLID命名由来
      • 10.六原则关系图谱
        • 10.1 原则的协同关系
        • 10.2 设计逻辑的全链
        • 10.3 一句话速记口诀
      • 11.原则的度量之道
        • 11.1 坏味道与原则表
        • 11.2 过度设计信号
        • 11.3 原则冲突与权衡
      • 12.原则工业实践
        • 12.1 Spring中SOLID
        • 12.2 Unix哲学与原则
      • 13.开篇案例再回顾
      • 14.核心收获总结
      • 15.课后思考与练习
      • 16.课后实战练习
    • 单一职责原则详解
    • 开闭原则详细介绍
    • 里式替换原则介绍
    • 接口隔离原则介绍
    • 依赖倒置原则介绍
    • 迪米特原则介绍
    • 项目重构演进之路
  • 巧学设计模式

  • 系统架构设计

  • 编程
  • 常见设计原则
杨充
2023-08-03
目录

面向对象六大原则

# 第二卷第1章:面向对象六大原则

# 目录介绍

  • 1.工作中真实案例
  • 2.六大原则总览
  • 3.单一职责原则
  • 4.开闭原则
  • 5.里氏替换原则
  • 6.依赖倒置原则
  • 7.接口隔离原则
  • 8.迪米特法则
  • 9.SOLID的由来
  • 10.六原则关系图谱
  • 11.原则的度量之道
  • 12.原则工业实践
  • 13.开篇案例再回顾
  • 14.核心收获总结
  • 15.课后思考与练习
  • 16.课后实战练习

# 1.工作中真实案例

# 1.1 上帝类之诞生记

做终端开发(Android / iOS / Web / 小程序都一样)遇到的一种场景:接手一个列表页的"图片加载"逻辑。最初它只是一个 50 行的工具类,负责"从网络下载图片 → 然后把图片设置到控件上展示出来"。

三个月后为了省流量加了内存缓存,半年后为了避免重启重新下载又加了磁盘缓存,一年后业务方要求线上可以自定义缓存策略(有的页面走内存,有的页面走二级缓存,甚至有的页面要走 CDN 预加载)。

最终这个类变成了 1200 行的"上帝类":if (useMemory) ... else if (useDisk) ... else if (useDouble) ... else if (useCdn) ...,任何一次需求改动都要在这棵 if-else 森林里小心翼翼地打补丁,每改一次就要提心吊胆地全量回归测试。

这就是真实世界里"小需求把代码堆烂"的典型过程,不是哪个端特有的问题,是所有终端开发都会踩的坑。

# 1.2 烂代码深层根源

如果只说"代码烂",这是现象。烂背后是三个递进的原因:

1.设计者不知道该什么时候抽象:第一版只是"下载 + 设 UI",看不出什么要抽;添加内存缓存时也看不出;直到第三种缓存出现,才发现需要抽,这时已经走入了“在破东西上面加补丁”的随处联环。

2.需求变化被误读为“应在原代码中继续套”:“加个 if 就能进走不同逻辑”表面上能唬人,但它把变化点填进了主流程代码。

3.上下游调用者被隐性耦合:你改 ImageLoader 里一段 if (useDouble),以为只动了一个页面,实际上所有用 ImageLoader 的页面都要重测试。

# 1.3 本篇待解之难题

本篇要解决的问题是:这同一段图片加载代码,如果从一开始就知道六大原则,它会长成什么样?为什么那样写就能"稳得住"未来三年的需求变化?

本篇先给一个总览,后面 7 篇逐一深挖每条原则。读完本篇,你至少能回答:六大原则"各自关心什么、彼此怎么配合、在实际项目中怎么落地"。

# 2.六大原则总览

mindmap
  root((六大原则))
    SRP 单一职责
      一个类的功能要单一
    OCP 开闭原则
      对扩展开放对修改关闭
    LSP 里氏替换
      子类可替父类
    DIP 依赖倒置
      依赖抽象不依赖细节
    ISP 接口隔离
      接口小而专
    LOD 迪米特
      只和直接朋友说话
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • 单一职责原则 SRP:一个类的功能要单一,不能包罗万象。
  • 开闭原则 OCP:一个模块在扩展性方面应是开放的,在修改性方面应是封闭的。
  • 里氏替换原则 LSP:子类应当可以替换父类,并出现在父类能够出现的任何位置。
  • 依赖倒置原则 DIP:具体依赖抽象,上层依赖下层的抽象接口。
  • 接口隔离原则 ISP:模块间要通过抽象接口隔开,而不是通过具体的类强行耦合。
  • 迪米特法则 LOD:一个实体应当尽量少地与其他实体发生相互作用,使得系统功能模块相对独立。

下面结合同一个 ImageLoader 案例,演示六大原则如何一步步把代码从"上帝类"救出来。

# 3.单一职责原则

# 3.1 原则定义解读

单一职责原则(Single Responsibility Principle,SRP):一个类应该仅有一个引起它变化的原因。说白了就是一个类中应该是"一组相关性很高的函数和数据"的封装。

"如何划分职责"是最容易引起争论的问题,并没有绝对的标准,通常靠经验和业务上下文判断。但有一条基本指导原则:两个完全不相关的功能,不要放在一个类里。

# 3.2 图片加载实现法

小杨接到第一个需求:实现图片加载,并把图片缓存起来。十分钟后小杨写下这样的代码:

public class ImageLoader {
    private LruCache<String, Bitmap> cache;
    private ExecutorService executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

    public ImageLoader() {
        int max = (int) (Runtime.getRuntime().maxMemory() / 1024);
        cache = new LruCache<>(max / 4);
    }

    public void displayImage(String url, ImageView view) {
        view.setTag(url);
        executor.submit(() -> {
            Bitmap bmp = download(url);                       // 职责 1:下载
            if (bmp != null && url.equals(view.getTag())) {
                view.setImageBitmap(bmp);                     // 职责 2:设到 UI
                cache.put(url, bmp);                          // 职责 3:缓存
            }
        });
    }

    private Bitmap download(String url) { /* HTTP 下载 */ return null; }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

主管一看就指出问题:"下载、缓存、UI 绑定"三件事挤在一个类里,以后任何一个部分变动都要动这个类。于是小杨把缓存拆出来:

// 专注"缓存"职责
public class ImageCache {
    private final LruCache<String, Bitmap> lru;
    public ImageCache() {
        int max = (int) (Runtime.getRuntime().maxMemory() / 1024);
        lru = new LruCache<>(max / 4);
    }
    public Bitmap get(String url) { return lru.get(url); }
    public void put(String url, Bitmap bmp) { lru.put(url, bmp); }
}

// 专注"加载"职责
public class ImageLoader {
    private final ImageCache cache = new ImageCache();
    private final ExecutorService executor = Executors.newFixedThreadPool(4);

    public void displayImage(String url, ImageView view) {
        Bitmap bmp = cache.get(url);
        if (bmp != null) { view.setImageBitmap(bmp); return; }
        view.setTag(url);
        executor.submit(() -> {
            Bitmap b = DownloadManager.download(url);
            if (b != null && url.equals(view.getTag())) {
                view.setImageBitmap(b);
                cache.put(url, b);
            }
        });
    }
}

//  专注"下载"职责
public class DownloadManager {
     public static Bitmap download(String url) { /* ... */ return null; }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

拆完后 ImageLoader 只关心"怎么加载",ImageCache 只关心"怎么缓存"。缓存算法未来改成 LFU 也好、接入磁盘也好,都不会影响 ImageLoader 一行代码,这就是 SRP 的价值。

# 3.3 为何必须拆开来

可能你会问:原来一个类也能跑,什么拆开才是"必须"的?三个原因:

1.变化频率不同:下载逻辑、缓存策略、UI 绑定这三件事变化频率及原因都不同。拆开以后,改缓存不会影响下载,改 UI 不会影响下载。

2.测试范围不同:拆开以后缓存能独立单测,ImageLoader 能护住不被缓存逻辑炼到。

3.复用性不同:ImageCache 在别的需求中可以独立复用(比如、你可能会调用东西不仅仅带图片,还可能调用资源。那样可以调用 ImageCache 。 )。

这三个原因任何一个成立,都足以拆开。不是为了 SRP 而 SRP,是为了判断为什么要拆。

# 4.开闭原则之详解

# 4.1 原则定义解读

开闭原则(Open-Closed Principle,OCP):对扩展开放,对修改关闭。产品一定会变,但变化应当通过"加新类"而不是"改老类"来实现。

# 4.2 磁盘缓存的添加

用户反馈"重启后缓存就没了",小杨加了磁盘缓存,顺手加个开关:

public class ImageLoader {
    private ImageCache memCache = new ImageCache();
    private DiskCache  diskCache = new DiskCache();
    private boolean useDisk = false;
    private boolean useDouble = false;

    public void displayImage(String url, ImageView view) {
        Bitmap bmp;
        if (useDouble)      bmp = doubleGet(url);    // if-else 开始繁殖
        else if (useDisk)   bmp = diskCache.get(url);
        else                bmp = memCache.get(url);
        if (bmp != null) view.setImageBitmap(bmp);
        // else 下载...
    }
    public void useDiskCache(boolean v)   { useDisk = v; }
    public void useDoubleCache(boolean v) { useDouble = v; }
    private Bitmap doubleGet(String url) { /* ... */ return null; }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

主管又来了:每次加一种缓存就要改 ImageLoader,用户还没法自定义缓存。问题的根源是:ImageLoader 和"具体的缓存实现"绑死了。解法是把"缓存"抽象成接口:

// 抽象:缓存规范
public interface ImageCache {
    Bitmap get(String url);
    void put(String url, Bitmap bmp);
}

// 实现 1:内存
public class MemoryCache implements ImageCache { /* ... */ }

// 实现 2:磁盘
public class DiskCache implements ImageCache { /* ... */ }

// 实现 3:双级(组合内存 + 磁盘)
public class DoubleCache implements ImageCache {
    private final ImageCache mem = new MemoryCache();
    private final ImageCache disk = new DiskCache();
    public Bitmap get(String url) {
        Bitmap b = mem.get(url);
        return b != null ? b : disk.get(url);
    }
    public void put(String url, Bitmap bmp) { mem.put(url, bmp); disk.put(url, bmp); }
}

// ImageLoader 依赖抽象
public class ImageLoader {
    private ImageCache cache = new MemoryCache();
    public void setImageCache(ImageCache c) { this.cache = c; }
    public void displayImage(String url, ImageView view) {
        Bitmap b = cache.get(url);
        if (b != null) view.setImageBitmap(b);
        // else 下载 + cache.put...
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

用法变得极简:

ImageLoader loader = new ImageLoader();
loader.setImageCache(new MemoryCache());
loader.setImageCache(new DiskCache());
loader.setImageCache(new DoubleCache());
loader.setImageCache(new ImageCache() { /* 用户自定义 */
    public Bitmap get(String url) { return null; }
    public void put(String url, Bitmap bmp) { }
});
1
2
3
4
5
6
7
8

至此 ImageLoader 再也不会因为"新增缓存策略"而被修改,这就是 OCP。

# 4.3 抽象隔离变化

这里背后的逻辑是:变化总会发生,问题不是"防变化"而是"压住变化的范围"。原来一个 if-else 迫使 ImageLoader 重新编译、重新测试;抽象之后,变化被仅限缩在“新加一个类”这件事上。

同时有一个必要提醒:OCP 不是说"一行原代码都不能动"。“在一个新文件里加一个 XxxCache implements ImageCache” 是 “扩展”,是 OCP 鼓励的。“displayImage 里增加一个 if (useXxx)” 是 “修改”,是 OCP 鼓励避免的。区别在于:动不动主流程代码、需不需要重跑原有回归、会不会拖别人下水。

# 5.里氏替换原则

# 5.1 原则定义解读

里氏替换原则(Liskov Substitution Principle,LSP):所有引用基类的地方,必须能透明地替换为子类的对象。一句话:子类替父类,行为不能变坏。

LSP 与多态相伴相生,没有多态就谈不上 LSP,但有多态不等于自动满足 LSP:子类能不能"无痛"地替父类上阵,取决于它有没有守住父类的契约。

# 5.2 LSP看缓存体系

接续 OCP 的 ImageCache 接口体系,现在有 MemoryCache、DiskCache、DoubleCache 三个实现。只要每个子类都老老实实地实现 get/put 的语义(put 后应当能 get 出来),ImageLoader 就不用关心底层是哪一种缓存:

// 所有缓存实现都必须能"无痛"替换 ImageCache
ImageCache cache = new MemoryCache();    // ✅
cache = new DiskCache();                 // ✅
cache = new DoubleCache();               // ✅
loader.setImageCache(cache);             // 调用方毫无感知
1
2
3
4
5

这正是 OCP 得以成立的前提,子类能安全替换父类。

# 5.3 违反 LSP 的案例

后来业务方提了一个奇葩需求:"某个运营活动页面,缓存不能写磁盘,因为该页面的图片都带有时效性水印,存盘反而会造成下次展示错误的图。"小杨觉得这很简单,写一个 NoCache 实现就完了:

// ❌ 违反 LSP:看似实现了接口,实则破坏了 put 的契约
public class NoCache implements ImageCache {
    public Bitmap get(String url) { return null; }     // 永远返回 null
    public void put(String url, Bitmap bmp) {
        // 什么都不做,欺骗调用者
    }
}
1
2
3
4
5
6
7

编译通过,测试"看起来"也没问题(毕竟活动页本来就不缓存)。但上线两周后,一个复用了 ImageLoader 的普通商品详情页也收到了这个 NoCache 实例,原因是某个新同学在配置缓存策略时复制错了代码。商品详情页每次进入都要重新下载图片,用户投诉"刷一下图就闪一下"。

问题出在哪?NoCache 表面上实现了 ImageCache 的两个方法,却破坏了 put 的隐含契约,"存进去的图,get 应该能取出来"。更隐蔽的是,它把 get 永远返回 null,这意味着 ImageLoader 里所有依赖 cache.get(url) != null 来跳过下载的逻辑全部失效。调用方按父类契约使用,但拿到了一个违背契约的子类,这正是 LSP 违反的经典表现。

正确的做法不是写一个"假实现",而是把"不缓存"当作缓存策略的合法变体,但仍要守住 return value 的语义:

// ✅ 遵循 LSP:用一个极短 TTL 的内存缓存代替"不缓存",语义仍正确
public class TransientNoStoreCache implements ImageCache {
    private final Map<String, Bitmap> map = new HashMap<>();
    public Bitmap get(String url) { return map.get(url); }    // put 过就能取到
    public void put(String url, Bitmap bmp) { map.put(url, bmp); }
}
1
2
3
4
5
6

或者更彻底地,不在 ImageCache 层级上处理这个需求,而是在 ImageLoader 上加一个 cachePolicy 开关,让"不缓存"成为流程控制而非缓存实现。但无论如何,不要在接口的语义上撒谎,这就是 LSP 发出的警告。

LSP 的本质是契约守护,后续《04.里式替换原则介绍》一篇会专门讲契约的四条细则。

# 6.依赖倒置原则

# 6.1 原则定义解读

依赖倒置原则(Dependency Inversion Principle,DIP):1.高层模块不应该依赖低层模块,两者都应该依赖其抽象;2.抽象不应该依赖细节,细节应该依赖抽象。

一句话:面向接口编程,不要面向实现编程。

# 6.2 通俗快速理解

用最通俗的话说就是:"不要依赖具体的东西,要依赖抽象的东西"。

案例1:点外卖

❌ 坏的设计(违反 DIP):小明饿了,他直接打电话给麦当劳。小明 → 麦当劳(具体店铺),问题:如果麦当劳关门了 → 小明饿肚子;如果小明想吃肯德基 → 得重新记肯德基的电话

✅ 好的设计(符合 DIP):小明饿了,他打开美团外卖(抽象平台),小明 → 美团外卖(抽象平台)← 麦当劳、肯德基、沙县小吃,小明只需要知道"美团外卖"这个抽象平台,不需要记住每家店的具体信息。想换啥吃就换啥吃!

案例2:充电器

❌ 坏设计:你买了一根华为P40专用充电线,只能给华为P40充电,换个小米手机就用不了。

✅ 好设计:你买了一根Type-C充电线(抽象标准),华为手机能充,小米手机能充。Type-C接口就是"抽象",不管什么牌子,只要接口一样就能用。

传统方向:手机 ——> 依赖 ——> 华为专用充电器。手机说:"我只能用华为P40的充电器。"

倒置之后:

手机 ——————> 依赖 ——> Type-C接口(抽象)
 ↑
华为充电器 ——> 依赖 ——> Type-C接口(抽象)
小米充电器 ——> 依赖 ——> Type-C接口(抽象)
苹果充电器 ——> 依赖 ——> Type-C接口(抽象)
1
2
3
4
5

倒置在哪里?以前:充电器是"被需要的",手机依赖充电器。现在:充电器反过来"需要符合Type-C标准",充电器依赖接口

依赖的方向从"手机→充电器"变成了"充电器→接口"。

核心思想就一句话:不要和具体的东西绑定死,要依赖一个"标准"或"接口",这样换起来才方便。

# 6.3 为何称之为倒置

"正常"的依赖方向是:高层用到低层,高层就 new 一个低层。引入抽象后,低层反过来去实现高层定义的接口,依赖箭头被"倒"了一下。

flowchart LR
    subgraph 倒置前
        A1[ImageLoader 高层] -->|直接 new| B1[MemoryCache 低层]
    end
    subgraph 倒置后
        A2[ImageLoader 高层] -->|依赖| I[ImageCache 抽象]
        B2[MemoryCache 低层] -.实现.-> I
        B3[DiskCache 低层] -.实现.-> I
        B4[DoubleCache 低层] -.实现.-> I
    end
1
2
3
4
5
6
7
8
9
10

传统上,高层依赖低层(从上到下);倒置后,高层和低层都依赖抽象,低层反过来要去适配抽象,依赖的方向反了,所以叫"倒置"。

# 6.4 图片加载的倒置实战

OCP 那一节的最终版 ImageLoader 其实就已经满足了 DIP,它持有的是 ImageCache 抽象,而不是任何具体类。但在这个结果出现之前,ImageLoader 经历了一次关键的重构拐点。回到 OCP 里那个"加磁盘缓存加出 if-else"的版本,看看 DIP 是如何一步到位解决问题的。

倒置前:高层直接依赖低层

// ❌ 违反 DIP:ImageLoader 直接依赖三个具体类
public class ImageLoader {
    private MemoryCache memCache = new MemoryCache();   // 紧耦合
    private DiskCache  diskCache = new DiskCache();     // 紧耦合

    public void displayImage(String url, ImageView view) {
        Bitmap bmp;
        if (useDouble)       bmp = doubleGet(url);
        else if (useDisk)    bmp = diskCache.get(url);
        else                 bmp = memCache.get(url);
        // ...
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

问题一目了然:ImageLoader(高层业务逻辑)不仅依赖了 MemoryCache、DiskCache 这些低层实现,还在主流程里用 if-else 亲自决定用哪一个,这是双重耦合。每加一种缓存就要改两处:① 加一个 if 分支,② 加一个具体类的成员变量。

倒置后:高层和低层都依赖抽象,且抽象由高层定义

// ✅ 遵循 DIP:ImageLoader 只依赖 ImageCache 抽象
public class ImageLoader {
    private ImageCache cache;                     // 依赖抽象
    public void setImageCache(ImageCache c) {     // 依赖注入
        this.cache = c;
    }
    public void displayImage(String url, ImageView view) {
        Bitmap bmp = cache.get(url);              // 不知道底层是谁
        // ...
    }
}
1
2
3
4
5
6
7
8
9
10
11

这里有一个容易被忽视的细节:ImageCache 这个接口是谁定义的?是 ImageLoader 所在的高层模块定义的,它只声明了 get / put 两个方法,因为业务侧只需要这两个能力。低层的 MemoryCache、DiskCache、DoubleCache 都是反过来实现高层定义的接口的。这就是"倒置"二字的真正含义,不是业务求着缓存库,而是缓存库适配业务。

DIP 是 OCP 能够成立的技术基础,这两条原则通常一起出现。想要"新增加一个缓存策略不修改 ImageLoader",前提就是 ImageLoader 手里拿的是抽象而不是具体类,这正是 DIP 的价值。

# 7.接口隔离原则

# 7.1 原则定义解读

接口隔离原则(Interface Segregation Principle,ISP):客户端不应该依赖它不需要的接口。换一种说法:类间的依赖关系应该建立在最小的接口上。

ISP 的目的是:让大接口拆成若干小接口,客户端只感知它关心的那部分,从而降低耦合、便于重构。

# 7.2 CloseUtils的最小接口哲学

Java 6 时代关资源要写一大串 try-finally,小杨把它封装成工具:

public final class CloseUtils {
    private CloseUtils() {}

    public static void closeQuietly(Closeable c) {
        if (c == null) return;
        try { c.close(); } catch (IOException ignored) {}
    }
}
1
2
3
4
5
6
7
8

这里 closeQuietly 只依赖 Closeable 这一个最小接口,它不关心 FileOutputStream 有 write 方法、不关心 Socket 有 shutdown 方法,它只要"能关闭"。Closeable 就是一个被隔离到极致的角色接口。

# 7.3 ImageLoader的ISP实战

回到 ImageCache 接口。经过 OCP 和 DIP 的改造,它目前只有 get(key) 和 put(key, bmp) 两个方法,这本身就是 ISP 的良好实践。但假设在项目演进过程中,负责缓存模块的同学为了"集中管理",给 ImageCache 不断追加能力:

// ❌ 违反 ISP:大而全的胖接口
public interface ImageCache {
    Bitmap get(String url);
    void put(String url, Bitmap bmp);

    // 运营需求:统计缓存命中率
    long getHitCount();        // 只有后台管理页面才需要
    float getHitRate();

    // 性能需求:缓存预热
    void preload(List<String> urls);   // 只有启动页才需要

    // 清理需求:手动清除
    void clearAll();           // 只有设置页"清理缓存"才需要
    void clearByPattern(String pattern);

    // 调试需求:导出缓存信息
    String dumpCacheInfo();    // 只有开发者面板才需要
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

这样一来,ImageLoader 这个只需 get / put 的调用方,被迫"看见"了 8 个方法,而它实际上只用到其中 2 个。更致命的是,MemoryCache 需要实现全部 8 个方法,其中 getHitRate、dumpCacheInfo 等方法跟"内存缓存"的职责完全无关,却不得不写空实现。

按 ISP 的重构方向,按调用者角色拆接口:

// ✅ 遵循 ISP:按调用者角色拆成小接口
public interface CacheAccess {
    Bitmap get(String url);
    void put(String url, Bitmap bmp);
}

public interface CacheStatistic {
    long getHitCount();
    float getHitRate();
}

public interface CachePreload {
    void preload(List<String> urls);
}

public interface CacheCleaner {
    void clearAll();
    void clearByPattern(String pattern);
}

public interface CacheDebug {
    String dumpCacheInfo();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

重构后:

  • ImageLoader 只依赖 CacheAccess,它只宣言自己需要的两个方法;
  • MemoryCache 只需实现它真正支持的接口,不再被"预热""清空"等无关能力绑架;
  • 缓存统计页面单独依赖 CacheStatistic,管理员面板只依赖 CacheCleaner + CacheDebug。

这就是 ISP 的精髓:不是"接口越小越好",而是"谁用什么,就只给它什么"。接口隔离的本质是按调用者角色切割依赖,ImageCache 之所以只保留 get / put,正是因为它的唯一调用者 ImageLoader 只需要这两项能力。

# 8.迪米特法则

# 8.1 原则定义解读

迪米特法则(Law of Demeter,LOD,也称最少知识原则 LKP):一个对象应该对其他对象有最少的了解。

更形象的说法是:Only talk to your immediate friends,只和直接朋友说话,不要和陌生人说话。

# 8.2 ImageLoader中的LOD实战

回到 ImageLoader 案例。现在 ImageCache 是多实现体系,DoubleCache 内部组合了 MemoryCache + DiskCache。有一天,业务方要展示"缓存占用磁盘空间",于是有人在调用方写下了这样的代码:

// ❌ 违反 LOD:调用者穿透了 4 层内部结构
ImageLoader loader = getLoader();
long diskSize = loader.getCache()           // ImageCache
                     .getDiskCache()         // DiskCache(只有 DoubleCache 才有)
                     .getFileStorage()       // FileStorage(DiskCache 的内部实现)
                     .getUsedSpace();        // 最终数据
1
2
3
4
5
6

这行代码的问题不仅在于"链太长",更在于调用者知道了它不该知道的东西:

  • 它假设 ImageCache 一定是 DoubleCache(如果换成纯内存缓存,.getDiskCache() 就崩了);
  • 它假设 DoubleCache 内部有 DiskCache(万一未来改成 RemoteCache 呢?);
  • 它假设 DiskCache 内部用 FileStorage(万一改成 SQLite 存储呢?)。

任何一层内部结构变化,这行代码就要改,而它只是一个展示"磁盘空间"的需求。

遵循迪米特法则的改法,在直接朋友上暴露业务语义的方法:

// ✅ 让 ImageLoader 封装内部结构
public class ImageLoader {
    private ImageCache cache;

    public long getDiskUsage() {             // 只暴露业务意图
        if (cache instanceof DiskUsageProvider) {
            return ((DiskUsageProvider) cache).getUsedBytes();
        }
        return 0;
    }
}

// 让该提供这个信息的缓存实现自己声明
public interface DiskUsageProvider {
    long getUsedBytes();
}

// DiskCache 选择实现这个角色接口
public class DiskCache implements ImageCache, DiskUsageProvider {
    public long getUsedBytes() { return fileStorage.getUsedSpace(); }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

调用方变成一行:

long diskSize = loader.getDiskUsage();   // 干净,不穿透
1

同样的思想贯彻到整个 ImageLoader 生态:上层业务只认 ImageLoader 和 ImageCache,根本不知道底下是 DiskLruCache 还是 OkHttp 缓存在顶班。底层实现随便换,上层毫无感知,这就是 LOD 带来的稳定性。"调用者只和最直接的朋友说话,朋友的朋友不该被你直接碰到"。

# 9.SOLID的由来

# 9.1 设计原则的起源

20 世纪 60-70 年代,随着软件规模急剧膨胀,出现了"软件危机",项目延期、预算超支、Bug 难以修复成为常态。1968 年 NATO 软件工程会议首次正式提出"软件工程"概念,标志着人们开始系统性地思考如何写出"好"代码。

从结构化编程(Dijkstra, 1968)→ 面向对象编程(Simula, 1967;Smalltalk, 1972)→ 设计原则(1980s-2000s),软件工程走了一条"关注计算 → 关注结构 → 关注变化"的演进路径。

timeline
    title 设计原则历史节点
    1967 : Simula 诞生 面向对象思想萌芽
    1979 : Meyer 提出契约式设计
    1986 : Liskov 提出里氏替换原则 LSP
    1988 : Meyer 在 OOSC 中正式提出 OCP
    1996 : Robert C. Martin 提出 DIP ISP
    2000 : Martin 将五原则合称为 SOLID
    2003 : 敏捷开发原则模式与实践 出版
1
2
3
4
5
6
7
8
9

# 9.2 SOLID命名由来

SOLID 是五个设计原则英文首字母的缩写,由 Robert C. Martin("Uncle Bob")在 2000 年前后整合提出:

字母 原则 英文全称 提出者 年份
S 单一职责 Single Responsibility Principle Robert C. Martin 2003
O 开闭原则 Open-Closed Principle Bertrand Meyer 1988
L 里氏替换 Liskov Substitution Principle Barbara Liskov 1986
I 接口隔离 Interface Segregation Principle Robert C. Martin 1996
D 依赖倒置 Dependency Inversion Principle Robert C. Martin 1996

迪米特法则(Law of Demeter,1987)不属于 SOLID,但与之并称为"六大原则"。

# 10.六原则关系图谱

# 10.1 原则的协同关系

flowchart TD
    OCP[OCP 开闭原则<br/>所有原则的终极目标]
    LSP[LSP 里氏替换]
    DIP[DIP 依赖倒置]
    ISP[ISP 接口隔离]
    SRP[SRP 单一职责<br/>最基础的原则]
    LOD[LOD 迪米特<br/>模块协作纪律]

    SRP --> LSP --> OCP
    SRP --> DIP --> OCP
    SRP --> ISP --> OCP
    LOD --> OCP
1
2
3
4
5
6
7
8
9
10
11
12

# 10.2 设计逻辑的全链

  1. SRP 是基石:先把类拆"小",每个类只做一件事;
  2. LSP 是手段:保证子类能安全替换父类;
  3. ISP 是约束:接口精简,不强迫依赖不需要的方法;
  4. DIP 是架构:高层不依赖低层,都依赖抽象;
  5. OCP 是目标:系统能通过扩展而非修改来演进;
  6. LOD 是纪律:模块间最少知识,降低耦合。

# 10.3 一句话速记口诀

原则 一句话核心 关键词
SRP 一个类只因一个原因而变化 职责单一
OCP 新增功能靠扩展,不靠修改 抽象扩展
LSP 子类替父类,行为不变 契约守护
ISP 接口小而专,不强迫实现 精简隔离
DIP 依赖抽象,不依赖实现 面向接口
LOD 只和直接朋友通信 最少知识

# 11.原则的度量之道

# 11.1 坏味道与原则表

代码坏味道 可能违反的原则 改进方向
一个类超过 500 行 SRP 拆成多个职责单一的类
新增功能要改多处旧代码 OCP 引入抽象层和扩展点
子类覆写父类方法后行为异常 LSP 重新设计继承关系
实现接口时有大量空方法 ISP 将大接口拆分为小接口
高层类直接 new 低层类 DIP 引入接口和依赖注入
一个类需要了解多个不相关类的细节 LOD 引入中介者或门面

# 11.2 过度设计信号

  • 简单功能拆成十几个类,看代码需要来回跳转;
  • 所有地方都抽了接口,但每个接口只有一个实现;
  • 用了很多设计模式,但说不清"为什么要用";
  • 代码"很优雅"但新人完全看不懂。

平衡三原则:

  • KISS(Keep It Simple, Stupid):保持简单;
  • YAGNI(You Ain't Gonna Need It):你不会需要它;
  • Rule of Three:重复三次再抽象。

# 11.3 原则冲突与权衡

SRP 要求拆分   ←→   拆多了增加复杂度
OCP 不修改    ←→   简单修改有时比抽象更合理
ISP 拆接口   ←→   拆多了增加维护成本
DIP 依赖抽象 ←→   简单场景直接依赖实现更清晰
1
2
3
4

设计原则是指导方针,不是法律条文。最好的设计是刚刚好的设计。

# 12.原则工业实践

# 12.1 Spring中SOLID

原则 Spring 中的体现
SRP @Controller / @Service / @Repository 分层,每层职责单一
OCP BeanPostProcessor 提供扩展点,不改核心就能扩展
LSP 任何 ApplicationContext 子类都可以替换父类
ISP BeanFactory(基础)vs ApplicationContext(增强)分层接口
DIP 核心机制就是依赖注入(@Autowired)
LOD IoC 容器管理对象关系,组件之间不直接创建依赖

# 12.2 Unix哲学与原则

Do one thing and do it well          → SRP
Write programs to work together      → ISP + LOD
Everything is a file                 → DIP(统一文件抽象)
管道机制 ls | grep | sort             → OCP(通过组合扩展功能)
1
2
3
4

# 13.开篇案例再回顾

回头看文章开头那个 1200 行的"图片加载上帝类",套用六大原则再看一遍,问题就一清二楚了:

症状 违反的原则 解法
下载、缓存、回调、UI 设置全挤在一起 SRP 拆成 Downloader / Cache / ImageBinder 三件事
每加一种缓存都要改 if-else OCP 把缓存抽象成接口,新策略"加类不改类"
某些子类的缓存悄悄改变父类语义 LSP 统一 Cache.get/put 契约,子类不能破坏
ImageCache 一次塞了清理、统计、预热等上层不关心的方法 ISP 按角色拆成若干小接口
ImageLoader 里 new MemoryCache() 硬编码 DIP 通过构造函数/Setter 注入 Cache 抽象
出现 loader.getCache().getDisk().getFile().getPath() LOD 在 loader 上提供聚合方法,内部细节不外泄

六大原则不是六块独立的规则,它们是从六个角度检查同一段代码的六把尺子。一段好代码,六把尺子都量得过去。

# 14.核心收获总结

  • 一张知识地图:知道 SOLID + LOD 各自在解决什么、彼此怎么协作、谁是基础、谁是目标、谁是手段、谁是纪律。
  • 一个诊断工具:再看自己项目里的代码,能说出它"违反了哪条原则、应该往哪个方向改",而不是只会说"感觉乱"。
  • 一条学习主线:接下来 7 篇每篇只聚焦一个原则,节奏是"工作案例 → 原则 → 由浅入深演进 → 回到案例 → 思考题 → 作业",每篇都围绕同一类终端开发场景展开,读完能形成闭环。

# 15.课后思考与练习

  1. 识别题:手头任意一段超过 300 行的类,列出它"有几个会引起它变化的外部原因"。如果超过 1 个,它违反了哪条原则?
  2. 辨析题:LSP 和多态是同一件事吗?为什么有多态的语言(Java/Kotlin)仍然需要 LSP 作为单独的原则?提示:从"语法"和"契约"两个角度思考。
  3. 权衡题:如果一个功能全项目只会有一种实现,未来几乎确定不会变,还值得为它抽一层接口(DIP)吗?结合 YAGNI 给出你的判断标准。

# 16.课后实战练习

在你当前项目里找一段自己或同事写的、自己都觉得"有点乱" 的代码(建议 200~500 行的类),完成下面三步:

  1. 画一张依赖图:这段代码依赖了哪些类、被哪些类依赖,画成箭头图。
  2. 打一份六原则体检表:对 SRP / OCP / LSP / ISP / DIP / LOD 各写一句"它在这段代码里被满足 / 被违反了,证据是哪一行"。
  3. 只选一条原则做一次最小重构:不要一次全改,只挑违反最明显的那一条,用本篇提到的手段做最小改动,然后跑一遍原有测试,确认行为不变。

做完这 3 步,再进入下一篇《02.单一职责原则详解》,体验"把拆分这一步做到位"的感觉。

上次更新: 2026/06/17, 11:43:57
README
单一职责原则详解

← README 单一职责原则详解→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式