如何优雅地处理空指针

3,305 阅读9分钟

当我们在讨论NPE的时候,我们在讨论什么

NPE(NullPointerException,空指针异常)本身并不是什么复杂的问题,解决方式显而易见,一行代码即可,这里我们将字符串类型的判空也包含进来:

if (xxx == null) return null;

// 如果是字符串类型
if (StringUtils.isEmpty(xxx)) return null;

这行代码也叫判空逻辑,我相信所有程序员都无比熟悉。但是问题在于,NPE实在过于频繁,我们需要经常地进行判空,这样会导致代码中充斥着if-else逻辑,会严重影响代码的可读性,所以使用其他方式来优雅地处理NPE(而不是粗暴的判空)是很重要的事情

到底有多少种空指针?

实际上,如果细分的话,根据出现的场景,空指针可以分为以下几种类型

服务请求结果对象为空

这种是最为常见的类型,诸如下面这种场景,调用远程服务之后没有判空,紧接着就调用了对象的方法

    String orderName = orderService.getOrderById(id).getName();

很多开发为了图省事,不想写那么多的判空逻辑,就直接链式调用导致NPE,这是最经典的场景

包装类隐含的方法调用

这种情况如果没遇到过可能永远不会注意到,但是遇到一次就永远不会再犯。这种情况是由于Java的自动拆箱会进行隐式类型转换导致的,一个很经典的例子:

    // Boolean checkIfServiceOpen(long serviceId);
    if (checkIfServiceOpen(id)) {
        // ...
    } else {
        // ...
    }

如果服务返回null那么同样会抛出NPE,是因为这里隐含了一个Boolean类转换为boolean的逻辑,会调用 Boolean#booleanValue的方法,如果没有被坑过几乎不会有人注意到这个问题

Switch-Case的NPE

如果是经常使用switch-case语句的一定会很注意这个问题,如果switch的参数为空,那么一定会抛出空指针异常

    String status = taskCommonService.queryStatusById(id);
    switch(status) {
        case "end":
            return true;
        case "processing":
            return false;
        default:
            return null;
    }

远程服务的NPE

越是代码不规范的公司这种情况遇到的就越多,这种情况与自己的服务无关,但是由于在rpc接口参数中传入了空值导致的

    SearchModel searchModel = buildSearchModel(id);
    Result<Company> result = queryCompanyFromEngine(searchModel);

如果 queryCompanyFromEngine 方法没有对入参进行判空,那么一旦参数为空,也会导致NPE

远程服务未注入

这种情况并不能完全算做NPE,不过确实也会导致空指针异常。主要是由于采用了自动注入,一旦服务没有注入成功,那么在调用方法的时候就会抛出NPE

调用参数为空

这种情况大部分人都会注意到,一般是接收参数的时候没有进行判空就执行运算,该情况不再赘述

如何优雅解决空指针

解决空指针的方法很简单,所有程序员都知道,就是一行判空代码的事情。但是一味地在空指针风险代码前加入判断逻辑是很污染代码的间接性的(除非你们公司按照代码行数算钱),这里介绍几种我常用/用过的解决通过简洁的方式处理空指针的方式

Optional类

首先声明,我个人并不经常使用Optional类,我个人也并不提倡将Optional类视作一种终极解决方案(因为本来就不能完全解决NPE问题)。Optional类说简单了就是对 if(xxx != null) { return xxx; } else { return null; } 进行了封装,在某些场景下,Optional的封装逻辑确实可以减少样板代码的编写。

Optional的核心方法只有两个(更准确地说是一个,只有orElse方法),即 getorElse 方法。get方法负责从封装后的Optional类中提取对象,orElse负责当对象为空时返回预设的值。举一个简单的例子

public queryOffer(QueryParam param) {
    // ...(省略了其他查询参数的获取)
    String status = param.getStatus();
    if (StringUtils.isEmpty(status)) {
        status = OfferStatus.NORMAL;
    }
    // ...
}   

如果我们有默认值的需求,且参数中不支持设置默认值(或很麻烦,比如二方包中的类),那么我们就需要在代码里进行判空。一旦有大量的参数需要设置默认值,那么代码里就会充斥着if-else逻辑,这里就可以采用Optional来解决这样的问题

public queryOffer(QueryParam param) {
    // ...(省略了其他查询参数的获取)
    String status = Optional.ofNullable(param.getStatus()).orElse(OfferStatus.NORMAL);
    // ...
}  

我个人只推荐在这种简单的场合使用Optional类型,如果你任何情况下都把Optional当做NPE的优雅解决方式,那么又会陷入了过度封装的陷阱中

方法拆分

这种方式适合大多数service层的代码,主要将核心逻辑单独抽出一个方法,来避免方法中糅杂过多的判空逻辑,同时还可以避免代码中出现长方法(单函数代码行数过多)。例如下面这样的逻辑(为了代码简洁,省略了日志打印的部分):

public boolean checkAndStartVipService(Long userId, ServiceType type) {
    if (userId == null || type == null) {
        return false;
    }
    List<Order> orders = orderService.listOrdersByUserIdAndType(userId, type);
    if (orders == null || orders.isEmpty()) {
        return false;
    }
    Order order = orders.get(0);
    if (order == null) {
        return false;
    }
    // 省略数十行订单合法性检查和开启服务的逻辑代码
    // ...
    // ...
    // ...
    return true;
}

