为什么要明确地抛出NullPointerException而不是让它自然发生?

194

阅读JDK源代码时,我发现作者经常会检查参数是否为空,然后手动抛出一个新的NullPointerException()异常。他们为什么要这样做呢?我认为没有必要这样做,因为当调用任何方法时,它会自动抛出新的NullPointerException()异常。(例如HashMap的一些源代码:)

public V computeIfPresent(K key,
                          BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
    if (remappingFunction == null)
        throw new NullPointerException();
    Node<K,V> e; V oldValue;
    int hash = hash(key);
    if ((e = getNode(hash, key)) != null &&
        (oldValue = e.value) != null) {
        V v = remappingFunction.apply(key, oldValue);
        if (v != null) {
            e.value = v;
            afterNodeAccess(e);
            return v;
        }
        else
            removeNode(hash, key, null, false, true);
    }
    return null;
}

36
编码的一个关键点是意图。 - Scary Wombat
20
这是你的第一个问题,非常好!我进行了一些小的编辑,希望你不介意。我还删除了感谢和关于这是你的第一个问题的说明,因为通常这类内容不包括在SO问题中。 - David Conrad
11
在C#中,惯例是在类似这种情况下引发ArgumentNullException异常(而不是NullReferenceException)。实际上,为什么要在这里显式引发NullPointerException异常而不是其他异常,这是一个非常好的问题。 - EJoshuaS - Stand with Ukraine
21
这是一个旧辩论,关于在参数为空时是抛出IllegalArgumentException还是NullPointerException。JDK惯例是后者。 - shmosel
33
真正的问题是它们会抛出错误并丢弃导致该错误的所有信息。看起来这确实是实际的源代码,甚至没有一个简单的字符串消息。遗憾。 - Martin Ba
显示剩余8条评论
9个回答

267

有很多原因可以想到,其中几个原因密切相关:

快速失败:如果它要失败,最好尽早失败。这样可以更容易地识别和恢复问题,并避免浪费CPU周期在注定会失败的代码上。

意图:显式抛出异常清楚地表明维护人员错误是故意的,作者已经意识到后果。

一致性:如果允许自然发生错误,则可能不会在每种情况下发生。例如,如果没有找到映射,则永远不会使用remappingFunction,也不会抛出异常。提前验证输入可以实现更确定的行为和更清晰的文档

稳定性:代码随时间而演变。自然遇到异常的代码可能在稍微重构后停止这样做,或者在不同情况下这样做。显式抛出它使得行为不会意外改变的可能性更小。


14
同样地:这样异常被抛出的位置就与一个检查过的变量精确地绑定在一起。如果没有它,异常可能是由于多个变量为空而引起的。 - Jens Schauder
46
另一个问题:如果您等待自然发生NPE,一些中间代码可能已经通过副作用改变了程序的状态。 - Thomas
6
尽管该片段未这样做,但您可以使用“new NullPointerException(message)”构造函数来澄清哪些是空值。这对于没有访问源代码的人很有用。在JDK 8中,他们甚至将此变成了一个一行代码的实用程序方法:“Objects.requireNonNull(object, message)”。 - Robin
3
“失败”应该与“错误”非常接近。“快速失败”不仅是一个经验法则。您何时不希望这种行为?任何其他行为意味着您正在隐藏错误。存在“错误”和“失败”。当此程序吞下空指针并崩溃时,就会发生“失败”。但是故障不在那一行代码上。空指针来自某个地方--一个方法参数。谁传递了该参数?从一些引用本地变量的代码行。那...你看到了吗?这很糟糕。谁应该负责看到坏值被存储了?那时您的程序应该崩溃。 - Noah Spurrier
4
不错的观点。Shmosel:托马斯的观点可能隐含在“故障快速失败”这一点中,但它有些被埋没了。这是一个非常重要的概念,它有自己的名字:“故障原子性”。请参考布洛赫的《Effective Java》第46条。它的语义比“故障快速失败”更强。我建议单独指出这一点。总的来说,回答非常好。+1 - Stuart Marks

