You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

14 KiB

14 | 禁止空指针,该怎么避免崩溃的空指针?

你好我是范学雷。今天我们讨论Java的空指针。

我们都知道空指针它的发明者开玩笑似的称它是一个价值10亿美元的错误同时呢他还称C语言的get方法是一个价值100亿美元的错误。空指针真的错得这么厉害吗get方法又有什么问题我们能够在Java语言里改进或者消除空指针吗

我们从阅读案例开始,来看一看该怎么理解这些问题,以及怎么降低这些问题的影响。

阅读案例

通常地一个人的姓名包括两个部分Last Name和名First Name。在有些文化里也会使用中间名Middle Name。所以我们通常可以使用姓、名、中间名这三个要素来标识一个人的姓名。用代码的形式表示出来就是下面的代码这样。

public record FullName(String firstName,
        String middleName, String lastName) {
    // blank
}

中间名并不是必需的因为有的人使用中间名有的人不使用。现在我们假设需要判断一个人的中间名是不是黛安Diane。这个判断的逻辑可能就像下面的代码这样。

private static boolean hasMiddleName(
        FullName fullName, String middleName) {
    return fullName.middleName().equals(middleName);
}

这个判断的逻辑是没有问题的。但是它的代码实现就存在没有校验空指针的错误。如果一个人不使用中间名那么FullName.middleName这个方法的返回值就是一个空指针。 如果一个对象是空指针那么调用它的任何方法都会抛出空指针异常NullPointerException

我们可以试着使用JDK 11的JShell看一看空指针异常的异常信息是什么样子的。

$ jshell -v
|  Welcome to JShell -- Version 11.0.13
|  For an introduction type: /help intro

jshell> String a = null;
a ==> null
|  created variable a : String

