Java 可选对象



在这篇文章中,我介绍了Java 8中新的   可选 对象的几个例子,并与其他编程语言中的类似方法做了比较,特别是函数式编程语言SML和基于JVM的编程语言   Ceylon ,后者目前正在由Red Hat开发。

我认为有必要强调的是,引入可选对象一直是一个争论不休的问题。在这篇文章中,我试图提出我对这个问题的看法,并努力展示支持和反对使用可选对象的论据。我认为在某些情况下,使用可选对象是有价值的,但最终每个人都有权发表意见,我只是希望这篇文章能帮助读者做出明智的选择,就像写这篇文章帮助我更好地理解这个问题一样。

关于Null的类型

在Java中,我们使用引用类型来获得对一个对象的访问,当我们没有一个特定的对象来使我们的引用指向,那么我们就把这种引用设置为 null 来暗示没有一个值。

在Java中, null 实际上是一个类型,一个特殊的类型:它没有名字,我们不能声明其类型的变量,也不能将任何变量投给它,事实上,只有一个值可以与它相关联(即字面意思是 null ),而且与Java中的其他类型不同,空引用可以安全地分配给任何其他引用类型(见JLS   3.10.7 和   4.1 )。

null 的使用是如此的普遍,以至于我们很少冥思苦想:对象的字段成员被自动初始化为 null ,程序员通常在没有初始值的时候将引用类型初始化为 null ,一般来说, null 的使用无处不在,暗示着在某些时候,我们不知道或者我们没有值给引用。

关于空指针引用问题

现在,空引用的主要问题是   如果 我们试图取消引用,那么我们会得到不祥的、众所周知的 NullPointerException

当我们使用一个从不同于我们代码的上下文中获得的引用时(即作为一个方法调用的结果,或者当我们收到一个引用作为我们正在工作的方法的参数时),我们都希望避免这个有可能使我们的应用程序崩溃的错误,但往往这个问题没有被足够早地注意到,它发现自己进入了生产代码,在那里它等待着失败的适当时机(这通常是月底的一个星期五,下午5点左右。 这通常是月底的一个星期五,下午5点左右,正是你准备离开办公室和你的家人去看电影或者和你的朋友喝一些啤酒的时候)。 更糟糕的是,你的代码失败的地方很少是问题的起源地,因为你的引用可能已经被设置为null,离你代码中你打算解除引用的地方很远。所以,你最好取消周五晚上的那些计划......

值得一提的是,这个空引用的概念是由ALGOL的创建者Tony Hoare 在1965年首次提出的。其后果在当时并不明显,但他后来对自己的设计感到后悔,他称其为" 10亿美元的错误 ",正是指从那时起,我们中的许多人花了难以计数的时间来修复这种空解引用问题。

如果类型系统能够区分在特定环境下可能为空的引用和不可能为空的引用,那不是很好吗?这在类型安全方面会有很大帮助,因为编译器可以强制要求程序员在允许直接使用其他引用的同时,对可能为空的引用做一些验证。我们在这里看到了一个改进类型系统的机会。在编写API的公共接口时,这可能特别有用,因为它将增加语言的表达能力,除了文档之外,给我们一个工具来告诉我们的用户,一个给定的方法可能或不可能返回一个值。

现在,在我们进一步深入研究之前,我必须澄清,这是现代语言可能会追求的一个理想(我们稍后会谈论锡兰和Kotlin),但当我们打算在Java这样的编程语言中修复这个漏洞时,并不是一件容易的事。因此,在接下来的段落中,我提出了一些场景,在这些场景中,我认为使用可选对象可以说是减轻了一些负担。即便如此,恶有恶报,没有什么能很快摆脱空引用,所以我们最好学会处理它们。理解这个问题是一个步骤,我认为这些新的可选对象只是另一种   处理 的方式,特别是在某些特定的场景中,我们想表达没有一个值。

寻找元素

有一组成语,在这些成语中,使用空引用是有潜在问题的。其中一个常见的情况是,我们在寻找最终无法找到的东西。现在考虑一下下面这段简单的代码,用来寻找一个具有某个名字的水果列表中的第一个水果:


public static Fruit find(String name, List<Fruit> fruits) {
   for(Fruit fruit : fruits) {
      if(fruit.getName().equals(name)) {
         return fruit;
      }
   }
   return null;
}


我们可以看到,这段代码的创建者使用一个空引用来表示   没有一个满足搜索条件

