面向对象六大原则
# 第二卷第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 迪米特
只和直接朋友说话
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; }
}
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; }
}
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; }
}
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...
}
}
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) { }
});
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); // 调用方毫无感知
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) {
// 什么都不做,欺骗调用者
}
}
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); }
}
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接口(抽象)
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
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);
// ...
}
}
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); // 不知道底层是谁
// ...
}
}
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) {}
}
}
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(); // 只有开发者面板才需要
}
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();
}
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(); // 最终数据
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(); }
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
调用方变成一行:
long diskSize = loader.getDiskUsage(); // 干净,不穿透
同样的思想贯彻到整个 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 : 敏捷开发原则模式与实践 出版
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
2
3
4
5
6
7
8
9
10
11
12
# 10.2 设计逻辑的全链
- SRP 是基石:先把类拆"小",每个类只做一件事;
- LSP 是手段:保证子类能安全替换父类;
- ISP 是约束:接口精简,不强迫依赖不需要的方法;
- DIP 是架构:高层不依赖低层,都依赖抽象;
- OCP 是目标:系统能通过扩展而非修改来演进;
- 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 依赖抽象 ←→ 简单场景直接依赖实现更清晰
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(通过组合扩展功能)
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.课后思考与练习
- 识别题:手头任意一段超过 300 行的类,列出它"有几个会引起它变化的外部原因"。如果超过 1 个,它违反了哪条原则?
- 辨析题:LSP 和多态是同一件事吗?为什么有多态的语言(Java/Kotlin)仍然需要 LSP 作为单独的原则?提示:从"语法"和"契约"两个角度思考。
- 权衡题:如果一个功能全项目只会有一种实现,未来几乎确定不会变,还值得为它抽一层接口(DIP)吗?结合 YAGNI 给出你的判断标准。
# 16.课后实战练习
在你当前项目里找一段自己或同事写的、自己都觉得"有点乱" 的代码(建议 200~500 行的类),完成下面三步:
- 画一张依赖图:这段代码依赖了哪些类、被哪些类依赖,画成箭头图。
- 打一份六原则体检表:对 SRP / OCP / LSP / ISP / DIP / LOD 各写一句"它在这段代码里被满足 / 被违反了,证据是哪一行"。
- 只选一条原则做一次最小重构:不要一次全改,只挑违反最明显的那一条,用本篇提到的手段做最小改动,然后跑一遍原有测试,确认行为不变。
做完这 3 步,再进入下一篇《02.单一职责原则详解》,体验"把拆分这一步做到位"的感觉。