40
为了清晰、一致并防止执行额外不必要的工作,需要使用保护性语句。
考虑如果方法顶部没有保护性语句会发生什么。即使在抛出NPE之前将null传递给了remappingFunction,它仍然会总是调用hash(key)getNode(hash, key)
更糟糕的是,如果if条件为false,则会进入else分支,该分支根本不使用remappingFunction,这意味着当传递null时,该方法并不总是抛出NPE;它是否抛出取决于映射表的状态。
这两种情况都很糟糕。如果null不是remappingFunction的有效值,则该方法应始终在调用时一致地抛出异常,而无论对象的内部状态如何,它都应该这样做,而且在抛出后不应执行无意义的不必要的工作。最后,在干净、清晰的代码方面有一个良好的原则,就是将保护性语句放在最前面,以便任何审查源代码的人可以轻松地看到它会这样做。
即使目前代码的每个分支都抛出异常,将来也可能会更改。在开头执行检查可确保它一定会被执行。

24
除了 @shmosel 的答案所列出的原因之外,还有其他原因:
性能方面:在某些JVM上,显式抛出NPE可能比让JVM自行处理更具有性能优势。这取决于Java解释器和JIT编译器检测空指针解引用的策略。一种策略是不测试null,而是陷入SIGSEGV(当指令尝试访问地址0时发生)的陷阱中。这是在引用始终有效的情况下最快的方法,但在NPE情况下非常昂贵。
如果在代码中显式测试null,则可以避免在NPE频繁发生的情况下陷入SIGSEGV性能损失。(我怀疑这在现代JVM中不会是一种值得进行微观优化的方式,但在过去可能会是。)
兼容性方面:异常中没有消息的可能原因是为了与JVM本身抛出的NPE兼容。在合规的Java实现中,JVM抛出的NPE具有null消息。(Android Java则不同。)

21
除了其他人指出的,值得注意的是约定的作用。例如,在C#中,您也有明确引发异常的惯例,例如在这种情况下,但它具体是一个“ArgumentNullException”,这样更加具体。(C#的惯例是,“NullReferenceException”始终表示某种错误-简单地说,在生产代码中它不应该发生;当然,“ArgumentNullException”通常也会出现,但它可能是一种更像“您不了解如何正确使用库”类型的错误)。
因此,在C#中,“NullReferenceException”基本上意味着您的程序实际上尝试使用它,而“ArgumentNullException”则意味着它认识到该值是错误的,甚至不费心去尝试使用它。其影响实际上可能是不同的(取决于情况),因为"ArgumentNullException"意味着相关方法还没有副作用(因为它未通过方法前提条件)。
顺便说一句,如果你正在抛出类似于"ArgumentNullException"或者“IllegalArgumentException”的异常,那么这就是进行检查的其中一部分:您希望获得比您通常获得的不同类型的异常。
无论如何,明确引发异常强化了好习惯,即明确说明方法的前提条件和预期参数,从而使代码更易于阅读、使用和维护。如果您没有明确检查“null”,我不知道是因为您认为没有人会传递“null”参数,还是因为您打算抛出异常,或者只是忘记检查。

4
我认为相关代码应该使用 'throw new IllegalArgumentException("remappingFunction cannot be null");' 这样一来,问题就会立即显现出来。目前的 NPE 显示有些模糊不清。 - Chris Parker
1
@ChrisParker 我曾经也持有同样的观点,但事实证明NullPointerException旨在表示将null参数传递给期望非null参数的方法,除了作为尝试取消引用null的运行时响应。从javadoc中可以看到:“应用程序应该抛出此类的实例来指示对“null”对象的其他非法使用。”我不是很喜欢它,但这似乎是预期的设计。 - VGR
1
我同意,@ChrisParker - 我认为该异常更具体(因为代码甚至没有尝试使用该值,它立即意识到不应该使用它)。在这种情况下,我喜欢C#的惯例。C#的惯例是NullReferenceException(相当于NullPointerException)表示您的代码实际上尝试使用它(这始终是一个错误 - 在生产代码中永远不应该发生),而不是“我知道参数是错误的,所以我甚至没有尝试使用它。”还有ArgumentException(表示参数由于其他原因是错误的)。 - EJoshuaS - Stand with Ukraine
2
我只想说,我总是按照所描述的那样抛出 IllegalArgumentException 异常。当我觉得某种约定很愚蠢时,我总是感到自在地违背它。 - Chris Parker
1
@PieterGeerkens - 是的,因为空指针异常在35行比非法参数异常("函数不能为空")在35行更好。 说真的? - Chris Parker
显示剩余3条评论