的值 。但不幸的是,在方法签名中没有明确指出这个方法可能不会返回一个值,而是一个空引用。

现在考虑一下下面的代码片段,它是由一个期望使用上图所示方法结果的程序员编写的。


List<Fruit> fruits = asList(new Fruit("apple"),
                            new Fruit("grape"),
                            new Fruit("orange"));

Fruit found = find("lemon", fruits);
//some code in between and much later on (or possibly somewhere else)...
String name = found.getName(); //uh oh!


这样简单的一段代码有一个编译器无法发现的错误,甚至程序员也无法通过简单的观察(他可能无法获得 find 方法的源代码)。在这种情况下,程序员天真地没有认识到上面的 find 方法可能会返回一个空引用来表示没有满足他的谓词的值的情况。这段代码等待执行时就会简单地失败,无论多少文档都无法阻止这个错误的发生,编译器甚至不会注意到这里有一个潜在的问题。

还请注意,参照物被设置为空的那一行   与有问题的那一行

不同。在这种情况下,它们足够接近,在其他情况下,这可能就不那么明显了。

为了避免这个问题,我们通常做的是,在我们试图解除引用之前,检查一个给定的引用是否为空。事实上,这种检查是很常见的,在某些情况下,这种检查可以在一个给定的引用上重复很多次,以至于Martin Fowler(以其关于重构原则的书而闻名)建议,对于这些特定的场景,可以通过使用他所谓的   Null对象 来避免这种检查。在我们上面的例子中,我们可以不返回 null ,而是返回一个 NullFruit 对象引用,这是一个内部被掏空的 Fruit 类型的对象,与空引用不同,它能够正确地响应 Fruit 的相同公共接口。

最小和最大

另一个可能出现潜在问题的地方是当把一个集合减少到一个值时,例如减少到一个最大值或最小值。考虑一下下面这段代码,它可以用来确定哪个是一个集合中最长的字符串。


public static String longest(Collection<String> items) {
   if(items.isEmpty()){
      return null;
   }
   Iterator<String> iter = items.iterator();
   String result = iter.next();
   while(iter.hasNext()) {
       String item = iter.next();
       if(item.length() > result.length()){
          result = item;
       }
   }
   return result;
}


在这种情况下,问题是当提供的列表为空时,应该返回什么?在这种特殊的情况下,返回的是一个空值,这再次为潜在的空值脱引问题打开了大门。

函数式世界的策略

有趣的是,在函数式编程范式中,静态类型的编程语言朝着不同的方向发展了。在像SML或Haskell这样的语言中,不存在这样一个 null 值,它在被解引用时引起异常。这些语言提供了一种特殊的数据类型,能够容纳一个可选的值,因此它也可以方便地用来表达一个可能不存在的值。  下面这段代码显示了SML option 类型的定义。


datatype 'a option = NONE | SOME of 'a


正如你所看到的, option 是一个有两个构造函数的数据类型,其中一个不存储任何东西(即 NONE ),而另一个能够存储某个价值类型 'a 的多态值(其中 'a 只是实际类型的占位符)。

在这种模式下,我们之前用Java写的那段通过名字查找水果的代码,可以用SML改写成如下。


fun find(name, fruits) =
   case fruits of
        [] => NONE
      | (Fruit s)::fs => if s = name
                         then SOME (Fruit s)
                         else find(name,fs)


在SML中,有几种方法可以实现这一点,这个例子只是展示了一种方法。这里重要的一点是,不存在 null 这样的东西,相反,当什么都没有找到时,会返回一个值 NONE ,否则会返回一个值 SOME fruit option

当程序员使用这个 find 方法时,他知道它返回一个 option 类型的值,因此程序员不得不检查所获得的值的性质,看它是 NONE SOME fruit  还是 SOME fruit 

,有点像这样。


  让
   val fruits = [Fruit "apple", Fruit "grape", Fruit "orange"]
   val found = find("grape", fruits)
在
   case found of
       NONE => print("没有发现")
     | SOME(Fruit f) => print("发现水果: " ^ f)
结束  


必须检查返回选项的真实性质,这使得不可能误解结果。

Java 可选类型

令人高兴的是,在Java 8中,我们终于有了一个叫做   可选 的新类,它允许我们实现一个类似于函数世界中的习语。和SML的情况一样, Optional 类型是多态的,可以包含一个值,也可以为空。因此,我们可以将之前的代码片段重写如下。


