[译]厌倦了NullPointException?Optional拯救你!

原文http://www.oracle.com/technetwork/articles/java/java8-optional-2175753.html

有人说,当你处理过了空指针异常才真正成为一个Java开发者。抛开玩笑话不谈,空指针确实是很多bug的根源。Java SE 8引入了一个新的叫做java.util.Optional 的类来缓解这个问题。

我们首先看看空指针有什么危险,Computer是一个嵌套的对象,如图:

Computer对象
Computer对象

下面的代码有什么潜在的问题呢?
String version = computer.getSoundcard().getUSB().getVersion();

这段代码貌似可行,但是,很多电脑(比如 Raspberry Pi)并没有Soundcard,因此getSoundcard会返回什么?

一个常见的(不好的)实现是返回一个null表示没有声卡。但是,这样会导致对null对象调用了getUSB方法,毫无疑问,结果自然是在运行时给你抛出一个NullPointException,然后终止程序的执行。

I call it my billion-dollar mistake. It was the invention of the null reference in 1965. I couldn't resist the temptation to put in a null reference, simply because it was so easy to implement.
—Tony Hoare

如何避免上面的空指针异常呢?一般的做法就是在调用方法之前进行检测:

String version = "UNKNOWN";
if(computer != null){
  Soundcard soundcard = computer.getSoundcard();
  if(soundcard != null){
    USB usb = soundcard.getUSB();
    if(usb != null){
      version = usb.getVersion();
    }
  }
}

但是,上面嵌套if检测的代码确实不怎么好看。但是没办法,我们需要很多这样死板的没什么意义的代码来避免碰到NullPointException。更恼火的是,这部分代码成了我们业务逻辑的一部分,还降低了代码的可读性。

万一我们忘记对某个可能为null的对象进行非空检测怎么办?使用null来说明某个值缺失是一种错误的方式, 下文将说明这个问题并给出更好的解决办法。

先看看别的编程语言是如何处理这个问题的。

Null的替代物