本身代码开始作为参数校验的三个判空逻辑还算可以接受,但是由于本身代码行数过多,就显得冗余了,尤其是核心代码如果又包含大量判空逻辑,那么就会显得更加臃肿,所以我们可以将代码拆分为前置参数校验+核心逻辑,这种拆分方式适用于绝大多数应用,如下:

public boolean checkAndStartVipService(Long userId, ServiceType type) {
    if (userId == null || type == null) {
        return false;
    }
    List<Order> orders = orderService.listOrdersByUserIdAndType(userId, type);
    if (orders == null || orders.isEmpty()) {
        return false;
    }
    Order order = orders.get(0);
    if (order == null) {
        return false;
    }
    return startVipService(userId, type, order);
}

private boolean startVipService(Long userId, SeviceType type, Order order) {
    // 包含订单合法性校验+服务开启逻辑
    // ...
}

这种方法拆分的方式更像是为了处理长方法和代码解耦,不过也可以看做一种优雅处理NPE的方式。一般来说,前置参数校验的地方会包含大量判空逻辑,且逻辑比较简单,所以把核心逻辑单独提取出来,将简单的逻辑堆叠在一起,就不会显得代码臃肿。

唯一判空

唯一判空,指的是对同一个参数的判空逻辑,只在一条链路中出现一次。这里举一个例子便于理解:

// OfferService.java
public OfferStatus queryOfferStatusById(Long id) {
    // ...
    String status = offer.getStatus();
    if (StringUtils.isEmpty()) {
        return null;
    }
    return OfferStatus.getByName(status);
}

// OfferStatus.java
public static OfferStatus getByName(String status) {
    if (StringUtils.isEmpty(status)) {
        return null;
    }
    // ...
}

我们可以看到,status实际上经过了两次判空,因为秉持着不相信任何上游数据的理念,很多开发者都会在接口第一行进行参数的判空,这种方法当然没有问题,但是如果我们能直接看到所调用方法的方法体的时候,或者两个方法在一个项目下,那我们就完全没有必要做一些多于的判空操作,比如上述代码可以改为:

// OfferService.java
public OfferStatus queryOfferStatusById(Long id) {
    // ...
    return OfferStatus.getByName(offer.getStatus());
}

// OfferStatus.java
public static OfferStatus getByName(String status) {
    if (StringUtils.isEmpty(status)) {
        return null;
    }
    // ...
}

注意这里并没有删掉底层方法的判空,而是删掉了上层方法的判空,这是因为底层的功能方法可能会被其他人调用,一定要尽可能健壮。但是如果两个方法并没有上下游关系,只是为了解耦或避免长方法所拆分的,或者调用限制在一个方法中,不会被外界调用,那么去掉哪一个方法的判空逻辑都无所谓。

合并判空

这种方法主要用于处理字符串的判空,如下:

public boolean updateStatus(String id, String status, String operator) {
    if (StringUtils.existEmpty(id, status, operator)) {
        return false;
    }
    // ...
}

// StringUtils.java(自己应用中的工具类,非apchae工具类)
    public static boolean existEmpty(String... strings) {
        if (strings == null) {
            return true;
        }
        if (strings.length == 0) {
            // 如果没有数据,认为不为空(因为用户没有提交用于判断的数据,所以不进行拦截)
            return false;
        }
        for (String string: strings) {
            if (isEmpty(string)) {
                return true;
            }
        }
        return false;
    }

与这种方式相似,还可以将判空逻辑写在模型中暴露为一个单独的方法(不建议这么做,除非是非常通用而且判空逻辑也统一的模型)

异常捕获

这种方式也经常会用到,如果方法中调用了rpc接口,且空值是作为异常值返回的(即不会经常返回null),那么如果不需要特殊处理时就可以省去额外的判空

public String upload(String fileName, byte[] file) {
    // ...
    try {
        Result result = storageService.upload(fileName, file);
        return result.getData().getFilePath();
    } catch(Exception e) {
        // ...
        return null;
    }
}

除了在自己方法中进行catch处理,还可以将异常交给上层或者顶层入口方法进行统一捕获。如果确认该链路(非跨应用)上的方法都只会存在唯一的上下游,那么下游方法中可以将一些判空逻辑当做异常抛给上游

处理NPE的误区

所有可能存在的NPE都需要进行判空

虽然绝大多数可能出现的NPE都需要处理,返回特定值或进行catch,但是有时候NPE也是一种正常的逻辑流程,这一点我在“异常捕获”的方式中已经说到了,一些NPE可以当做一种逻辑分支,只要上游可以处理这种情况,就没必要进行判空处理

判空逻辑需要封装

我个人是不习惯对一些简单方法进行封装,很多情况下属于过度设计,完全可以采用其他方式来解决问题

为了提高方法执行速度,判空校验能省就省

这是一个很典型的误区,实际上复杂的native运算逻辑,与http/rpc调用耗时相比也是不值一提的,更不要说简单的if-else逻辑,我们更多的是要注意的是代码可读性。

总结

总结一下就是,​所有NPE问题,​先看判空的值是否在其他地方判空过,如果没有判空过就看能不能统一catch住,不能catch住就看能不能拆分方法让代码看起来整洁,如果不好拆分就封装逻辑,如果不好封装再使用if-else进行判空

这片文章是年前趁着无聊即兴写的,可能会有很多不严谨的地方,如果有纰漏或者不认可的地方欢迎交流指正。