跳到主要内容

空对象模式-Null Object Pattern

序言​

写 Java 代码时,"空指针异常"(NullPointerException)几乎是每个人最熟悉的噩梦。为了防御它,我们养成了到处写 if (xxx != null) 的习惯:

Logger logger = createLogger();
if (logger != null) {
logger.info("系统启动");
}

如果 createLogger() 因为配置缺失返回了 null,这段代码只能"静默跳过"日志——而如果没有判空,直接崩溃。无论是哪种,null 都让调用方被迫额外处理"什么都没有"这个分支,代码被 if-else 塞满。

空对象模式(Null Object Pattern)给出了一种完全不同的思路:与其返回 null,不如返回一个"什么都不做"的对象。调用方不再需要判空——调用一个什么都不做的 NullLogger.info(),效果等同于什么都没发生,但代码干干净净。

定义​

空对象模式并不是 GoF 23 种经典设计模式之一,而是由 Bobby Woolf 在《Pattern Languages of Program Design》中提出的行为型模式:

The intent of a Null Object is to encapsulate the absence of an object by providing a substitutable alternative that offers suitable default do-nothing behavior.

空对象的意图是:通过提供一个可替换的、具有合适"空操作"默认行为的对象,来封装"对象的缺失"。

两个关键点:

  1. 可替换:空对象必须实现与真实对象相同的接口,客户端可以无缝使用;
  2. 空操作:空对象的方法实现是"什么都不做"(或提供安全的默认值),而不是抛异常。

结构​

让我们从一个问题开始​

假设我们的系统有一个日志接口,生产环境使用文件日志,但某些环境(如测试、无日志需求的环境)不需要日志。不用空对象模式:

public class LoggerFactory {

public static Logger getLogger(boolean loggingEnabled) {
if (loggingEnabled) {
return new FileLogger();
}
return null; // 不启用日志就返回 null
}
}

于是调用方必须判空:

Logger logger = LoggerFactory.getLogger(config.isLoggingEnabled());

// 到处都是判空
if (logger != null) {
logger.info("系统启动完成");
}
if (logger != null) {
logger.warn("配置项缺失");
}

问题显而易见:

  1. 每个调用点都要判空,代码冗余且容易遗漏(漏掉一处就崩);
  2. null 的含义模糊:是"没日志"还是"出错了"?无法区分;
  3. 新同事接手代码,不知道"哪些地方可能返回 null",空指针风险潜伏。

空对象模式的结构​

引入一个 NullLogger——它实现 Logger 接口,但所有方法都是空操作:

各个角色的职责如下:

  • AbstractClass / 接口(抽象类或接口):定义真实对象和空对象共有的行为契约,对应图中的 Logger。
  • RealObject(真实对象):实现真实业务逻辑,对应图中的 FileLogger。
  • NullObject(空对象):实现同一接口,但方法体为空操作或返回安全默认值,对应图中的 NullLogger。
  • 工厂(可选):在"无法提供真实对象"时返回空对象而不是 null,对应图中的 LoggerFactory。

关键设计:空对象与真实对象实现同一接口,客户端完全无感知。logger.info(...) 无论拿到的是 FileLogger 还是 NullLogger,代码都一模一样——差别只在"做了"还是"没做"。

代码实现​

我们来实现日志系统。

定义接口和真实对象​

public interface Logger {
void info(String message);

void warn(String message);

void error(String message);
}
public class FileLogger implements Logger {

@Override
public void info(String message) {
System.out.println("[INFO] " + message);
}

@Override
public void warn(String message) {
System.out.println("[WARN] " + message);
}

@Override
public void error(String message) {
System.out.println("[ERROR] " + message);
}
}

定义空对象​

public class NullLogger implements Logger {

// 所有方法都是空操作:调用后什么都不发生
@Override
public void info(String message) {
// do nothing
}

@Override
public void warn(String message) {
// do nothing
}

@Override
public void error(String message) {
// do nothing
}
}

定义工厂:返回空对象而不是 null​

public class LoggerFactory {

public static Logger getLogger(boolean loggingEnabled) {
if (loggingEnabled) {
return new FileLogger();
}
// 关键:返回空对象,而不是 null
return new NullLogger();
}
}

客户端使用​

public class Main {

public static void main(String[] args) {
// 开启日志的环境
Logger fileLogger = LoggerFactory.getLogger(true);
fileLogger.info("系统启动完成");
fileLogger.warn("配置项缺失");
fileLogger.error("数据库连接失败");

System.out.println("=========================");

// 不开启日志的环境:拿到的是 NullLogger,但代码与上面完全一致
Logger nullLogger = LoggerFactory.getLogger(false);
nullLogger.info("系统启动完成");
nullLogger.warn("配置项缺失");
nullLogger.error("数据库连接失败");
}
}

输出结果:

[INFO] 系统启动完成
[WARN] 配置项缺失
[ERROR] 数据库连接失败
=========================

核心收益:

  1. 客户端零判空——两段代码一模一样,只是第二个环境"什么都打印";
  2. 空对象完全消除了 NullPointerException 的风险;
  3. 日志功能开关的语义清晰:关 = NullLogger,而不是神秘的 null。

带默认行为的空对象​

空对象不一定是"完全空操作",也可以返回安全默认值:

public class NullUser implements User {

@Override
public String getName() {
return "未知用户"; // 安全默认值
}

@Override
public boolean isAdmin() {
return false; // 安全默认值
}

@Override
public void updateProfile(String bio) {
// 空操作:匿名用户无法修改资料
}
}