public static Optional<Fruit> find(String name, List<Fruit> fruits) {
   for(Fruit fruit : fruits) {
      if(fruit.getName().equals(name)) {
         return Optional.of(fruit);
      }
   }
   return Optional.empty();
}


正如你所看到的,该方法现在返回一个 Optional 的引用

List<Fruit> fruits = asList(new Fruit("apple"),
                            new Fruit("grape"),
                            new Fruit("orange"));

Optional<Fruit> found = find("lemon", fruits);
if(found.isPresent()) {
   Fruit fruit = found.get();
   String name = fruit.getName();
}
,如果发现了什么, Optional 对象就会被构造出一个值

,否则就被构造成空的 。

而使用这段代码的程序员会做如下的事情。


List<Fruit> fruits = asList(new Fruit("apple"),
                            new Fruit("grape"),
                            new Fruit("orange"));

Optional<Fruit> found = find("lemon", fruits);
if(found.isPresent()) {
   Fruit fruit = found.get();
   String name = fruit.getName();
}


现在,在 find 方法的类型中很明显,它返回一个可选值

,这个方法的使用者必须相应地编写他的代码(6-7)。

所以我们看到,采用这种功能化的习语有可能使我们的代码更加安全,不容易出现空解引用的问题,因此更加健壮,不容易出错。当然,这并不是一个完美的解决方案,因为毕竟 Optional 引用也可能被错误地设置为空引用,但我希望程序员坚持在期望有可选对象的地方不传递空引用的惯例,就像我们今天认为在期望有集合或数组的地方不传递空引用一样,在这些情况下正确的做法是传递一个空数组或集合。这里的重点是,现在我们在API中有一个机制,我们可以用它来明确说明,对于一个给定的引用,我们可能没有一个值可以分配给它,而用户则被迫通过API来验证。

引用我后面提到的一篇关于Guava集合框架中可选对象的使用的文章。"除了给null一个   名字 带来的可读性的增加,Optional最大的优势是它的防白痴性。如果你希望你的程序能够被编译的话,它迫使你主动考虑不存在的情况,因为你必须主动解除Optional的包装并处理这种情况"。

其他方便的方法

到今天为止,除了上面解释的静态方法 ofemptyOptional 类还包含以下方便的实例方法。

ifPresent() 如果可选值中存在,则返回真。
get() 如果存在的话,返回对可选对象中包含的项目的引用,否则抛出一个 NoSuchElementException
ifPresent(Consumer<T> consumer) 如果存在的话,将可选的值传递给提供的消费者(可以通过lambda表达式或方法引用实现)。
orElse(T other) 如果存在的话,返回该值,否则返回其他值。
orElseGet(Supplier<T> other) 其中如果存在,则返回值,否则返回由供应商提供的值(可以通过lambda表达式或方法引用来实现)。
orElseThrow(Supplier<T> exceptionSupplier) 如果存在就返回值,否则就抛出供应商提供的异常(可以用λ表达式或方法引用来实现)。

避免模板式的存在检查

我们可以使用上面提到的一些方便的方法来避免必须检查一个值是否存在于可选对象中。例如,我们可能想在没有发现任何东西的情况下使用一个默认的水果值,比方说,我们想使用 "猕猴桃"。所以我们可以这样重写我们之前的代码。


Optional<Fruit> found = find("lemon", fruits);
String name = found.orElse(new Fruit("Kiwi")).getName();


在另一个例子中,如果有水果,代码将水果的名字打印到主输出端。在这种情况下,我们用一个lambda表达式来实现 Consumer


Optional<Fruit> found = find("lemon", fruits);
found.ifPresent(f -> { System.out.println(f.getName()); });


这另一段代码使用lambda表达式来提供一个 Supplier ,如果可选的对象是空的,最终可以提供一个默认的答案。


Optional<Fruit> found = find("lemon", fruits);
Fruit fruit = found.orElseGet(() -> new Fruit("Lemon"));


很明显,我们可以看到这些方便的方法简化了很多与可选对象有关的工作。

那么Optional有什么问题?

我们面临的问题是: Optional 能不能摆脱空引用?答案是断然否定的! 因此,反对者立即质疑它的价值,问道:那么它有什么好处,我们已经不能用其他方法来做了?

与SML o Haskell等函数式语言不同,它们从来没有空引用的概念,在Java中,我们不能简单地摆脱历史上存在的空引用。这将继续存在,而且它们可以说有其适当的用途(仅举一例。   三值逻辑 )。

