为什么 ImmutableCollection.contains(null) 会失败?

5
提前提问: 为什么在Java中对于ImmutableCollections调用coll.contains(null)会失败?
我知道,ImmutableCollections不能包含null元素,我不想讨论这是好事还是坏事。
但是当我编写一个函数,它接受(一般而不是显式的不可变)集合时,检查null时会失败。为什么实现不返回false(这实际上是“正确”的答案)?
并且如何正确地检查一般Collection中是否有null值?
编辑: 经过一些讨论(感谢评论者!),我意识到我混淆了两件事情:guava库的ImmutableCollection和java.util.List.of返回的List,后者是ImmutableCollections的某个类。 但是,这两个类都会在.contains(null)上抛出NPE异常。
我的问题在于List.of的结果,但从技术上讲,使用guave的实现也会发生同样的情况。[编辑:事实并非如此]

1
需要代码。你尝试了什么?你能展示一下你是如何产生这个问题的吗? - markspace
2
是的,那个集合因为多种原因违反了Liskov替换原则,而你遇到了其中之一,并且也知道为什么类不应该违反该原则。对于这个具体问题,没有清晰简洁的解决方法,你可以检查实际对象类型,然后根据类型进行不同的检查,但是这也是一个代码异味。顺便说一句:你可以向集合中添加项目,但是该类抛出异常,这也是一种不好的想法,但它就是这样。 - luk2302
@markspace 你可以按照我说的方式进行复制:在任何ImmutableCollection(例如ImmutableList)上调用.contains(null) - Dániel Somogyi
@luk2302 感谢你提供的原则。那正是我的问题,只是我没有为它命名。 我也知道当你插入时会抛出错误(这就是为什么它是不可变的),但我觉得对于包含函数来说很奇怪... - Dániel Somogyi
3个回答

13

我对这个讨论感到苦恼!

自从我写下最终成为Guava的第一个集合之前,这些集合就一直困扰着我。如果你发现任何一个Guava集合因为你问了一个完全无辜的问题,例如.contains(null)而抛出NPE,请提交一个bug!我们讨厌那种东西。

编辑:我非常苦恼,以至于我不得不回去查看我2007年的修改记录,那是我第一次创建ImmutableSet的时候看到的字面意思:

  @Override public boolean contains(@Nullable Object target) {
    if (target == null) {
      return false;
    }

啊哈哈哈。


那么,根据Guava的规范/API设计原则,immutableList.contains(null)应该解析为false而不是抛出NPE?我可以理解大多数遇到这个问题的人没有想到提交错误报告并认为这是预期行为(我也是!) - rzwitserloot
1
对不起:是谁撞到了什么?在这种情况下,有一个Guava集合在抛出异常吗? - Kevin Bourrillion
2
@KevinBourillion 犯了个错误。眼睛看到的是 return false;,但大脑却解析成了 throw new NullPointerException。大脑已经受到了应有的惩罚。 - rzwitserloot
3
我感到宽慰。但是自从那个评论之后……我们发现了一个问题!EvictingQueue出了点问题,所以我们正在修复它。 - Kevin Bourrillion

1

如果在ImmutableCollections上调用containsAll(),问题会变得更加严重:

@Override
public boolean contains(Object o) {
    return o.equals(e0) || e1.equals(o); // implicit nullcheck of o
}

它使用 AbstractCollection 的超级实现:

public boolean containsAll(Collection<?> c) {
    for (Object e : c)
        if (!contains(e))
            return false;
    return true;
}

当(不允许空值的)ImmutableCollection检查到另一个允许空值的集合时遇到null引用,就会引发NullPointerException

因此,在尝试像这样比较ImmutableCollectionHashSet(在OpenJDK 15中)时:

Set.of("").containsAll(Stream.of((Object)null).collect(Collectors.toSet()));

抛出了一个NullPointerException。这种情况在那些遗留项目中使用“现代”Collection API时会让工作变得非常不愉快。

你需要像这样进行一个可能荒谬的显式检查来比较“遗留”的Collection:

Collection coll = Stream.of((Object)null).collect(Collectors.toSet());
boolean hasNull = coll == null || coll.contains(null);
if (!hasNull) {
    Set.of("").containsAll(coll);
}

0
为什么在Java中,对于ImmutableCollections,调用coll.contains(null)会失败?
因为设计团队(创建guava的人)决定,在他们的集合中,null是不需要的,因此他们的集合与null检查之间的任何交互,即使在这种情况下,都应该抛出异常,以便尽早向程序员突出显示不匹配。即使已经建立了行为(如核心运行时本身中现有实现(例如ArrayList和friends,以及javadoc)),明确地走另一条路,并表示非语言检查(这个梨是否属于这个苹果列表?)强烈建议只返回false而不是抛出异常。
换句话说,guava搞砸了。但是现在他们已经这样做了,回头可能会破坏向后兼容性。这真的不是很好-您正在用抛出的异常替换为false返回值;可能存在依赖NPE的代码(捕获它并执行与contains(null)返回false而不是抛出异常不同的操作)-但这是一个罕见的情况,而且guava总是打破向后兼容性。
如何在一般情况下正确检查集合中的空值?
通过调用.contains(null),就像你现在所做的一样。Guava不能正确处理这个问题并不改变答案。你也可以问“如何向列表添加元素”,并反驳回答“好吧,你调用list.add(item)来实现”,说:“我有一个实现了List接口的程序,它会播放Rick Astley的歌曲而不是添加到列表中,所以我拒绝你的答案。”
这就是Java和接口的工作方式:你可以对它们进行实现,唯一需要遵循的是作者理解必须遵循的契约。
通常来说,一个编写得如此糟糕以至于无缘无故破坏契约的库是不流行的。但是Guava很受欢迎。非常受欢迎。这表明了一个简单的事实:没有哪个库是完美的。Guava的API设计通常相当不错(在我看来,比例如Apache commons库要好得多),团队积极花费大量时间讨论适当的API设计,其代码使用Guava编写的效果很好(即易于理解,少有意外,易于维护,易于测试,也可能容易突变以应对变化的需求——对于像“美观”或“优雅”代码这样模糊的术语,唯一有用的定义就是能做到这些事情的代码,其他一切都是毫无意义的审美空谈)。换句话说,他们正在积极尝试,并且通常会做得正确。
只是,在这种情况下不是这样的。解决方法是:`return item != null && coll.contains(item);` 将完成任务。

在支持guava的选择方面有一个主要的论点:'contract break'是一种隐式的中断 - 人们会期望.contains(null)有效,并且总是返回false,但是在javadoc中没有明确说明必须这样做。相比之下,例如IdentityHashMap在其.containsKey等实现中使用标识等价性(a==b)而不是值等价性(a.equals(b)),这明确违反了j.u.Map接口中规定的javadoc合同。IHM有一个很好的理由,并在javadoc中强调了差异,并解释了原因。Guava对于它们奇怪的null行为并不那么清楚,但是,关于java中的null有一个至关重要的事情:

它的含义模糊不清。有时它意味着“空”,这是一种糟糕的设计:您永远不应该编写 if (x == null || x.isEmpty()) - 这意味着某些 API 编码存在问题。如果 null 在语义上等同于某个值(例如 ""List.of()),那么您应该返回 ""List.of(),而不是 null。但是,在这样的设计中,list.contains(null) == false)是有意义的。

