转载请注明出处,作者为 桦说编程
新手Java开发总是经常空指针检查,甚至某些老手也会犯这样的问题。我们来看看如何系统地避免空指针问题。
首先需指出的是,Java语言层面就支持引用为空,再怎么优秀的方法也无法绕过语言底层的设定。
好消息是:空指针问题并没特别难解决。根据谷歌的内部调查,代码中使用空指针的概率约为95%,笔者在工作中的实际体验也和这个数据相差不大,甚至更低。只有少量的代码需要注意空指针问题,比如我们很少见集合对象中存在null,很多方法的调用也是空指针安全的。
防止空指针的基本思路:
- 静态代码分析工具
- 快速失败(fail-fast), 代码空指针检查
- 兼容 null
- 减少使用 null
使用静态代码分析工具
Java中静态代码分析工具有很多,FindBugs、SpotBugs、ErrorProne 等等,笔者使用的IDEA自带的代码分析器。
打开方法:Settings -> Inspections -> 搜索nullability,勾选相关选项。 对于新项目,建议尽量都勾选上,同时提示类型为warning及以上。以后遇到相关空指针报警时,可以快速定位问题。
以下列举一些优秀的代码规范:
- 如果方法返回对象可能为空,使用Optional封装。
对应了IDEA代码中Probable Bugs => Nullability problems => Return of 'null' 稍差一点的方法是使用注解(NonNull)标注。这种方式的优点是强制方法调用者检查。如果调用者确认对象存在,可以强制获取对象opt.orElseThrow(),如果不确定返回结果是否存在,可以使用Optonal提供的方法进行链式处理,不能进行链式处理的,可以调用orElse(null),进行空校验。
值得一提的是,如var userOpt = repo.getUser(userId);获取user后,再调用orElse(null),看似多此一举,实则已经提示开发需要进行判空。虽然其与方法直接返回null,再进行空判断的逻辑一致。
Optional难以进行链式处理的情况大多为两种:一是多种变量进行运算,二是需要提前返回结果(卫模式)。
- 实际代码开发中,对于常用获取操作,还可以封装成新方法。
getCheckedUser(userId) -> @NonNull User
当user不存在时,会抛出业务异常BusinessException("用户不存在") 存在则返回非空对象 - 不要在方法参数中允许传空
可以为空的参数可以通过枚举、拆分出多个方法、使用方法参数对象实现。
方法参数中传空指针会让代码难以理解。 比如repo.getUser(null);
这里传空可以返回空,或者返回所有用户。如果方法没有在注释或文档中标明,调用者需要阅读方法的具体实现,违背了封装的原则。
这里最简单的改造方法是新增方法getAllUsers(), 同时原方法禁止传空。 - 使用Nullability相关注解
目前空指针相关注解并没有统一的标准,各家都有各自的实现,不过很多实际上大同小异,几乎所有的注解IDEA都可以分析(一般就@Nullable和@NonNull两种)。
使用方法非常简单,对于方法返回值、参数、字段,如果可为空,就标注@Nullable。
我推荐使用JSpecify,当前该项目试图提供统一的空指针注解方案,虽然目前还没有定论,不过其提供的注解足够满足当前的代码需要。
其支持一些边界条件,比如其注解支持泛型参数标注,提供默认非空范围标注@NullMarked(Guava中使用
@ElementTypesAreNonnullByDefault
)。对于复杂的情况可以参考其规范:jspecify.dev/docs/spec
这里不想讨论过多的边界情况,仅举一例:
对象的某个属性在初始化前为空,初始化后不为空,如何标注?
这个属性可以标注为@Nullable,不过更好的解决方法是标注非空+注释,同时在报警的位置@SuppressWarning。
java
public class Demo {
@SuppressWarnings("NotNullFieldNotInitialized")
@NonNull
@Getter
private String serviceName;
public void init() {
serviceName = "demoService";
}
}
快速失败(fail-fast)
快速失败在这里指的是:如果对象需要不为空,就提前在代码中明确;而不是在用到的时候才发现原来不能为空的对象是空指针。
大多数NPE都是上面这种场景,发生NPE后,排查代码时,从堆栈一步一步地往后找,最终可以找到问题的源头可能就是原本被认为不能为空的对象实际是空指针。
对外暴露的代码如果不进行快速失败处理,调用者就会觉得可能是原方法的问题,比如:
远程调用userService#getUser(userId),传入参数为null, 这时被调用方报空指针异常,这个问题的源头应该由被调用方负责;正确的返回结果应该是报错'传入userId不能为空'或者返回null。
Guava中提供了Preconditions类,包含以下3个非常有用的方法: checkNotNull, checkState, checkArgument。
标准库也紧随其后,在Objects类中,常用的方法为:
requireNonNull(IDEA中快捷键var.req), requireNonNullElse, requireNonNullElseGet.
后两个方法可以用来赋初值或默认值。
Validator规范常常用于对象字段校验,@NotNull 可以用于空指针校验,这里不赘述。
从Spring中随便拉取一个构造器方法,其便使用了快速失败方法:
java
public PropertyValue(PropertyValue original, @Nullable Object newValue) {
Assert.notNull(original, "Original must not be null");
this.name = original.getName();
this.value = newValue;
this.optional = original.isOptional();
this.conversionNecessary = original.conversionNecessary;
this.resolvedTokens = original.resolvedTokens;
setSource(original);
copyAttributesFrom(original);
}
再举一例,代码中常常需要手动实现类似数据库的join操作,此时需要创建索引map, map.get方法返回值可能为空,我们确定不为空,就可以代码确认。这样在测试时,出现问题时可以快速定位。
修改后代码如下
Java
class JoinDemo {
public static List<UserRoleDTO> toDTOs(List<User> users, List<Role> roles, List<UserRoleRelation> relations) {
var roleIdsByUserId = relations.stream().collect(
toImmutableSetMultimap(r -> r.userId(), r -> r.roleId()));
var roleMap = Maps.uniqueIndex(roles, Role::id);
return users.stream()
.map(u -> {
var roleNames = roleIdsByUserId.get(u.id()).stream()
.map(roleMap::get)
.map(r -> Objects.requireNonNull(r).name())
.toList();
return UserRoleDTO.builder()
.userId(u.id())
.userName(u.name())
.roles(roleNames)
.build();
})
.toList();
}
}
@Builder
record UserRoleDTO(Long userId, String userName, List<String> roles) {}
record User(Long id, String name) {}
record UserRoleRelation(Long userId, Long roleId) {}
record Role(Long id, String name) {}
兼容null
一般来说null表示没有或者空对象,我们可以在代码中兼容null, 从而避免程序的崩溃,提升健壮性。 常见的null安全方法如:
CollectionUtils.isEmpty, StringUtils.isBlank, ListUtils.emptyIfNull, Strings.nullToEmpty... 对于不受信任的代码,安全起见,可以使用兼容方法(防御性编程)。
代码中最常见的POJO对象,方法如果返回集合对象,可以返回空集合,这样调用者可以直接在返回对象上调用方法,形成链式调用提高可读性。
类似地可以推广到空对象模式,字符串的空对象为'',基本类型的空对象可以用Optional封装,这样自由组合成任意的空对象。空对象可以不为单例,上例中的用户角色对象,空对象可以为(userId: 1, userName: '桦说', roles: []),表示用户没有任何角色。
此处的对象不仅仅指的是具体的对象,还可以是比较抽象的责任链、服务注册模式中的兜底对象,单元测试中的空实现对象(Mock对象)等。
减少使用 null
很多情况下null可以不存在。
如果没有IDE的支持,我们甚至不知道null的名字,是的,大多数时候我们不会给不变的null变量(如val user)起名字。
比如当新建一个数据库对象,然后插入数据库,方法调用如下: var newUser = new UserPO(null, userName, null, null, 'admin');
userRepo.insert(newUser);
这是第一个null表示id, 第二个表示用户类型(可以为空,表示普通用户),第三个表示创建时间,空表示使用数据插入时间。
这个例子恰好说明了null降低了代码的可读性,甚至可维护性。
可以使用builder模式解决这个问题,
java
var newUser = UserPO.builder()
.userName(userName)
.operator('admin')
.build();
userRepo.insert(newUser);
同理,方法的调用除了拆解方法功能,还可以提取方法参数(ParameterObject)对象实现排除null的使用。
黑魔法:使用lombok.NonNull
lombok中@NonNull注解提供了以上所述的静态代码检查、运行时快速失败功能,可以标注在方法参数、对象字段上。如果对于lombok的具体实现不清楚,可以直接查看 build 的代码或者使用IDEA的delombok功能。
标注在对象字段上时,生成的代码可以作用在setter方法、构造器上:
java
@Data
class NonNullDemo{
@lombok.NonNull
private String name;
}
生成的代码:
typescript
class NonNullDemo {
private @NonNull String name;
public NonNullDemo(final @NonNull String name) {
if (name == null) {
throw new NullPointerException("name is marked non-null but is null");
} else {
this.name = name;
}
}
public @NonNull String getName() {
return this.name;
}
public void setName(final @NonNull String name) {
if (name == null) {
throw new NullPointerException("name is marked non-null but is null");
} else {
this.name = name;
}
}
}
总之,通过以上方法可以极大地降低空指针发生的概率。