Grovvy语言有一个?.的操作符,可以安全地处理潜在可能的空引用(C#即将包含这个特性,Java7曾被建议引入这个但是并没有发布。)它是这么用的:

String version = computer?.getSoundcard()?.getUSB()?.getVersion();

如果getSoundcard(),getUSB(),getVersion任意一个返回null,变量version就被赋值为null,不需要额外的复杂的嵌套检测。更好的是,Grovvy还有一个Elvis操作符:?:,可以给类似上面的表达式提供默认值。下面的表达式如果?. 返回了null那么变量version会被赋值为"UNKNOW":

String version = computer?.getSoundcard()?.getUSB()?.getVersion() ?: "UNKNOWN";

其他的一些函数式编程语言,比如Haskell, Scala,使用了一种别的方式。Haskell有一个Maybe型态,这个型态代表了一种有可选值的类型。Maybe形态的值可能包含一个给定类型的值或者是Nothing(译者注:代表没有值),完全没有空指针的概念。Scala有一种类似的叫做Option[T]的东西来代表类型T的某一个值存在或者没有。因此,你必须显式检测这个值是否存在,如果不存在就不能使用任何Option类型的操作符;这样由于Scala的类型系统,你永远也不会忘记对于空指针的检测。

貌似有点扯远了,那么,Java8给我们提供了什么呢?

果壳里的Optional

受到Haskell和Scala的启发,Java8引入了一个叫做java.util.Optional<T>的类,这一个包含一个可选值的类型,你可以把它当作包含单个值的容器——这个容器要么包含一个值要么什么都没有,如下图:

Lift2
Lift2

我们在数据模型里面引入Optional

public class Computer {
  private Optional<Soundcard> soundcard;  
  public Optional<Soundcard> getSoundcard() { ... }
  ...
}

public class Soundcard {
  private Optional<USB> usb;
  public Optional<USB> getUSB() { ... }

}

public class USB{
  public String getVersion(){ ... }
}

用上面的代码,我们一眼就可以看出来一个computer有没有soundcard(他们是optioal,可选的),更进一步,一个声卡也有一个可选的USB端口;新的模型能清晰地反映出一个给定的值是有可能不存在的。这种做法在某些库里面也存在,比如Guava(译:Java5之后就可以使用,不过有局限)

我们能用Optional对象干什么?Optional对象包含了一些方法来显式地处理某个值是存在还是缺失,Optional类强制你思考值不存在的情况,这样就能避免潜在的空指针异常。

值得一提的是,设计Optional类的目的并不是完全取代null, 它的目的是设计更易理解的API。通过Optional,可以从方法签名就知道这个函数有可能返回一个缺失的值,这样强制你处理这些缺失值的情况。

Optional的正确打开方式

废话扯了这么多,来点实际的例子吧!首先来看看如何使用Optional类来实现传统的空指针检测:

String name = computer.flatMap(Computer::getSoundcard)
                          .flatMap(Soundcard::getUSB)
                          .map(USB::getVersion)
                          .orElse("UNKNOWN");

如果无法理解这段代码,可以复习Java8的lambda和方法引用,见Java8 Lambdas 以及stream pipelining概念,见Processing Data with Java SE 8 Steams

创建Optional对象

如何创建Optional对象呢,有下面几种方式:

  1. 空的Optional
Optional<Soundcard> sc = Optional.empty(); 
  1. 包含非空值的Optional
SoundCard soundcard = new Soundcard();
Optional<Soundcard> sc = Optional.of(soundcard); 

一旦soundcardnull,这段代码会立即抛出一个NullPointException(而不是等你以后你访问这个空的soundcard对象的时候)

  1. 可能为空的Optional
Optional<Soundcard> sc = Optional.ofNullable(soundcard); 

如果soundcardnull那么这个Optional将会是empty.

值存在的时候进行进一步的操作

现在你有了一个Optional对象,你可以显式地处理值存在或者不存在的情况,再也不用想这样如履薄冰地进行空指针检测了:

SoundCard soundcard = ...;
if(soundcard != null){
  System.out.println(soundcard);
}

现在,可以使用ifPresent()方法,如下:

Optional<Soundcard> soundcard = ...;
soundcard.ifPresent(System.out::println);

现在,你再也不用显示地进行非空检测了,类型系统帮你干了这件事。如果Optional是empty,上面的代码就不会执行打印了。

你也可以使用isPresent()方法检查某个值是否存在,另外,get 方法可以返回Optional容器里面包含的那个对象,如果没有这个对象,get方法会立即抛出一个NoSuchElementException,这两个方法可以结合起来:

if(soundcard.isPresent()){
  System.out.println(soundcard.get());
}

但是,并不提倡这样使用Optional。(这么做跟null检测有什么区别?),下面有一些惯用手法,我们来看一下。

默认值和默认操作

在某个操作返回空的时候给出一个默认值也是一个典型的场景,通畅的做法是使用三目运算符(?):

Soundcard soundcard = maybeSoundcard != null ? 
            maybeSoundcard : new Soundcard("basic_sound_card");

可以使用Optional对象的ifElse方法改进这个代码:

Soundcard soundcard = maybeSoundcard.orElse(new Soundcard("defaut"));

如果你想在空值的时候抛出一个异常,可以使用ifElseThrow方法:

Soundcard soundcard = 
  maybeSoundCard.orElseThrow(IllegalStateException::new);

使用filter过滤特定值

很多时候你需要调用某个对象的方法并且检查它的一些属性。例如:你可能需要检测一个USB的端口是否是一个特定的版本;如果需要避免空指针异常,通畅的方式是检测非空然后调用getVersion方法,如下:

USB usb = ...;
if(usb != null && "3.0".equals(usb.getVersion())){
  System.out.println("ok");
}

使用Optional的filter可以这么干:

Optional<USB> maybeUSB = ...;
maybeUSB.filter(usb -> "3.0".equals(usb.getVersion())
                    .ifPresent(() -> System.out.println("ok"));

filter方法带有一个Predicate的参数,如果Optional容器里面的对象存在并且满足这个predicate,那么filter返回那个对象,否则就返回empty的Optional。(跟Stream接口的filter类似)

使用map转换值

另外一个比较常见的场景是需要从某个对象里面提取出特定的值。例如:从一个Soundcard对象里面取出一个USB对象然后检测这个usb对象是否是正确的版本。通常可以这么写:

if(soundcard != null){
  USB usb = soundcard.getUSB();
  if(usb != null && "3.0".equals(usb.getVersion()){
    System.out.println("ok");
  }
}

使用Optional的map方法,如下:

Optional<USB> usb = maybeSoundcard.map(Soundcard::getUSB);

Optional容器里面的值被某个函数(这里是USB的方法引用)作为参数“转换”了,如果Optional是empty那么就什么也不会发生。

结合使用mapfilter可以检测某个声卡是否有USB 3.0的接口:

maybeSoundcard.map(Soundcard::getUSB)
      .filter(usb -> "3.0".equals(usb.getVersion())
      .ifPresent(() -> System.out.println("ok"));

现在我们的代码看起来比较像是在描述问题了!而且没有任何非空检测,太酷了!

使用flatMap级联Optional

我们已经有一些常见的模式可以通过Optional重构了,那么我们如何用一种安全的方式重构下面的代码呢?

String version = computer.getSoundcard().getUSB().getVersion();

上面的代码都是从一个对象里面取出另外一个对象, 这不正是上文介绍的map吗?我们改写Computer模型对象,让它拥有一个Optional<Soundcard>和一个Optional<USB>,然后就可以把代码改成这样:

String version = computer.map(Computer::getSoundcard)
                  .map(Soundcard::getUSB)
                  .map(USB::getVersion)
                  .orElse("UNKNOWN");

但是,这段代码并不能通过编译。为什么?

computer变量类型是Optional<Computer>,因此它调用map方法没有任何问题;但是,getSoundcard()方法的返回类型是Optional<Soundcard>这意味着map操作结果的类型是Optional<Optional<Soundcard>>,因此getUsb这个调用是非法的:外面的那个Optional包含的值是另外一个Optional,自然就没有getUsb方法,下图是这个调用的结构:

two level Optional
two level Optional

如何解决这个问题呢?Optional类提供了一个flapMap的方法。这个方法可以对一个Optional使用一个函数转换为一个Optional然后把结果(两个Optional)flatten为一个单个Optional,下图给出了mapflatMap的区别:

map and flatMap
map and flatMap

flatMap重写我们的代码:

String version = computer.flatMap(Computer::getSoundcard)
                   .flatMap(Soundcard::getUSB)
                   .map(USB::getVersion)
                   .orElse("UNKNOWN");

第一个flatMap确保返回一个Optioan<Soundcard>而不是Optional<<Optional<Soundcard>>,第二个flatMap确保返回一个Optional<USB>;接着第三次调用着需要一个map即可,因为getVersion返回一个String而非Optional方法。

Cool!现在我们可以抛弃痛苦的嵌套非空检测了,使用Optional可以写出声明式的,更可读的代码,并且永远不会有空指针异常!

总结

本文介绍了如何使用Java SE 8的java.util.Optional<T>Optional的目的不是替换你代码里面的每个null,它可以帮助你设计出更好的API,使用者通过方法签名就能知道是否有一个可选的值。另外,Optional通过强迫主动处理空指针情况,可以保护代码不出现NullPointException

译后感

嵌套的非空检测确实是个很头大的问题,虽然有一些静态代码检测工具可以检测到这些异常,但是这样无聊的检测代码很是让人失望。Java 8引入的Optional确实可以部分缓解这部分问题;但是依然存在局限性,比如,如果某个特定的方法调用出了别的运行时异常怎么办?对于?Haskell Maybe Monad只吸收了一部分,不过已经很不错了,期待什么时候能引入Grovvy的?.操作符,在处理空指针问题上,?.更加简洁有力。

Optional虽好,但是Java 8目前并不普及,Android 就不用想了。虽然有retrolambda项目支持在Java 6里面使用lambda,但是它对默认方法以及接口的静态方法支持有限,对于Optional支持也有限。

虽然Grava项目也有一个Optional类,但是没有函数式接口,我们所能做的不过是把if (obj == null)替换为if (opt.isPresend())罢了;虽说能提高类型安全性,但是还是得写一堆shit一样的嵌套检测。

对于Android开发,想使用这个是没有希望了。但愿Kotlin能给我们惊喜。

参考

  1. Chapter 9, "Optional: a better alternative to null," from Java 8 in Action: Lambdas, Streams, and Functional-style Programming
  2. "Monadic Java" by Mario Fusco
  3. Processing Data with Java SE 8 Streams

致谢

Thanks to Alan Mycroft and Mario Fusco for going through the adventure of writing Java 8 in Action: Lambdas, Streams, and Functional-style Programming with me.

关于作者

Raoul-Gabriel Urma (@raoulUK) is currently completing a PhD in computer science at the University of Cambridge, where he does research in programming languages. He's a coauthor of the upcoming book Java 8 in Action: Lambdas, Streams, and Functional-style Programming, published by Manning. He is also a regular speaker at major Java conferences (for example, Devoxx and Fosdem) and an instructor. In addition, he has worked at several well-known companies—including Google's Python team, Oracle's Java Platform group, eBay, and Goldman Sachs—as well as for several startup projects.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 198,154评论 5 464
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,252评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 145,107评论 0 327
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,985评论 1 268
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,905评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,256评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,978评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,611评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,891评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,910评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,736评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,516评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,995评论 3 301
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,132评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,447评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,034评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,242评论 2 339

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,201评论 25 707
  • 第157期:如何调试 Android Native Framework 深度讨论 如何调试 Android Nat...
    优雅的程序员阅读 1,597评论 0 2
  • 一、数据类型 1.Java数据类型划分,见下图: 2.Java基本数据类型的大小、范围、默认值,见下图: 如果超过...
    丶Castiel阅读 741评论 0 49
  • 原谅今天想一股脑的把负能量发泄在这里。 在我前12年的学习中,我还记得几乎每年老师在成绩单上都会有关于我劣...
    李番茄阅读 300评论 6 0
  • 我理解今天得重点是随心所欲,好吧,我去死拼暗线237。 邓老师的微信群也关门了,今教了第一个图样立体公路。我偷了懒...
    卡波阅读 156评论 0 0