如何实现null安全

http://tommwq.tech/blog/2020/11/12/203

如果对null进行解引用操作,就会引发NPE异常。NPE是让人感到非常讨厌的一个异常,一不小心就会掉到NPE的陷阱里面。

Listing 1: switch引发NPE

public int foo(String s) {
    switch (s) {
    case "abc":
        return 1;
    case "xyz":
        return 2;
    default:
        return 0;
    }
}

String s = "abc";
// ...
s = null;
// ...
foo(s); // NPE

也许有人会说,加上null检查就可以避免NPE问题。但是Java语言中的变量(除了少数基础类型外)全部都是引用,如果在使用每个变量前都进行null检查,无疑会增加非常大的工作量。而且后面我们也会看到,null检查也无法完全避免NPE。

实际上,null安全问题的根源在于引用没有testAndGet原子操作。这个问题不仅存在于Java中,任何允许null引用存在的语言,都有同样的问题。而null安全的解决方案在于维护语义清晰。

“引用”是同具体对象相关联的一个句柄。对句柄进行解引用,可以得到具体对象自身。据此我们可以为引用建立一个模型

public interface Reference<T> {
    T get(); // 解引用
}

一些语言允许尚未和具体对象建立关联的引用(即null)存在。对于这类语言,引用存在一个测试方法,判断引用是否和具体对象建立关联。

public interface Reference<T> {
    T get() throws NPE; // 解引用
    boolean test(); // 判断引用是否有效,即nonnull
}

对于这种情况,由于null引用的存在,在解引用前必须进行null检查

Reference ref;
if (ref.test()) {
    ref.get();
}

kotlin中所所谓的null安全(?.)就是这段代码的语法糖。这段代码在单线程环境下可以避免NPE。但是在多线程环境中,由于随时可能发生线程调度,如果出现如下指令序列,NPE仍然会发生

线程1执行 if (ref.test()) 
线程调度
线程2执行 对ref解绑定,ref变为null
线程调度
线程1执行 ref.get(),引发NPE

因此在多线程环境下,要保证null安全,必须将ref.test()和ref.get()放在临界区中:要么由语言(和运行时)提供testAndGet()原子操作,要么使用锁进行保护。Java没有提供testAndGet原子操作,而对所有变量使用锁进行保护,从工作量和性能角度看,是不现实的。难道null安全问题无法解决吗?如果我们跳出语法层面,从语义层面取考虑,就可以从很大程度上避免NPE的发生。

引用存在nonnull和null两种状态。一些人用null表示(符合查询条件的)对象不存在。null是软件层面的概念,表示引用未绑定。而(符合查询条件的)对象不存是业务层面的概念。混用这两个概念是导致NPE的主要问题。如果我们把这两个概念分开,保证所有引用都是nonnull,就不需要担心NPE问题。对于(符合查询条件的)对象不存在的情况,要根据业务规则区分。如果业务规则要求这种对象一定要存在,那么查询不到就是一种异常情况,需要通过异常流程处理。

// always return nonnull object
Foo getFooById(String id) throws FooNotFoundException;

如果方法返回,一定返回一个nonnull对象。对于找不到的情况,通过异常流程处理。如果业务规则允许找不到对象的情况,应该使用Optional表达业务意图。

Optional<Foo> findFooById(String id);

做到这一点,我们可以放心的使用方法返回的对象,不再需要检查null了。

而对于类成员的使用,通过不变性保证成员一直是nonnull的。

public class Bar {
    private String x;
    public Bar(String x) {
        if (x == null) {
            throw new IllegalArgumentException("invalid constructor parameter");
        }

        this.x = x;
    }

    public String getX() {
        return x;
    }

    public void setX(String x) {
        if (x == null) {
            throw new IllegalArgumentException("invalid constructor parameter");
        }

        this.x = x;
    }
}

做到了这一点还不够。由于Java支持反射,还必须保证通过反射设置域时不能传入null。这一点要通过代码评审和单元测试保证。可能觉得,这里也使用了异常,和NPE差不多。这种方案有两点优势:第一,保证任何时候域都是nonnull的,可以安全使用。第二,符合fast-fail原则,可以快速找到尝试赋值null的代码。

此外,还要保证每个引用都通过nonnull对象初始化,并且禁止出现类似 foo = null; 的语句。

做到了上面这几点,可以保证在单线程环境下不会发生NPE。在多线程环境下,对于线程之间共享的对象(包括类内部的对象),还必须使用锁进行保护。对于没有使用锁保护的类,可以加上(自定义的)@ThreadSafe/@NonThreadSafe注解,表明类不能在多线程环境下直接使用。

总的来说,解决null安全问题不能依赖于语法层面的“银弹”,而是要通过软件设计和代码评审来解决。下面是一些避免NPE的建议

  • 所有变量都使用nonnull对象初始化。
  • 禁止将引用赋值为null。
  • 所有方法都返回nonnull对象。
  • 不要用null表达业务概念。
  • 使用Optional表达所查询对象不存在的情况。
  • 如果按照业务规则,所查询对象必须存在,则在查询失败时抛出异常。
  • 利用不变性保证类中成员是nonnull的。
  • 尽量避免使用反射为域赋值。
  • 在使用反射时,禁止将域赋值为null。
  • 对线程间共享的对象,使用锁进行保护。
  • 对非线程安全类,定义并添加注解@NonThreadSafe,以避免类被错误使用。

猜你喜欢

转载自blog.csdn.net/tq1086/article/details/109648253