一文詳解Java如何系統(tǒng)地避免空指針問題
新手Java開發(fā)總是經(jīng)??罩羔槞z查,甚至某些老手也會(huì)犯這樣的問題。我們來看看如何系統(tǒng)地避免空指針問題。
首先需指出的是,Java語言層面就支持引用為空,再怎么優(yōu)秀的方法也無法繞過語言底層的設(shè)定。
好消息是:空指針問題并沒特別難解決。根據(jù)谷歌的內(nèi)部調(diào)查,代碼中使用空指針的概率約為95%,筆者在工作中的實(shí)際體驗(yàn)也和這個(gè)數(shù)據(jù)相差不大,甚至更低。只有少量的代碼需要注意空指針問題,比如我們很少見集合對(duì)象中存在null,很多方法的調(diào)用也是空指針安全的。
防止空指針的基本思路
- 靜態(tài)代碼分析工具
- 快速失?。╢ail-fast), 代碼空指針檢查
- 兼容 null
- 減少使用 null
使用靜態(tài)代碼分析工具
Java中靜態(tài)代碼分析工具有很多,F(xiàn)indBugs、SpotBugs、ErrorProne 等等,筆者使用的IDEA自帶的代碼分析器。
打開方法:Settings -> Inspections -> 搜索nullability,勾選相關(guān)選項(xiàng)。 對(duì)于新項(xiàng)目,建議盡量都勾選上,同時(shí)提示類型為warning及以上。以后遇到相關(guān)空指針報(bào)警時(shí),可以快速定位問題。
以下列舉一些優(yōu)秀的代碼規(guī)范:
1.如果方法返回對(duì)象可能為空,使用Optional封裝。
對(duì)應(yīng)了IDEA代碼中Probable Bugs => Nullability problems => Return of 'null' 稍差一點(diǎn)的方法是使用注解(NonNull)標(biāo)注。這種方式的優(yōu)點(diǎn)是強(qiáng)制方法調(diào)用者檢查。如果調(diào)用者確認(rèn)對(duì)象存在,可以強(qiáng)制獲取對(duì)象opt.orElseThrow(),如果不確定返回結(jié)果是否存在,可以使用Optonal提供的方法進(jìn)行鏈?zhǔn)教幚?,不能進(jìn)行鏈?zhǔn)教幚淼模梢哉{(diào)用orElse(null),進(jìn)行空校驗(yàn)。
值得一提的是,如var userOpt = repo.getUser(userId);獲取user后,再調(diào)用orElse(null),看似多此一舉,實(shí)則已經(jīng)提示開發(fā)需要進(jìn)行判空。雖然其與方法直接返回null,再進(jìn)行空判斷的邏輯一致。
Optional難以進(jìn)行鏈?zhǔn)教幚淼那闆r大多為兩種:一是多種變量進(jìn)行運(yùn)算,二是需要提前返回結(jié)果(衛(wèi)模式)。
2.實(shí)際代碼開發(fā)中,對(duì)于常用獲取操作,還可以封裝成新方法。
getCheckedUser(userId) -> @NonNull User
當(dāng)user不存在時(shí),會(huì)拋出業(yè)務(wù)異常BusinessException("用戶不存在") 存在則返回非空對(duì)象
3.不要在方法參數(shù)中允許傳空
可以為空的參數(shù)可以通過枚舉、拆分出多個(gè)方法、使用方法參數(shù)對(duì)象實(shí)現(xiàn)。
方法參數(shù)中傳空指針會(huì)讓代碼難以理解。 比如repo.getUser(null);
這里傳空可以返回空,或者返回所有用戶。如果方法沒有在注釋或文檔中標(biāo)明,調(diào)用者需要閱讀方法的具體實(shí)現(xiàn),違背了封裝的原則。
這里最簡單的改造方法是新增方法getAllUsers(), 同時(shí)原方法禁止傳空。
4.使用Nullability相關(guān)注解
目前空指針相關(guān)注解并沒有統(tǒng)一的標(biāo)準(zhǔn),各家都有各自的實(shí)現(xiàn),不過很多實(shí)際上大同小異,幾乎所有的注解IDEA都可以分析(一般就@Nullable和@NonNull兩種)。
使用方法非常簡單,對(duì)于方法返回值、參數(shù)、字段,如果可為空,就標(biāo)注@Nullable。
我推薦使用JSpecify,當(dāng)前該項(xiàng)目試圖提供統(tǒng)一的空指針注解方案,雖然目前還沒有定論,不過其提供的注解足夠滿足當(dāng)前的代碼需要。
其支持一些邊界條件,比如其注解支持泛型參數(shù)標(biāo)注,提供默認(rèn)非空范圍標(biāo)注@NullMarked(Guava中使用@ElementTypesAreNonnullByDefault
)。
對(duì)于復(fù)雜的情況可以參考其規(guī)范:jspecify.dev/docs/spec
這里不想討論過多的邊界情況,僅舉一例:
對(duì)象的某個(gè)屬性在初始化前為空,初始化后不為空,如何標(biāo)注?這個(gè)屬性可以標(biāo)注為@Nullable,不過更好的解決方法是標(biāo)注非空+注釋,同時(shí)在報(bào)警的位置@SuppressWarning。
public class Demo { @SuppressWarnings("NotNullFieldNotInitialized") @NonNull @Getter private String serviceName; public void init() { serviceName = "demoService"; } }
快速失?。╢ail-fast)
快速失敗在這里指的是:如果對(duì)象需要不為空,就提前在代碼中明確;而不是在用到的時(shí)候才發(fā)現(xiàn)原來不能為空的對(duì)象是空指針。
大多數(shù)NPE都是上面這種場景,發(fā)生NPE后,排查代碼時(shí),從堆棧一步一步地往后找,最終可以找到問題的源頭可能就是原本被認(rèn)為不能為空的對(duì)象實(shí)際是空指針。
對(duì)外暴露的代碼如果不進(jìn)行快速失敗處理,調(diào)用者就會(huì)覺得可能是原方法的問題,比如:
遠(yuǎn)程調(diào)用userService#getUser(userId),傳入?yún)?shù)為null, 這時(shí)被調(diào)用方報(bào)空指針異常,這個(gè)問題的源頭應(yīng)該由被調(diào)用方負(fù)責(zé);正確的返回結(jié)果應(yīng)該是報(bào)錯(cuò)'傳入userId不能為空'或者返回null。
Guava中提供了Preconditions類,包含以下3個(gè)非常有用的方法: checkNotNull, checkState, checkArgument。
標(biāo)準(zhǔn)庫也緊隨其后,在Objects類中,常用的方法為:
requireNonNull(IDEA中快捷鍵var.req), requireNonNullElse, requireNonNullElseGet.
后兩個(gè)方法可以用來賦初值或默認(rèn)值。
Validator規(guī)范常常用于對(duì)象字段校驗(yàn),@NotNull 可以用于空指針校驗(yàn),這里不贅述。
從Spring中隨便拉取一個(gè)構(gòu)造器方法,其便使用了快速失敗方法:
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); }
再舉一例,代碼中常常需要手動(dòng)實(shí)現(xiàn)類似數(shù)據(jù)庫的join操作,此時(shí)需要?jiǎng)?chuàng)建索引map, map.get方法返回值可能為空,我們確定不為空,就可以代碼確認(rèn)。這樣在測試時(shí),出現(xiàn)問題時(shí)可以快速定位。
修改后代碼如下
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表示沒有或者空對(duì)象,我們可以在代碼中兼容null, 從而避免程序的崩潰,提升健壯性。 常見的null安全方法如:
CollectionUtils.isEmpty, StringUtils.isBlank, ListUtils.emptyIfNull, Strings.nullToEmpty... 對(duì)于不受信任的代碼,安全起見,可以使用兼容方法(防御性編程)。
代碼中最常見的POJO對(duì)象,方法如果返回集合對(duì)象,可以返回空集合,這樣調(diào)用者可以直接在返回對(duì)象上調(diào)用方法,形成鏈?zhǔn)秸{(diào)用提高可讀性。
類似地可以推廣到空對(duì)象模式,字符串的空對(duì)象為'',基本類型的空對(duì)象可以用Optional封裝,這樣自由組合成任意的空對(duì)象??諏?duì)象可以不為單例,上例中的用戶角色對(duì)象,空對(duì)象可以為(userId: 1, userName: '樺說', roles: []),表示用戶沒有任何角色。
此處的對(duì)象不僅僅指的是具體的對(duì)象,還可以是比較抽象的責(zé)任鏈、服務(wù)注冊(cè)模式中的兜底對(duì)象,單元測試中的空實(shí)現(xiàn)對(duì)象(Mock對(duì)象)等。
減少使用 null
很多情況下null可以不存在。
如果沒有IDE的支持,我們甚至不知道null的名字,是的,大多數(shù)時(shí)候我們不會(huì)給不變的null變量(如val user)起名字。
比如當(dāng)新建一個(gè)數(shù)據(jù)庫對(duì)象,然后插入數(shù)據(jù)庫,方法調(diào)用如下:
var newUser = new UserPO(null, userName, null, null, 'admin'); userRepo.insert(newUser);
這是第一個(gè)null表示id, 第二個(gè)表示用戶類型(可以為空,表示普通用戶),第三個(gè)表示創(chuàng)建時(shí)間,空表示使用數(shù)據(jù)插入時(shí)間。
這個(gè)例子恰好說明了null降低了代碼的可讀性,甚至可維護(hù)性。
可以使用builder模式解決這個(gè)問題,
var newUser = UserPO.builder() .userName(userName) .operator('admin') .build(); userRepo.insert(newUser);
同理,方法的調(diào)用除了拆解方法功能,還可以提取方法參數(shù)(ParameterObject)對(duì)象實(shí)現(xiàn)排除null的使用。
黑魔法:使用lombok.NonNull
lombok中@NonNull注解提供了以上所述的靜態(tài)代碼檢查、運(yùn)行時(shí)快速失敗功能,可以標(biāo)注在方法參數(shù)、對(duì)象字段上。如果對(duì)于lombok的具體實(shí)現(xiàn)不清楚,可以直接查看 build 的代碼或者使用IDEA的delombok功能。
標(biāo)注在對(duì)象字段上時(shí),生成的代碼可以作用在setter方法、構(gòu)造器上:
@Data class NonNullDemo{ @lombok.NonNull private String name; }
生成的代碼:
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; } } }
總之,通過以上方法可以極大地降低空指針發(fā)生的概率。
到此這篇關(guān)于一文詳解Java如何系統(tǒng)地避免空指針問題的文章就介紹到這了,更多相關(guān)Java空指針內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
IntelliJ IDEA安裝scala插件并創(chuàng)建scala工程的步驟詳細(xì)教程
這篇文章主要介紹了IntelliJ IDEA安裝scala插件并創(chuàng)建scala工程的步驟,本文通過圖文并茂的形式給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-07-07java使用randomaccessfile在文件任意位置寫入數(shù)據(jù)
Java在文件任意位置寫入數(shù)據(jù)可以使用RandomAccessFile方法來完成,下面看一個(gè)簡單的示例就明白了2014-01-01JPA原生SQL實(shí)現(xiàn)增刪改查的示例詳解
JPA除了對(duì)JPQL提供支持外,還對(duì)原生SQL語句也提供了支持。本文將利用生SQL實(shí)現(xiàn)增刪改查功能,文中的示例代碼講解詳細(xì),需要的可以參考一下2022-09-09Java中Builder模式的實(shí)現(xiàn)詳解
在設(shè)計(jì)模式中對(duì)Builder模式的定義是用于構(gòu)建復(fù)雜對(duì)象的一種模式,所構(gòu)建的對(duì)象往往需要多步初始化或賦值才能完成。下面這篇文章主要給大家介紹了在Java各個(gè)版本中Builder模式實(shí)現(xiàn)的相關(guān)資料,文中介紹的非常詳細(xì),需要的朋友可以參考學(xué)習(xí)。2017-05-05