再见了,空指针异常(NullPointerException)!看看有哪些好的实践可以避开它

3,638 阅读4分钟

1. 快速介绍

空指针异常,只有大家写过业务系统,一定对它不陌生。它是一个运行时错误,一般而言常见逻辑不严谨、懒散的代码风格导致。它的原因理解起来很简单,但是要避免它却不是一件容易的事。下面我记录了一些我认为比较好的实践,这些实践帮助我避免空指针异常的同时,也间接地提升了我的代码质量和工作效率。这些内容一部分来自我在 Kindle 上读过的书籍,一部分来自 StackOverflow 的建议和我自己实践的感受,这里记录下来,希望可以给大家带来一些启发。

2. 问题定义

假设一个这样的业务系统:一个任务 Task 中维持最近一次的执行记录 Execution,执行记录 Execution 维持了这一次的执行结果 Result。下面是接口定义:


public interface Task {
    Execution getExecution();
}

public interface Execution {
    Result getResult();
}

public interface Result {
}

现在从数据库中查询获得了 Task,需要获得这一次的执行结果,如果存在结果,则做下一步操作,否则跳过。我们常常这样来写:

Task task = queryTaskFromDB();
if (task != null) {
    if (task.getExecution() != null) {
        if (task.getExecution().getResult() != null) {
            doSomethingOnResult(task.getExecution().getResult());
        }
    }
}

为了逻辑上的严密,我们不得不对每一个 CURD 中 R 获得的内容进行判空处理,这样的面条代码显得非常啰嗦。

实际的开发中,能够如此细致严密地对每一个获取的类进行判空处理却不是一件容易的事情。很多时候业务代码中的定义并没有清楚的说明 Task::getExecution 到底能否可以返回为空:如果为空时返回 null 还是返回 NoSuchElementException 呢?这常常因为具体的实现的不同而发生变化。这也是 Java 中不友好的一面,因为这种设计导致难以完全贯彻面向接口编程。

3. 一些常见的最佳实践

3.1 建议#0 尝试使用 Optional 改造你的接口

以 2.1 中的简单的业务系统为例。我们尝试使用 Optional 来改造 TaskExecution 的接口定义:


public interface Task {
    Optional<Execution> getExecutionNullable();
}

public interface Execution {
    Optional<Result> getResultNullable();
}

public interface Result {
}

Optional 表示这个方法返回的结果可能存在,也可能为空,如果存在则里面是一个给定类型的对象。

完成接口的改造后,如果要从数据库中查询 Task,并拿到它的执行结果要怎么做呢?看下面👇的代码

Optional<Task> task = queryTaskFromDBNullable();
task
    .flatMap(Task::getExecutionNullable)
    .flatMap(Execution::getResultNullable)
    .ifPresent(this::doSomethingOnResult);

这里面处理的关键是 flatMap,flatMap 方法结果根据Optional 的内容的不同而变化:

  • 如果 Optional 有值,则取出值赋值给匿名函数
  • 如果 Optional 没有值,返回一个 Optional.empty()

通过上面的做法,便可以避免繁琐的“面条代码”了。:)

3.2 其他的一些开发建议

我们的业务系统固然可以通过改造接口来完成对 null pointer check 的规避,但是如果是遗留系统呢,如果是第三方库呢?这个时候需要从代码风格入手。

3.2.1 建议#1 习惯用 String.valueOf 代替 toString

Object obj = null;
obj.toString(); // Ops,出错了

toString() 在开发中是非常常用的方法,但是如果 obj 为空时会出现空指针异常,这时更好的实践是通过静态工厂方法 valueOf 来保证 toString 总是成功的:

Object obj = null;
String.valueOf(obj); //如果为null 返回 "null"

这里的关键技巧在于一部分方法的逻辑在一些静态工厂方法中也存在,但是使用静态工厂方法可以保证这一次的操作完全函数式,而不用担心方法所在的对象不存在。

3.2.2 建议#2 对于初始化后不再变化的变量增加 final

String prompt = null;
int i = 50;
if (i < 50) {
    prompt = "too small";
} else if (i > 50) {
    prompt = "too large";
}
prompt.toString(); // Ops, 出错了

如果增加 final 会强制你初始化

final String prompt;
int i = 50;
if (i < 50) {
    prompt = "too small";
} else if (i > 50) {
    prompt = "too large";
} else {
    prompt = "you get it";
}
prompt.toString();

3.2.3 建议#3 equals 比较时值在前,变量在后

String var = null;
if (var.equals("test")) {
    doSomething(); // Ops, 出错了
}

换成下面的写法就可以保证总是正确。

String var = null;
if ("test".equals(var)) {
    doSomething(); 
}

这样还有一个好处在于 == 误写成 = 的错误。不过越来越多的人开始使用 Intellij,这么写的好处已经不存在了。

4. 总结

以上我介绍了一些自己的实践来规避或者优化空指针检查。希望大家能够有所收获。其中一些关于 Optional 还有涉及到 flatmap、Monad 的部分可能一部分读者不太熟悉,后续会在更新一些文章讲讲我自己的一些实践和理解。