我怀疑 Optional 类的意图是要取代每一个nullable引用,而是要帮助创建更强大的API,在这些API中,只要阅读一个方法的签名,我们就可以知道我们是否可以期待一个可选的值,并迫使程序员相应地使用这个值。但最终, Optional 将只是另一个引用,并受制于语言中其他引用的相同弱点。很明显, Optional 是不能拯救世界的。

这些可选对象应该如何使用,或者它们在Java中是否有价值,已经成为lambda项目邮件列表中   激烈辩论的问题。从反对者那里我们听到了一些有趣的论点,比如。

所以,看起来 Optional 的好处确实值得怀疑,而且可能只限于提高可读性和执行公共接口契约。

流API中的可选对象

不管争论如何,可选的对象是要留下来的,它们已经在新的流API中被用于像 findFirstfindAnymaxmin 这样的方法。 值得一提的是,   一个非常类似的类 已经在成功的Guava集合框架中被使用。

例如,考虑下面的例子,我们从一个流中按字母顺序提取最后的水果名称。


Stream<Fruit> fruits = asList(new Fruit("apple"),
                              new Fruit("grape")).stream();
Optional<Fruit> max = fruits.max(comparing(Fruit::getName));
if(max.isPresent()) {
   String fruitName = max.get().getName(); //grape
}


或者另一个例子,我们在一个流中获得第一个水果


Stream<Fruit> fruits = asList(new Fruit("apple"),
                              new Fruit("grape")).stream();
Optional<Fruit> first = fruits.findFirst();
if(first.isPresent()) {
   String fruitName = first.get().getName(); //apple
}


锡兰编程语言和可选类型

最近我开始玩了一下   锡兰 编程语言,因为我正在为另一篇文章做研究,我打算很快在这个博客上发表。我必须说,我不是锡兰的忠实粉丝,但我仍然发现,在锡兰中,可选值的概念得到了进一步的发展,而且语言本身也为这个习语提供了一些句法糖。在这种语言中,我们可以用 ? (问号)来标记任何类型,以表示其类型是一个可选类型。

例如,这个 find 函数将与我们原来的Java版本非常相似,但这次返回一个可选的 Fruit? 引用

List<Fruit> fruits = [Fruit("apple"),Fruit("grape"),Fruit("orange")];
Fruit? fruit = find("lemon", fruits);
print((fruit else Fruit("Kiwi")).name);
。同时注意到,一个 null 值与可选的 Fruit? 引用 orElse 是兼容的。


Fruit? find(String name, List<Fruit> fruits){
   for(Fruit fruit in fruits) {
      if(fruit.name == name) {
         return fruit;
      }
   }
   return null;
}


而且我们可以用这个锡兰代码来使用它,类似于我们上次的Jav**段,我们在其中使用了一个可选的值。


List<Fruit> fruits = [Fruit("apple"),Fruit("grape"),Fruit("orange")];
Fruit? fruit = find("lemon", fruits);
print((fruit else Fruit("Kiwi")).name);


注意这里的 else 关键字的使用与Java 8 Optional 类中的方法 orElse 相当相似。还注意到这个语法与C#的   nullable类型 的声明相似,但在锡兰的意思完全不同。值得一提的是,   Kotlin ,Jetbrains正在开发的编程语言,有一个与   null safety 相关的类似功能(所以也许我们是在编程语言的一个趋势之前)。

另一种方法是这样做的。


List<Fruit> fruits = [Fruit("apple"),Fruit("grape"),Fruit("orange")];
Fruit? fruit = find("apple", fruits);
if(exists fruit){
   String fruitName = fruit.name;
   print("The found fruit is: " + fruitName);
} //else...


注意这里的 exists 关键字的使用 find 与Java Optional 类中的 isPresent 方法调用的目的相同。

与Java相比,锡兰的巨大优势在于他们可以从一开始就在API中使用这种可选类型,在他们的语言领域内,他们不必处理不兼容的问题,而且在任何地方都可以完全支持它(也许他们的问题将是在与Java其他API的整合中,但我还没有研究过)。

希望在未来的Java版本中,来自Ceylon和Kotlin的这种同样的语法糖也能在Java编程语言中使用,也许在引擎盖下,使用Java 8中引入的这个新的 Optional 类。

进一步阅读


原文链接: Java Optional Objects