设计模式修炼之路 ㉓ 空对象模式——让判空从代码里消失
非 GoF、用空对象消灭 null 判断、十亿美元错误、NOPLogger 与 emptyList、空对象 vs Optional
目录
行为型第十站,非 GoF 补遗。
上一篇结尾埋了个雷:聊天室查无此人,findMember 返回 null——调用方就得 if (member != null) 判一路空,判空代码像癣一样长满全身,漏判一处就是 NullPointerException,号称"十亿美元错误"。
能不能造一个"什么都不做的对象"顶替 null,让判空彻底从代码里消失?——能,这就是空对象模式。
一、空对象模式是什么
先交代出身:它不在 GoF 23 之列,1996 年由 Bobby Woolf 在 PLoP 论文里正式提出,后来收录进《Pattern Languages of Program Design 3》。因为和 GoF 行为型模式血缘太近、太常用,通常被当作"第 24 式"补进教科书。
Woolf 的定义:
Provide a surrogate for another object that shares the same interface but does nothing. The Null Object encapsulates the implementation decisions of how to "do nothing" and hides those details from its collaborators. (为另一个对象提供一个共享同样接口、但什么也不做的替身。空对象把"如何什么都不做"的实现决策封装起来,对协作者隐藏这些细节。)
精髓在后半句:"什么都不做"本身也是一种行为,也需要被设计、被封装。静默、返回原价、返回空集合——"没有"的行为定义清楚了,调用方才能毫无心理负担地直接调用。
角色:
| 角色 | 干什么 | 本文对应 |
|---|---|---|
| 抽象对象 | 定义真实对象与空对象共享的接口 | Member |
| 真实对象 | 正常的业务逻辑 | RealMember |
| 空对象 | 每个方法都是"合理的默认行为" | NullMember |
| 使用者(工厂) | 决定返回真实对象还是空对象 | ChatRoom |
和 ⑬ 代理模式形似神不似:代理是"转发给真家伙",空对象是"根本就没有真家伙"。
二、优点与缺点
优点:
- 判空代码整体消失:调用方对"对象不存在"毫无感知,代码里再也不会出现
if (x != null)的噪音。 - NPE 从机制上根除:不是靠"记得判空"的纪律,而是靠类型系统——返回值永远是合法对象,无从炸起。
- 默认行为集中管理:"查无此人时该怎么办"只在一个类里定义,改默认策略只改一处。
- 零开销:空对象无状态,全宇宙共享一个单例。
缺点:
- 静默可能掩盖问题:该炸的时候不炸——数据库连接丢失被静默吞掉,用户看到的只是"功能莫名没生效",排查难度指数级上升。
- "合理的默认行为"不好设计:
sendWelcome静默容易,getDiscount返回原价也成立,可"查无此人时的转账金额"呢?硬造一个 0 元空对象,那是灾难。 - 查询方法开始蔓延:
isPresent()这类口子一旦多起来,等于把判空换了个马甲重新长满全身。 - 接口负担:接口有二十个方法,空对象就得写二十个空实现——接口越大,空对象越像体力活。
三、什么时候用
判断标准:"对象不存在"时的行为可以定义成合理的默认值,且大多数调用方根本不关心这个区别。
- 日志:没绑定日志实现 → 空日志器静默吞掉一切
- 监听器/回调:没人注册监听器 → 空监听器
- 展示类旁路逻辑:头像没上传 → 默认头像,会员没查到 → 原价
- 集合返回值:查不到结果 → 空集合而非 null
- 测试替身:不想 mock 的依赖,塞个空对象让流程跑通
反面对照:"不存在"是业务错误(支付时用户查无此人)——该抛异常抛异常,空对象会把错误静默吞成事故;存在与否要走完全不同的主干分支——用 Optional 显式表达比空对象诚实;默认行为本身就说不清——先别急着造空对象,先补齐领域模型。
四、实现方式:聊天室查人
4.1 反面教材:null 的八百种死法
先看 ㉒ 结尾那个直球方案:
public class BadChatRoom {
private final Map<String, Member> members = new HashMap<>();
// 找不到返回 null——调用方自己看着办
public Member findMember(String name) {
return members.get(name);
}
}
一个方法返回 null,八百个调用方就有八百种死法。运行见证三种人:
========== 反面教材:null 的八百种死法 ==========
[欢迎] 欢迎回来,张三!
[折扣] 张三享 8 折
[翻车] NullPointerException!田七不在群里,代码炸了
[沉默] 王五不在群里,跳过欢迎流程
老实人判了空,安全,但判空噪音永久驻留;冒失鬼忘了判空,当场 NPE;更多的人写出第三种——判空加一句"跳过",业务代码里全是这种和业务无关的防御工事。null 最恶劣的地方在于:它把"对象可能不存在"这个事实变成调用方的无限连带责任——每个调用点都要自卫,漏一个就是生产事故。
4.2 正解:空对象登场
空对象本体——每个方法都给出"合理的默认行为":
public final class NullMember extends Member {
private static final NullMember INSTANCE = new NullMember();
private NullMember() {
}
public static NullMember getInstance() {
return INSTANCE;
}
@Override
public String getName() {
return "游客";
}
@Override
public void sendWelcome() {
// 静默:什么都不做,正是它的全部工作
}
@Override
public String getDiscount() {
// "没有会员"的语义就是不打折——默认行为在业务上完全成立
return "原价";
}
@Override
public boolean isPresent() {
return false;
}
}
工厂把契约从"可能返回 null"升级为"永远返回非 null":
public class ChatRoom {
// 永不返回 null:查无此人就给空对象
public Member findMember(String name) {
Member member = members.get(name);
return member != null ? member : NullMember.getInstance();
}
}
注意:判空没有消失,而是从八百个调用点收缩到这一个工厂方法里——只判一次,天下太平。运行:
========== 正解:空对象让判空消失 ==========
[欢迎] 欢迎回来,张三!
[折扣] 张三享 8 折
—— 田七的欢迎消息?没有。空对象静默处理,一行判空都不用写 ——
[折扣] 田七享 原价(非会员不打折,语义完美)
[查询] 张三在群里吗:true,田七在群里吗:false
田七查无此人:没有 if、没有 NPE、没有 else 噪音——sendWelcome 静默通过,getDiscount 给出"原价"这个业务上完全正确的默认值。真需要区分时,isPresent() 还在。
4.3 三处细节
细节一:单例 + 不可变。 final class、private 构造、静态 INSTANCE——空对象无状态,全宇宙一个实例就够,这是 ② 单例模式在 ㉓ 的返场。再往深想一层:所有空对象天然可共享,这又是 ⑫ 享元"共享细粒度对象"的极致形态——细粒度到内部一个字段都没有。
细节二:默认行为必须"业务上成立"。 getDiscount 返回"原价"而不是"0 折"——非会员不打折,语义严丝合缝;sendWelcome 静默——不给不存在的人发欢迎,天经地义。空对象的每个默认值都要经得起业务推敲:它不是"假装存在"(那是造假),而是"诚实地给出'没有'时的正确答案"。给不出来的场景(转账金额、订单状态),就是第三节的反面对照——别硬造。
细节三:isPresent 是口子,不是主流。 空对象不打算骗过所有人——关键路径依然能区分真身与替身。命名沿用了 Optional 的 isPresent 惯例(GoF 时代的写法是 isNull())。但要警惕:如果代码里到处都在 isPresent(),说明这个场景里"存在与否"就是主干分支——你把判空换了个马甲,该换 Optional 了。
五、在框架里找它
-
SLF4J 的 NOPLogger:只引入 slf4j-api、不绑定任何实现时,
getLogger返回的就是 NOPLogger——所有日志方法静默空实现。库代码一行判空不用写,绑定缺失从"崩溃"降级为"没日志"。这是空对象模式在工业级框架里最标准的应用。 -
Collections.emptyList() / emptyMap() / emptyIterator():JDK 官方空集合,不可变、全局单例。《Effective Java》专门一条讲"返回空集合,不要返回 null"——空集合就是集合界的空对象,
emptyList()就是它的工厂。 -
Optional:空对象的函数式进化——把"可能没有"编码进类型系统,编译器和 IDE 帮你盯着。它是第六节的主角。
-
反例:System.console():没有控制台的环境(后台进程、某些 IDE)里它返回 null,JDK 自己也逃不过判空泥潭。新代码遇到它照样得写
if (console != null)——历史 API 没赶上好时代,你的新 API 别再走老路。
六、空对象 vs Optional——两代判空歼灭战
同一个问题,两代答案。空对象是面向对象流派的 1996 年答案,Optional 是函数式流派的 2014 年(Java 8)答案:
| 维度 | 空对象模式 ㉓ | Optional |
|---|---|---|
| 出身 | Bobby Woolf,1996,面向对象 | Haskell Maybe / Scala Option 血统,Java 8 引入 |
| 调用方感知 | **无感知**,拿到就调 | **强制感知**,必须拆箱才能用 |
| 默认行为住在哪 | 空对象内部(内聚) | 调用方 `orElse` / `orElseGet`(分散) |
| 忘了处理会怎样 | 静默通过,可能掩盖 bug | 不处理就没法用值,问题前置暴露 |
| 适用场景 | "没有也无所谓"的旁路逻辑 | "没有就该走另一条路"的业务分支 |
| 典型风险 | 静默吞掉真错误 | `isPresent()+get()` 反模式,写成了换皮判空 |
选用口诀:旁路默认用空对象,主干分支用 Optional,业务错误直接抛异常——谁都别替你兜底。
Optional 内部其实藏着一个空对象:Optional.empty() 返回的就是全局单例的空 Optional,所有方法都是"合理的默认行为"——所以两者不是对手,是先后两代的同一血脉。
写在最后
- null 的原罪:把"可能不存在"变成八百个调用点的无限连带责任,漏判一处就是 NPE
- 空对象的手术:把"什么都不做"设计成正经行为,判空从八百处收缩到工厂一处
- 默认行为必须业务成立——给不出正确默认值的场景,空对象就是造假
- isPresent 是应急口子,用多了说明该换 Optional
- 和 ⑬ 代理的分界:代理转发给真家伙,空对象根本没有真家伙
㉓ 的 Member 有 VIP 与普通之分,折扣各不相同——好在行为简单,直接长在每个类自己身上,干净利落。下一篇情况反过来:一群类型稳定的对象,要面对频繁变化的一堆操作——发福利一种算法、做报表一种算法、风控审计又一种。把操作塞进每个类,加一种操作全体类都要改;写在类外,instanceof 分支满天飞。把"对一群对象做什么"从对象家里抽出来,装进访问者自己家,双分派穿针引线——访问者模式,下一篇见。