但有时候null表示“未找到”,“不相关”,“不适用”或“未知”(例如,如果map.get(k)返回null,则表示未找到。不是“我为你找到一个空值”)。这与SQL中NULL的含义相符。在所有这些情况下,.contains(null)既不应该返回true也不应该返回false。如果我给你一个弹珠袋,并问你里面是否有一个蓝绿色的弹珠,而你不知道“蓝绿色”的含义,你不应该对我的询问回答“是”或“否”:任何一种回答都毫无意义。你应该告诉我这个问题无法被回答。这在Java中最好通过抛出异常来表示,这也正是Guava所做的。这也与SQL中的NULL的作用相符。在SQL中,v IN (x)返回3个值之一,而不是2个值:它可以解析为true、false或null。v IN (NULL)将解析为NULL而不是false。它回答了一个无法通过NULL值回答的问题,NULL的含义是:不知道。
换句话说,guava 对于 null 的含义做出了一种调用,显然与您的定义不匹配,因为您期望 .contains(null) 返回 false。我认为您的观点更符合惯用法,但重要的是,guava 的观点是不同的但也是一致的,而 javadoc 只是在暗示,但并没有明确要求 .contains(null) 返回 false。
这对于修改您的代码毫无用处,但希望它能给您提供一个心智模型,并回答您的问题“为什么会这样工作?”

1
很有帮助,谢谢。几点评论:IHM的行为很“奇怪”,但它表述得非常清楚。另一方面,我花了一段时间才找到ImmutableCollection是我的问题根源...我想我没有预料到java.util包中会有这样的行为。此外,你的代码x == null || x.isEmpty()不够用,我真的需要知道null是否在集合中(如果它不是不可变的话)。我猜我需要一个instanceof。 - Dániel Somogyi
是的,一开始我搞混了。我以为Java的ImmutableCollections(例如从java.util.List.of()返回)和Guava的ImmutableCollection是相同的。(在寻找解决方案时搞混了)。我也知道你不能将等式处理为任何包含函数之一,那只是我对如何处理“grue”的想法。在我看来,这是Java的缺陷,通用的等式函数取决于两个对象中的一个非空,并且你甚至必须知道哪一个(例如,null.equals(a)失败)。 - Dániel Somogyi
3
所以,嗯...这里没有人想过检查一下对番石榴的指控是否曾经属实吗? :-) - Kevin Bourrillion
@KevinBourrillion,“指控”石榴对空值的处理方式与SQL类似(即:它表示未知/意外,因此通常在涉及空值时无法回答任何问题,因此异常比任意答案更好)这一点是什么意思?我实际上非常喜欢这种对空值的处理方式,所以我不确定“指控”是最好的选择。如果您指的是其他事情,请说出来。我总是对与那些拥有许多我喜欢的API的人进行API设计辩论感兴趣。 - rzwitserloot
1
@KevinBourrillion Collection.contains允许抛出NullPointerException,一直以来都是如此。自从一开始就有一些集合类会这样做,例如TreeSet或者遗留的Hashtable类的keySet()values()方法。在Java 5中添加的并发集合类也会在调用contains(null)时抛出异常。 - Holger
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接