12

这是为了让你在犯错时尽早得到异常提示,而不是在使用地图时才出现异常而不知道原因。


9
它将看似不稳定的错误状态转化为明确的合同违约:该函数有一些工作正确的前提条件,因此它会在运行之前检查这些条件并强制满足它们。
其效果是,当你从中获得异常时,你不必调试computeIfPresent()。一旦你看到异常来自于前提条件检查,你就知道你使用了非法参数调用函数。如果没有进行检查,则需要排除computeIfPresent()本身存在导致异常抛出的错误可能性。
显然,抛出通用的NullPointerException是一个非常糟糕的选择,因为它本身并不表示合同违约。更好的选择是IllegalArgumentException
附注:
我不知道Java是否允许这样做(我怀疑不允许),但C/C++程序员在这种情况下使用一个assert(),这对调试来说更加友好:它告诉程序,如果提供的条件评估为false,立即崩溃并以最大的力度崩溃。因此,如果你运行
void MyClass_foo(MyClass* me, int (*someFunction)(int)) {
    assert(me);
    assert(someFunction);

    ...
}

如果在调试器下,某个参数传入了NULL,程序将会停在告知哪个参数是NULL的那一行,并且您可以随意检查整个调用栈中的所有局部变量。


1
确保 something != null;但是在运行应用程序时需要使用 -assertions 标志。如果没有 -assertions 标志,assert 关键字将不会抛出 AssertionException。 - Zoe stands with Ukraine
我同意,这就是为什么我更喜欢在这里使用C#约定 - 空引用、无效参数和空参数通常都意味着某种错误,但它们暗示了不同类型的错误。 "您正在尝试使用空引用"通常与"您正在错误使用库"非常不同。 - EJoshuaS - Stand with Ukraine

7

这是因为它可能不会自然发生。让我们看看像这样的代码片段:

bool isUserAMoron(User user) {
    Connection c = UnstableDatabase.getConnection();
    if (user.name == "Moron") { 
      // In this case we don't need to connect to DB
      return true;
    } else {
      return c.makeMoronishCheck(user.id);
    }
}

当然,这个代码示例中存在许多关于代码质量的问题。抱歉我太懒了,无法想象完美的样本。

即使 c == null ,也有可能不会实际使用 c 并且不会抛出 NullPointerException 的情况。

在更复杂的情况下,很难追踪此类情况。这就是为什么像 if(c == null)throw new NullPointerException()这样的一般检查更好的原因。


可以说,当不真正需要数据库连接时,能够正常工作的代码是一件好事情;而仅仅为了测试连接是否失败而连接到数据库的代码通常会让人感到非常烦恼。 - Dmitry Grigoryev

5

这是有意为之的,目的是为了保护免受进一步损害,或者避免陷入不一致的状态。


2
除了其他优秀答案之外,我还想补充一些情况。
如果您创建自己的异常,可以添加消息。
如果您抛出自己的 NullPointerException,则可以添加消息(您绝对应该这样做!)。
默认消息是 new NullPointerException() 中的 null 和使用它的所有方法,例如 Objects.requireNonNull。如果打印该 null,甚至可以转换为空字符串...
有点短而且不太有信息量......
堆栈跟踪会提供很多信息,但是为了让用户知道是什么 null,他们必须挖掘代码并查看确切的行。
现在想象一下将 NPE 包装并作为 Web 服务错误中的消息发送,例如在不同的部门甚至组织之间。最坏的情况是,可能没有人知道 null 代表什么...
链式方法调用将使您猜测。
异常只会告诉您发生异常的行。考虑以下行:
repository.getService(someObject.someMethod());

如果你收到一个NPE并且它指向这一行,那么repositorysomeObject中哪一个是空的?相反,当你获取这些变量时检查它们,至少会指向一个只处理这些变量的行。而且,正如之前提到的,如果你的错误信息包含变量的名称或类似的内容,那就更好了。
在处理大量输入时出现错误时应该提供识别信息。想象一下,你的程序正在处理一个有数千行的输入文件,突然出现了一个NullPointerException。你看着这个地方,意识到某些输入是不正确的...是哪些输入呢?你需要更多关于行号的信息,也许是列号,甚至是整个行文本,才能理解哪一行需要修复。

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