在用户系统中,UserRepository.findByUsername(...) 查无此人时返回 NullUser 而不是 null,业务代码就可以直接 user.getName() 而无需判空——"未知用户"也能正常走完业务流程。

在开源框架中的应用​

JDK:OutputStream.nullOutputStream() 的源码实现​

JDK 11 起,java.io.OutputStream 提供了官方空对象实现。从源码层面看,nullOutputStream() 返回的匿名 OutputStream 正是标准的空对象——实现同一接口、所有方法空操作(或安全的默认行为):

// java.io.OutputStream.nullOutputStream()(JDK 11+ 源码)
public static OutputStream nullOutputStream() {
return new OutputStream() {
// 空对象内部维护一个"已关闭"标记,保证契约安全
private volatile boolean closed;

private void ensureOpen() throws IOException {
if (closed) {
throw new IOException("Stream closed");
}
}

// 空操作:写入的字节全部静默丢弃
@Override
public void write(int b) throws IOException {
ensureOpen();
}

// 空操作:同样丢弃整段字节,但保留边界校验
@Override
public void write(byte[] b, int off, int len) throws IOException {
Objects.checkFromIndexSize(off, len, b.length);
ensureOpen();
}

@Override
public void close() {
closed = true;
}
};
}

对照空对象模式:

  • 接口(抽象类) = OutputStream;
  • 真实对象 = FileOutputStream、ByteArrayOutputStream 等——真正把字节写出去;
  • 空对象 = nullOutputStream() 返回的匿名 OutputStream——write 全部空操作;
  • 关键设计:它与真实输出流实现同一接口,所以任何接受 OutputStream 参数的代码(日志、压缩、序列化)都可以无缝传入"空流"而无需判空;同时它用 ensureOpen() 保留了流契约的边界行为(关闭后再写抛异常),这是"带安全默认行为的空对象",而不是简单的 null。

类似地,InputStream.nullInputStream() 返回一个"永远读不到数据"的输入流(read 返回 -1),Reader.nullReader()/Writer.nullWriter() 也遵循同一模式。JDK 官方用空对象模式解决了"流不能为 null"的契约问题。

java.util.Optional:空对象的现代替代方案​

Java 8 引入的 java.util.Optional<T> 提供了一种不同于空对象的"显式缺失"方案:

import java.util.Optional;

public class UserRepository {

// 返回 Optional:明确表达"可能没有"
public Optional<User> findByUsername(String username) {
// 查不到时返回 Optional.empty()
return Optional.empty();
}
}

// 客户端:用 Optional API 处理缺失,而不是 if (user != null)
UserRepository repository = new UserRepository();
repository.findByUsername("nonexist")
.ifPresent(user -> System.out.println("找到用户:" + user.getName()));

空对象模式与 Optional 的对比:

维度空对象模式Optional
核心思想用"空操作对象"替换 null,客户端无感知用容器对象显式包装"可能存在/不存在"
客户端代码与真实对象完全一致,零判空需要调用 ifPresent/orElse 等方法
语义清晰度"空"隐藏在对象内部"可能缺失"在类型上可见
适合场景接口方法多、调用点多的服务对象(Logger、流)查询结果、返回值等单值缺失场景

两者并不冲突:空对象适合"对象本身操作多、缺失时什么都不该做"的场景(日志、流、策略),Optional 适合"返回值可能没有、调用方需要显式处理"的场景(查询、查找)。现代代码实践中,二者经常配合使用。

适用场景​

  • 当系统需要统一的"无操作"实现(不启用日志、不启用监控、静默模式),且调用点很多时,空对象可以消除大量判空。
  • 当接口方法数量较多,而某些环境下不需要真实实现时,空对象比"每个调用点判空"干净得多。
  • 当对象缺失的默认行为可以定义(返回默认值、不抛异常)时,空对象让业务流程顺畅走完。
  • 注意:如果"缺失"需要被显式感知和处理(比如查询不到要报错、要区分"没找到"与"出错"),空对象会掩盖问题,此时应使用 Optional、异常或显式判空。

与其他设计模式的关系​

  • 空对象模式与**策略模式(Strategy)**结构相似:空对象可以看作一种"什么都不做的策略"(如 NoOpPayStrategy),客户端可以无缝替换策略。
  • 空对象与**责任链模式(Chain of Responsibility)**也常结合:责任链的末端放一个空处理器(NoOpHandler),避免链尾判空。
  • 空对象与**命令模式(Command)**结合时,可以提供"空命令"(NoCommand)作为默认命令,避免按钮无操作时判空。
  • 空对象模式与 Optional 是"用空对象"与"显式处理缺失"两条路线,实践中按场景选择或组合。

总结​

空对象模式用一个"什么都不做"的对象替代 null:

  • 客户端零判空,代码干净统一;
  • 空对象与真实对象实现同一接口,可无缝替换;
  • "缺失"的语义从"危险的 null"变成"安全的空操作对象"。

空对象模式的缺点是:空对象会掩盖"缺失"的真实原因,如果缺失本身需要被感知和处理,使用空对象反而有害;同时接口方法很多时,空对象要实现大量空方法(可用默认方法、代理或 Lombok 简化)。它最合适的场景是默认行为明确、调用点众多的横切组件——日志、流、监控、策略,让"没有"不再成为一次空指针事故。