• Day722. 空指针烦恼 -Java8后最重要新特性


    空指针烦恼

    Hi,阿昌来也,今天来学习记录的是关于空指针的问题&和对应在新特性中可以使用什么方式去避免空指针问

    我们都知道空指针,它的发明者开玩笑似的,称它是一个价值 10 亿美元的错误;

    同时呢,他还称 C 语言的 get 方法是一个价值 100 亿美元的错误。空指针真的错得这么厉害吗?

    get 方法又有什么问题?能够在 Java 语言里改进或者消除空指针吗?

    一、阅读案例

    通常地,一个人的姓名包括两个部分,姓(Last Name)和名(First Name)。

    在有些文化里,也会使用中间名(Middle Name)。所以,我们通常可以使用姓、名、中间名这三个要素来标识一个人的姓名。

    用代码的形式表示出来,就是下面的代码这样。

    public record FullName(String firstName,
            String middleName, String lastName) {
        // blank
    }
    
    • 1
    • 2
    • 3
    • 4

    中间名并不是必需的,因为有的人使用中间名,有的人不使用。

    现在我们假设,需要判断一个人的中间名是不是黛安(Diane)。

    这个判断的逻辑,可能就像下面的代码这样。

    private static boolean hasMiddleName(
            FullName fullName, String middleName) {
        return fullName.middleName().equals(middleName);
    }
    
    • 1
    • 2
    • 3
    • 4

    这个判断的逻辑是没有问题的。

    但是它的代码实现,就存在没有校验空指针的错误。

    如果一个人不使用中间名,那么 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)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    然后,我们再试试看 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)
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    对比一下,我们可以看到,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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    空指针的问题,其实是我们人类行为方式的一个反映

    无论是纪律还是常识,如果没有配以强制性的手段,都没有办法获得 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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这样,我们就不用检查空指针了;

    因此,也就不用担心空指针带来的问题了。

    所以,代码的使用也就变得简洁了起来。

    private static boolean hasMiddleName(
            FullName fullName, String middleName) {
        return fullName.middleName().equals(middleName);
    }
    
    • 1
    • 2
    • 3
    • 4

    在很多场景下,我们都可以使用空值来替代空指针,比如,空的字符串、空的集合。

    在 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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

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

    public final class FullName {
        // snipped
        public Optional<String> middleName() {
            return Optional.ofNullable(middleName);
        }
        // snipped
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    遗憾的是,我们也可以不按照预期的方式使用它,比如下面的代码,我们就没有调用Optional.isPresent方法,而是直接使用了 Optional.get 方法。

    这不在设计者的预期之内,但是这是合法的代码。

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

    如果 Optional 指代的对象不存在,或者是个空指针,Optional.get 方法就会抛出 NoSuchElementException 异常。

    和空指针异常一样,这个异常也是运行时异常。虽然这个异常的名字不再叫做空指针异常,但它实质上依然是空指针异常。

    当然,这个异常也具有和空指针异常相同的问题。

    如果你对比一下使用空指针的代码和使用 Optional 类的代码,就会发现这两个类型的代码,不论是正确的使用方法还是错误的使用方法,它们在形式上是相似的。

    Optional 带来了不必要的复杂性,然而它并没有简化开发者的工作,也没有解决掉空指针的问题。

    五、新特性带来的新希望

    那么,对于空指针的检查,我们能不能借助编译器,让它变得更强硬一点呢?

    下面的例子,就是我们使用新特性来解决空指针问题的一个新的探索。

    我们希望返回值的检查是强制性的。

    如果不检查,就没有办法得到返回值指代的真实对象。

    实现的思路,就是使用封闭类switch模式匹配

    首先呢,我们定义一个指代返回值的封闭类 Returned。

    为什么使用封闭类呢,因为封闭类的子类可查可数。

    可查可数,也就意味着我们可以有简单的模式匹配。

    public sealed interface Returned<T> {
        Returned.Undefined UNDEFINED = new Undefined();
    
        record ReturnValue<T>(T returnValue) implements Returned {
        }
    
        record Undefined() implements Returned {
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

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

    public final class FullName {
        // snipped
        public Returned<String> middleName() {
            if (middleName == null) {
                return Returned.UNDEFINED;
            }
    
            return new Returned.ReturnValue<>(middleName);
        }
        // snipped
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    最后,来看看 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);
            }
        };
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这种使用了封闭类和模式匹配的设计,极大地压缩了开发者的自由度,强制要求开发者的代码必须执行空指针的检查,只有这样才能编写下一步的代码。

    这种看似放弃了灵活性的设计,恰恰把开发者从低级易犯的错误中解救了出来。不论是对写代码的开发者,还是对读代码的开发者来说,这都是一件好事。

    好事情的背后,往往都意味着一些妥协。

    比如说吧,使用空指针的代码,我们可以轻松地使用档案类;

    使用 Optional 和 Returned 的代码,我们就要重新回到传统的类上面来了。

    无论档案类、封闭类还是模式匹配,对于 Java 来说,都还是新鲜的技术。

    要想让这些技术之间熟练配合,还需要一些这样或者那样的磨练,包括不停地改进,组合效应的新研究等。

    六、总结

    在代码里,尽量不要产生空指针。

    没有空指针,也就没有了空指针的烦恼。

    如果避免不了空指针,我们就要看看能不能执行强制性的检查。

    • 通过使用空值去代替空指针
    • 通过@NotNull注解,只是告诉调用者我这个函数返回值可能是空
    • 通过Optional类,Optional 的价值不在于能避免空指针,更多的是一个强制性的提醒,让你去强制校验空指针。如果你去问一个开发为啥NPE了,得到的答案可能就是两个字: 忘了, 就这么直接。所以optional 是提醒你要去校验,而不是能替你解决。
    • 使用封闭类和模式匹配的组合形式,让编译器和接口设计帮助我们实施这种强制性。如果不能实施强制性的检查,就要遵守空指针的编码纪律。也就是说,对于可能是空指针的变量,先检查后使用。

  • 相关阅读:
    Error: [vuex] do not mutate vuex store state outside mutation handlers.
    亚马逊是如何铺设多个IP账号实现销量大卖的?
    页面分配策略(驻留集、页面分配、置换策略、抖动现象、工作集)
    Spark提交参数说明和常见优化
    Hadoop概述
    【Java EE初阶二十】http的简单理解(一)
    【深度思考】5年开发经验,不知道git rebase,是否应该被嘲笑?
    奥拉帕尼人血清白蛋白HSA纳米粒|陶扎色替卵清白蛋白OVA纳米粒|来他替尼小鼠血清白蛋白MSA纳米粒(试剂)
    方差迭代公式推导
    Java获取Object中Value的方法
  • 原文地址:https://blog.csdn.net/qq_43284469/article/details/126573767