空对象模式:优雅地告别空指针异常

空指针异常,Java程序员的老朋友了。我记得刚入行那会儿,半夜被电话叫醒,线上系统挂了,日志里清一色的NullPointerException。那种感觉,就像你写好了所有逻辑,结果被一个null给绊倒了。后来我慢慢意识到,很多空指针问题,其实在设计阶段就能避免。

空对象模式(Null Object Pattern)就是为此而生。它不是什么高深莫测的东西,说白了就是:用一个“什么都不做”的对象,代替null值。这样你的代码就不需要到处检查null了。

模式的核心思想

你想想看,我们为什么会被空指针折磨?因为调用了一个null对象的方法。那如果我们不返回null,而是返回一个“空实现”的对象呢?问题不就解决了?

空对象模式的结构其实很简单,就三个角色:

  • 抽象接口(Abstract Interface):定义业务方法
  • 真实对象(Real Object):实现具体业务逻辑
  • 空对象(Null Object):实现抽象接口,但方法体为空或返回默认值

核心原则:用空对象替代null,让客户端代码无需判空。

一个真实的案例

我在做一个用户权限系统时遇到过这个问题。系统需要根据用户ID查找用户信息,然后判断用户是否有某个权限。最初的代码是这样的:

// 传统写法,到处都是判空
User user = userService.findById(userId);
if (user != null) {
    if (user.hasPermission("DELETE")) {
        // 执行删除操作
    }
}

看起来还行?但真实项目中,这种判空会散落在几十个地方。一旦漏掉一个,线上就炸了。我当时重构时,引入了空对象模式。

代码实现

先定义抽象接口:

// 抽象接口
public interface User {
    String getName();
    boolean hasPermission(String permission);
    void doSomething();
}

然后是真实对象:

// 真实对象
public class RealUser implements User {
    private String name;
    private Set<String> permissions;
    
    public RealUser(String name, Set<String> permissions) {
        this.name = name;
        this.permissions = permissions;
    }
    
    @Override
    public String getName() {
        return name;
    }
    
    @Override
    public boolean hasPermission(String permission) {
        return permissions.contains(permission);
    }
    
    @Override
    public void doSomething() {
        System.out.println(name + " 正在执行操作");
    }
}

关键来了,空对象:

// 空对象
public class NullUser implements User {
    @Override
    public String getName() {
        return "未知用户";
    }
    
    @Override
    public boolean hasPermission(String permission) {
        return false;  // 默认没有权限
    }
    
    @Override
    public void doSomething() {
        // 什么都不做
    }
}

最后,修改服务层:

// 服务层,不再返回null
public class UserService {
    public User findById(String userId) {
        // 如果找不到用户,返回空对象
        if (userId == null || userId.isEmpty()) {
            return new NullUser();
        }
        // 正常查找逻辑...
        return new RealUser("张三", Set.of("READ", "WRITE"));
    }
}

客户端代码就清爽多了:

User user = userService.findById(userId);
// 不需要判空,直接调用
if (user.hasPermission("DELETE")) {
    user.doSomething();
}

我的习惯:空对象最好设计成单例,因为它的状态是不变的。这样可以节省内存,也避免创建大量无意义的对象。

模式的结构图

下面这张图展示了空对象模式的核心结构,我画得比较简洁,方便你理解:

抽象接口 User 真实对象 RealUser 有具体业务逻辑 空对象 NullUser 方法体为空或默认值 客户端 无需判空,直接调用

什么时候用?什么时候别用?

空对象模式不是银弹。我用过很多次,也踩过坑。这里总结一下:

场景 推荐使用 不推荐使用
集合类操作 返回空集合代替null
服务层查找 用户、配置等非关键数据 必须明确区分“不存在”和“存在但无效”的场景
回调/监听器 提供空回调实现 回调需要区分不同状态时
树形结构 叶子节点用空对象 需要频繁遍历且性能敏感时

我曾经踩过的坑:有一次在支付系统中用了空对象模式,结果空对象返回了默认的“支付成功”状态。虽然代码没报空指针,但业务逻辑全错了。后来我加了一条铁律:空对象只能用于“查询不到”的场景,绝不能用于“状态判断”的场景

Java 8+ 的 Optional 怎么选?

有人会问:Java 8 不是有 Optional 了吗?还用空对象模式干嘛?

我的看法是:两者不冲突。Optional 适合方法返回值的包装,告诉调用者“可能为空”。而空对象模式更适合那些需要多态行为的场景。比如上面的 User 例子,如果我用 Optional,客户端还是得写:

Optional<User> userOpt = userService.findById(userId);
userOpt.ifPresent(user -> {
    if (user.hasPermission("DELETE")) {
        user.doSomething();
    }
});

用空对象模式,直接调用就行。哪种更清爽?一目了然。

C++ 中的实现

C++ 里没有 Optional(C++17 才有),空对象模式就更实用了。而且 C++ 的指针操作更容易出空指针问题。

// C++ 实现
class User {
public:
    virtual std::string getName() = 0;
    virtual bool hasPermission(const std::string& permission) = 0;
    virtual void doSomething() = 0;
    virtual ~User() = default;
};

class RealUser : public User {
private:
    std::string name_;
    std::set<std::string> permissions_;
public:
    RealUser(const std::string& name, std::set<std::string> permissions)
        : name_(name), permissions_(std::move(permissions)) {}
    
    std::string getName() override { return name_; }
    bool hasPermission(const std::string& permission) override {
        return permissions_.count(permission) > 0;
    }
    void doSomething() override {
        std::cout << name_ << " 正在执行操作" << std::endl;
    }
};

class NullUser : public User {
public:
    std::string getName() override { return "未知用户"; }
    bool hasPermission(const std::string&) override { return false; }
    void doSomething() override { /* 什么都不做 */ }
};

C++ 小技巧:空对象可以设计成静态单例,避免每次创建。但要注意线程安全。

总结一下

空对象模式不是什么新概念,但它解决了一个很实际的问题。我个人习惯在以下场景优先考虑它:

  • 接口返回的对象经常需要判空
  • 空对象的行为是确定的(比如返回 false、空字符串)
  • 客户端代码希望统一处理,不想分散判空逻辑

记住一点:空对象模式不是让你消灭所有 null,而是让你把 null 的处理集中到一处。这样代码更干净,也更不容易出错。

嗯,今天就聊到这里。下次遇到空指针,不妨想想:是不是可以用一个空对象来替代?


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