jshell> a.equals("b");
|  Exception java.lang.NullPointerException
|        at (#2:1)

然后我们再试试看JDK 17里空指针异常信息是什么样的。

$ jshell -v
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro

jshell> String a = null;
a ==> null
|  created variable a : String

jshell> a.equals("b");
|  Exception java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because "REPL.$JShell$11.a" is null
|        at (#2:1)

对比一下我们可以看到JDK 17的异常信息里包含了调用者REPL.$JShell$11.a和被调用者String.equals(Object)的信息而JDK 11里调用者的信息需要从调用堆栈里寻找而且没有被调用者的信息。

这是空指针异常的一个小的改进。它简化了问题排查的流程,提高了问题排查的效率。

好的我们再回到主题看一看空指针异常到底有什么危害。按照我们前面讨论过的中间名的逻辑有的人不使用中间名。那么如果一个对象的中间名是空值也就意味着他没有中间名。可是在上面的实现代码里如果中间名是空值hasMiddleName抛出了空指针异常而不是通过返回值来表示这个对象没有中间名。

这当然是一个错误。我们**需要检查返回值有没有可能是空指针,然后才能继续使用返回值。**这是一个C语言或者Java语言软件工程师需要掌握的基本常识。当然这也是一个我们编码的时候需要遵守的纪律。

检查返回值有没有可能是空指针需要额外的代码,而且不符合我们的思维习惯。下面的代码,我添加了空指针的检查,这就让它看起来就有点臃肿。这就是精准控制的代价。

private static boolean hasMiddleNameImplA(
        FullName fullName, String middleName) {
    if (fullName.middleName() != null) {
        return fullName.middleName().equals(middleName);
    }

    return middleName == null;
}

**空指针的问题,其实是我们人类行为方式的一个反映。**无论是纪律还是常识如果没有配以强制性的手段都没有办法获得100%的执行。如果不能100%地执行,一个危害就会从一个小小的局部,蔓延到一个庞大的系统。

今天的应用程序我们几乎可以肯定地说都是由很多小的部件组合起来的。其中99%以上的部件,我们都不了解,甚至都不知道它们的存在。任何一个小的部件出了问题,都会蔓延开来,酝酿出一个更大的问题。

在C语言和Java语言里存在着大量的空指针。不管我们怎么努力也不管我们经验多么丰富总是会时不时地就忘了检查空指针。而忘了检查这样的小错误很可能就蔓延成严重的事故。所以空指针发明者称它是一个价值10亿美元的错误。

那有什么办法能够降低空指针的负面影响呢?

避免空指针

降低空指针的负面影响的最重要的办法,就是不要产生空指针。没有空指针的代码,代码更简洁,风险也更小。

比如说我们可以使用空字符串来替代字符串的空指针。如果用这种思路我们就可以把阅读案例里FullName档案类修改成不使用空指针的版本了。

public record FullName(String firstName,
        String middleName, String lastName) {
    public FullName(String firstName,
            String middleName, String lastName) {
        this.firstName = firstName == null ? "" : firstName;
        this.middleName = middleName == null ? "" : middleName;
        this.lastName = lastName == null ? "" : lastName;
    }
}

这样,我们就不用检查空指针了;因此,也就不用担心空指针带来的问题了。所以,代码的使用也就变得简洁了起来。

private static boolean hasMiddleName(
        FullName fullName, String middleName) {
    return fullName.middleName().equals(middleName);
}

在很多场景下我们都可以使用空值来替代空指针比如空的字符串、空的集合。在API设计的时候如果碰到了使用空指针的规范或者代码我们要停下来想一想有没有替代空指针的办法如果能够避免空指针我们的代码会更健壮更容易维护。

强制性检查

不过,不是在所有的情况下我们都能够避免空指针的。如果空指针不能避免,降低空指针的负面影响的另外一个办法,就是在使用空指针的时候,执行强制性的检查。所谓强制性的检查,对于编程语言来说,指的是我们通常能够依赖的是编译器的能力,以及新的接口设计思路。

不尽人意的Optional

在JDK 8正式发布而后在JDK 9和11持续改进的Optional工具类是JDK试图降低空指针风险的一个尝试。

设计Optional的目的是希望开发者能够先调用它的Optional.isPresent方法然后再调用Optional.get方法获得目标对象。 按照设计者的预期这个Optional类的使用应该像下面的代码这样。

private static boolean hasMiddleName(
        FullName fullName, String middleName) {
    if (fullName.middleName().isPresent()) {
        return fullName.middleName().get().equals(middleName);
    }

    return middleName == null;
}

当然我们还需要修改FullName的API就像下面的代码这样。

public final class FullName {
    // snipped
    public Optional<String> middleName() {
        return Optional.ofNullable(middleName);
    }
    // snipped
}

遗憾的是我们也可以不按照预期的方式使用它比如下面的代码我们就没有调用Optional.isPresent方法而是直接使用了Optional.get方法。这不在设计者的预期之内但是这是合法的代码。

private static boolean hasMiddleName(FullName fullName, String middleName) {
    return fullName.middleName().get().equals(middleName);
}

如果Optional指代的对象不存在或者是个空指针Optional.get方法就会抛出NoSuchElementException异常。和空指针异常一样这个异常也是运行时异常。虽然这个异常的名字不再叫做空指针异常但它实质上依然是空指针异常。当然这个异常也具有和空指针异常相同的问题。

如果你对比一下使用空指针的代码和使用Optional类的代码就会发现这两个类型的代码不论是正确的使用方法还是错误的使用方法它们在形式上是相似的。Optional带来了不必要的复杂性然而它并没有简化开发者的工作也没有解决掉空指针的问题。

被寄予厚望的Optional的设计不能尽如人意。

新特性带来的新希望

那么,对于空指针的检查,我们能不能借助编译器,让它变得更强硬一点呢?下面的例子,就是我们使用新特性来解决空指针问题的一个新的探索。

我们希望返回值的检查是强制性的。如果不检查,就没有办法得到返回值指代的真实对象。实现的思路,就是使用封闭类和模式匹配。

首先呢我们定义一个指代返回值的封闭类Returned。为什么使用封闭类呢因为封闭类的子类可查可数。可查可数也就意味着我们可以有简单的模式匹配。

public sealed interface Returned<T> {
    Returned.Undefined UNDEFINED = new Undefined();

    record ReturnValue<T>(T returnValue) implements Returned {
    }

    record Undefined() implements Returned {
    }
}

然后呢我们就可以使用Returned来表示返回值了。

public final class FullName {
    // snipped
    public Returned<String> middleName() {
        if (middleName == null) {
            return Returned.UNDEFINED;
        }

        return new Returned.ReturnValue<>(middleName);
    }
    // snipped
}

最后我们来看看Returned是怎么使用的。

private static boolean hasMiddleName(FullName fullName, String middleName) {
    return switch (fullName.middleName()) {
        case Returned.Undefined undefined -> false;
        case Returned.ReturnValue rv -> {
            String returnedMiddleName = (String)rv.returnValue();
            yield returnedMiddleName.equals(middleName);
        }
    };
}

这种使用了封闭类和模式匹配的设计,极大地压缩了开发者的自由度,强制要求开发者的代码必须执行空指针的检查,只有这样才能编写下一步的代码。 这种看似放弃了灵活性的设计,恰恰把开发者从低级易犯的错误中解救了出来。不论是对写代码的开发者,还是对读代码的开发者来说,这都是一件好事。

好事情的背后往往都意味着一些妥协。比如说吧使用空指针的代码我们可以轻松地使用档案类使用Optional和Returned的代码我们就要重新回到传统的类上面来了。

无论档案类、封闭类还是模式匹配对于Java来说都还是新鲜的技术。要想让这些技术之间熟练配合还需要一些这样或者那样的磨练包括不停地改进组合效应的新研究等。

总结

好,到这里,我来做个小结。前面,我们讨论了空指针带来的问题,以及降低空指针负面影响的一些办法。

总体来说,在我们的代码里,尽量不要产生空指针。没有空指针,也就没有了空指针的烦恼。

如果避免不了空指针,我们就要看看能不能执行强制性的检查。比如使用封闭类和模式匹配的组合形式,让编译器和接口设计帮助我们实施这种强制性。

如果不能实施强制性的检查,我们就要遵守空指针的编码纪律。也就是说,对于可能是空指针的变量,先检查后使用。

如果面试中聊到了空指针的问题,你可以聊一聊空指针的危害,以及我们这一次学习到的解决办法。

思考题

今天,我们使用封闭类和模式匹配来降低空指针危害的例子,有点像我们前面提到过的替代异常处理的错误码方案。其实,一个带有返回值的方法,通常要考虑三种情况:正常情况、异常情况以及空指针。我们可以把空指针解读为正常情况,也可以解读为异常情况。

如果要在返回值这个封闭类里考虑进这三种情况,我们该怎么设计这个封闭类以及它的许可类呢?这是我们这一次的思考题。

为了方便你阅读我把我们这次讨论用到的Returned的实现代码拷贝到了下面。你可以在这个基础上修改。

public sealed interface Returned<T> {
    Returned.Undefined UNDEFINED = new Undefined();

    record ReturnValue<T>(T returnValue) implements Returned {
    }

    record Undefined() implements Returned {
    }
}

欢迎你在留言区留言、讨论,分享你的阅读体验以及你的设计和代码。我们下节课见!

注:本文使用的完整的代码可以从GitHub下载,你可以通过修改GitHubreview template代码,完成这次的思考题。如果你想要分享你的修改或者想听听评审的意见,请提交一个 GitHub的拉取请求Pull Request并把拉取请求的地址贴到留言里。这一小节的拉取请求代码请在空指针专用的代码评审目录建一个以你的名字命名的子目录代码放到你专有的子目录里。比如我的代码就放在nullp/review/xuelei的目录下面。