原文自国外技术社区dzone,作者为 Robert Brautigam,传送门

当编写 Java 代码时,对输入参数进行非空判断并不会时代码变得更“安全”,相反,这会使得代码更为难读并且更不安全。

非空判断的代码可读性更差

非空检查很受人喜欢,这句话很难说出口。很多时候,这只是一种模板式的代码,并不给方法带来任何“逻辑性”的贡献。它更是一种纯技术构造。就像下面这段:

1
2
3
4
5
6
7
@Override
public void getLock(String name,
Handler<AsyncResult<Lock>> handler) {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(handler, "handler");
getLockWithTimeout(name, DEFAULT_LOCK_TIMEOUT, handler);
}

这个从一个很酷的vertx项目中来的方法,里面有其中的三方代码,其中有两行是对空值做检查的,这使得这个方法变得更难读了。这类型的检查甚至会变得更危险当它们出现在下面类似的方法逻辑中:

1
2
3
4
5
6
7
8
9
10
11
@Override
public Object getValue(final ELContext context,
Object base, Object property) {
BeanManagerImpl beanManager = getManager(context);
if (property != null) {
String propertyString = property.toString();
ElLogger.LOG.propertyLookup(propertyString);
Namespace namespace = null;
if (base == null) {
...
}

这个从非常火的 Weld Project 中截取的代码段可以看出非空检查会增加代码的循环复杂度。这种情况下,null 是被期望的并且作为标记参数存在。

非空检查存在的原因

进行非空检查最直接的原因是惧怕在每个 Java 程序员心中都根深蒂固的空指针异常 NullPointerException。下面引用“发明” null 引用的 Thomas Hoare 先生说的一句话:

“我称这个(null 引用)为我的10亿元的错误。”

提及许多程序崩溃、错误、无尽的 debug以及大量浪费资源,究其原因,都是因为 null

然而,对空参数的检查,并非对这种类型系统所产生的公认的缺点的正确解决方法,因为它忽略了实际上有两类不同类型的关于空值相关的问题。

第一类是,是简单的程序异常。当然异常和 bug 总是会发生,例如即使代码中需要一个非空的引用,一些参数总是会变为 null。Java 已经有一种机制,通过抛出 NullPointerException ,去捕获这种类型的异常。确实在这种情况下这并非一种坏事。为了更容易去纠正,这些 bug 需要变得可见。

第二类是麻烦的一类。当代码处理 null 值的时候,通常会赋予 null 值一种意义,就像上面提及的 Weld 的代码。在这种情况下,异常不会被产生,只会有不同行为的产生。一些参数会变得不可见如果他们意外地变为空,相反,这还可能带来一些业务级别上的问题。这些值可能更需要 debug 或者更深层次的分析而不是遵循一些明确的异常。

非空检查的代价

除了像上面说的那样增加了一系列模板式代码段这种明显的消耗之外,非空检查付出最大的代价是它让 null 合法化。它使传递和接收空值变得令人所接受,因此在处理空值相关的问题时会增加代码的消耗量。

移除需非空检查的参数

最完美的情况,应该不允许传递或返回 null 值,至少在公共方法中不能这样做。这也是像 Kotlin、Haskell和一些其他的语种明显设计得比 Java 好的一点。但是,我们能够假装 null 并不存在。问题是:这可行吗?

在一些编程错误中,很容易发现是空值被传递过去所导致的。如果不执行非空判断或额外的方法去处理,这迟早会产生 NullPointerException。这是很明显的,因此也是很容易被找到和更正的。

所以下面的代码:

1
2
3
4
5
6
7
@Override
public void getLock(String name,
Handler<AsyncResult<Lock>> handler) {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(handler, "handler");
getLockWithTimeout(name, DEFAULT_LOCK_TIMEOUT, handler);
}

应该被简化为:

1
2
3
4
5
@Override
public void getLock(String name,
Handler<AsyncResult<Lock>> handler) {
getLockWithTimeout(name, DEFAULT_LOCK_TIMEOUT, handler);
}

但是,如果 null 在参数中是合法的话,这种策略就行不通了。这种情况下最简单的解决途径是将方法拆分成多个,它们都带有所有的参数。这会改变上述的 Weld 代码,当 base 参数不再被需要的时候:

1
2
public Object getValue(final ELContext context,
Object base, Object property) // 不需要 base 参数

转换为:

1
2
3
4
5
public Object getObjectValue(final ELContext context,
Object base, Object property) // 需要 base 参数
...
public Object getRootValue(final ELContext context,
Object property) // 忽略 base 参数

另外一种解决途径是将“root”作为 base 参数,这样方法就不需要拆分,并且 base 也能作为方法中的所需参数。(原文为 An alternative solution is to allow the “root” as a base parameter, in which case the method does not need to be split, and the base can be a required parameter。译者认为作者的意思是再添加一个不同名称的方法。)

有时候,方法声明中的1或者2个参数是非必须的,这在构造函数中十分常见。这种情况下,构造者模式也许会帮到忙。

避免将 null 作为值返回

当调用者不需要检查的时候,移除这些检查才是合理的。只有调用者能够自由地从另外的方法的调用中传递结果这才变得可能。因此,方法不应该返回 null。所有返回 null 的方法都需要合理地判断所返回的 null 值的意义。更多时候,这表示不能找到确定的对象并且调用者需要根据自己的逻辑进行判断。这种情况下,Optional 类可以帮到忙:

1
2
3
4
5
6
@Override
public Optional<Object> getValue(final ELContext context,
Object base, Object property) {
...
return Optional.empty();
}

这种情况下,值缺失的可能性就变得可见了,调用者也可以根据自己的意愿作出响应。

Optional 注意的一点:不要使用 get() 方法,使用 map(), orElse(), orElseGet(), ifPresent() 等方法。

有时候,返回空值意味着调用者需要执行一些由被调用者定义的一些默认逻辑处理。方法需要返回自身的默认逻辑处理而不是将责任推给调用者。让我们再次看看 Weld 的另外一个例子:

1
2
3
4
5
6
7
8
9
protected DisposalMethod<...> resolveDisposalMethod(...) {
Set<DisposalMethod<...>> disposalBeans = ...;
if (disposalBeans.size() == 1) {
return disposalBeans.iterator().next();
} else if (disposalBeans.size() > 1) {
throw ...;
}
return null;
}

一种可能性是这方法返回空值是为了表明最后结果中并没有声明一个用来定义实例化 Weld(一个依赖注入框架) bean 的方法。但实际上是,这方法想说的其实是没有什么是需要处理的。不是为了使许多中间对象对 null 合法化,只是为了让某些对象最终能够检查返回值 DisposalMethod 是否返回空来不执行任何操作,这个方法能够只返回一个什么都不处理的 DisposalMethod 对象。

1
2
3
4
5
6
7
8
public final class NoDisposalMethod
implements DisposalMethod {
...
@Override
public void invoke(...) {
// Do nothing
}
}
1
2
3
4
protected DisposalMethod<...> resolveDisposalMethod(...) {
...
return new NoDisposalMethod();
}

这样,方法就不会返回空,并且也不需要检查是否为空了。

遗留的点

不幸的事实是我们并不能忽略所有的的空值情况。有各种的类、对象和框架都是我们无法涉及的。我们必须处理这时发生的空值情况,并且还有部分是 JDK 本身自带的。

对于这种情况以及仅对于这种情况,如果只是将它们通过 Optional.ofNullable() 打包或者使用 Objects.requireNonNull() 作检查,那么非空检查是被允许的。

总结

有一种时下流行的观点认为非空检查会使 Java 代码变得更安全,但事实上,它使代码更不安全并且可读性更差。

相反,我们应该假装 null 是不存在的并且将 null 的任何出现都视为编程错误,至少在公共方法和返回值上得这样做。具体地说:

  • 方法和构造器不应该对空值进行检查
  • 方法不应该返回空值

而不是:

  • 方法和构造器应该假设所有参数都不可能为空
  • 允许空值的方法应该被重新设计或者拆分成多个方法
  • 带有多个不需要的参数的构造器应该考虑使用构造者模式
  • 返回空值的方法需要返回 Optional 或者使用期望空值出现的逻辑处理的特殊实现去解决

译者总结

这篇文章中作者用很强烈的想法去表达他对空值的看法,其实译者我本身也看过一些开发手册或者一些 API,其实大部分都允许空值的产生和传递的。而我工作本身也会对空值进行预处理和判断,也会将空值作为逻辑处理依据。

所以我翻译文章更多是想从别人的看法中去看待 null 这个神奇的定义,以及他有什么其他的想法去处理这种情况。各位读者如果有其他对 null 的想法,对这篇文章的意见,以及对翻译的一些吐槽,都可以在下方评论中和大家分享。

那么,下一篇见。

小喇叭

广州芦苇科技Java开发团队

芦苇科技-广州专业互联网软件服务公司

抓住每一处细节 ,创造每一个美好

关注我们的公众号,了解更多

想和我们一起奋斗吗?lagou搜索“ 芦苇科技 ”或者投放简历到 **server@talkmoney.cn** 加入我们吧

关注我们,你的评论和点赞对我们最大的支持