【灰藍 Java 訓練】如何處理空值

jywhltj發表於2021-02-09

本文原發於我的個人部落格:https://hltj.me/java/2021/01/09/java-exercise-nulls.html。本副本只用於圖靈社群,禁止第三方轉載。

這個系列以練習為主,可能不會有多少講述(當然本篇例外),可以作為初學者的自學驗收之用。

Java 中有非受限的空值,並且不知哪時會引發 NPE(即 NullPointerException),解決這個問題對於 Android 開發來說很簡單——用 Kotlin 就好了。 其實不僅限於 Android,對於服務端開發來說終極方案也應該是遷移到 Kotlin。 因為只要用 Java,空值問題就沒辦法徹底解決(之前在《現代程式語言系列2:安全表達可選值》中也提到過這點),而 JVM 平臺主流工業級語言中只有 Kotlin 很好地解決了這一問題。

但是對於服務端開發來說,常有各種非技術原因不能在專案中以 Kotlin 取代 Java,對於這些專案來說顯然沒辦法徹底解決空值問題。 那麼有沒有一些方法與工具可以讓空值問題處理起來儘可能規範、簡易些呢?這裡有幾點經驗分享。

NPE 防禦

一些典型場景的 NPE 可以通過編碼習慣來防禦——某些靜態分析工具或許也能檢測到一些問題,但很難完美覆蓋; 沒辦法,只能通過編碼規範、程式設計師的自律來解決了。 其中比較常見的兩個場景是空值比較以及使用不可變集合。

空值比較

對實際值為 null 的變數呼叫包括 equals() 在內的任何方法都會導致 NPE。 因此比較可空值(通常為變數)與非空值(通常為常量,不盡然)時,以可空值為引數對非空值調 equals() 即可避免這個問題。 例如:

if ("Hello".equals(nullableStr)) {
    ……
}

如果比較兩個可空值怎麼辦呢?用 Objects.equals(),例如:

if (Objects.equals(nullableObj1, nullableObj2)) {
    ……
}

不可變集合不支援空值

Java 9 引入的 List.of()Set.of()Map.of()Map.entry() 以及 Java 10 引入的 List.copyOf()Set.copyOf()Map.copyOf()Collectors.toUnmodifiableList()Collectors.toUnmodifiableSet()Collectors.toUnmodifiableMap() 等均不支援 null,其中構造不可變的 MapMap.Entry 時 key、value 均不能為 null。 還需要注意的一點是不能以 null 值呼叫不可變集合的 contains() / containsKey() / containsValue() / containsAll() 方法,其中 containsAll() 還要求引數集合中不能有 null

例如:

List.of("a", null); // NPE:元素不可以有空值
Map.of("a", 1, null, 2); // NPE:key 不可有空值
Map.of("a", 1, "b", null); // NPE:value 不可有空值
Map.entry("hello", null); // NPE:value 不可有空值

var map = new HashMap<String, Integer>();
map.put("a", 1);
Map.copyOf(map); // OK
map.put(null, 2);
Map.copyOf(map); // NPE:key 不可有空值

// NPE:元素不可以有空值
Stream.of((String)null).collect(Collectors.toUnmodifiableList());

// NPE:不能用 null 呼叫不可變集合的 containsKey() 方法
Map.of("a", "b").containsKey(null);

千萬不要因為這點而放棄不可變集合。 不可變集合本身有很多優勢,Java 10 及以後版本也推薦使用不可變集合。 只是需要特別注意上述幾點:構造字面值時不能有 null、呼叫 contains() / containsKey() 等之前需要判斷引數是否為 null、呼叫 collect() / copyOf() / containsAll() 之前去除引數集合中的 null 值。 例如:

var isInSet = nullableStr != null && Set.of("hello", "world").contains(nullableStr);

Stream.of("hello", (String)null, "world").filter(Objects::nonNull).collect(Collectors.toList());

var map = new HashMap<String, Integer>();
map.put("a", 1);
map.put(null, 2);
map.put("b", null);

map.entrySet().stream()
        .filter(entry -> entry.getKey() != null && entry.getValue() != null)
        .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue));

【注】:這些 Java 9-11 新引入的集合方法,可通過《Java實戰(第2版)》第8章學習。

Optional

Java 中解決可選值問題,首先應該想到的就是 OptionalOptional 是 Java 8 引入的可選值型別,用於在很多場景中取代 null 來表達可選值,進而避免 null 所帶來問題。

Optional 的用法

Optional 的核心方法有 map()flatMap()orElse()orElseGet()

map():由一個可選值得到另一個可選值

例如對於一個可選的字串,計算其長度:

var lengthOpt = Optional.ofNullable(nullableString).map(String::length);

flatMap():處理可選值巢狀情況

例如,從可空整數列表中取第一個正值,從列表中取第一個元素可以用 Stream<Integer>#findFirst(),該方法返回 Optional<Integer>,如果繼續用 map() 的話,會得到可選值的可選值:

Optional<Optional<Integer>> intOptOpt = Optional.ofNullable(nullableIntList).map(intList ->
        intList.stream().filter(i -> i > 0).findFirst()
);

而用 flatMap() 會將兩層 Optional 打平為一層:

Optional<Integer> intOpt = Optional.ofNullable(nullableIntList).flatMap(intList ->
        intList.stream().filter(i -> i > 0).findFirst()
);

orElse()orElseGet():由可選值得到值,如果無值取預設值

例如,對於可選字串,有值取長度,無值取 0,可以用 orElse() 得到一個整數:

int length = strOpt.map(String::length).orElse(0);

如果預設值需要惰性求值,那麼還可以用 orElseGet()

int lengthOrRandom = strOpt.map(String::length).orElseGet(() ->
        new Random().nextInt()
);

還有一個特別的場景,就是將 Optional<T> 轉換為可空的 T 值時千萬不能想當然地調 get()——它在無值時拋 NoSuchElementException 而不是返回 null(參見其文件)。 正確的方式是 orElse(null)。例如:

Integer lengthOrNull = strOpt.map(String::length).orElse(null);

Optional 的其他方法在特定場景也很實用,由於方法不多,大家可以直接讀其文件,亦可通過《Java 8函數語言程式設計》第4章第10節、《Java實戰(第2版)》第11章學習,在此不再贅述。

Optional 的問題

很多文章稱 Optional 是 Java 8 針對 NPE 問題甚至十億美元問題的解決方案,實際上遠非如此。且不說在 Java 中沒辦法強制使用 Optional 而不用 null,即便 Optional 自身用起來也有很多侷限性。

Optional 物件自身也可能為 null

一個特別坑的問題是 Optional 是引用型別,因此一個 Optional 物件自身也可能為 null,例如:

Optional<String> strOpt = null;
strOpt.map(String::length); // NPE

當然 Optional 的官方文件稱 Optional 型別值不應該為 null,在 IDEA 中寫上述程式碼也會標出警告,但 Java 語法與編譯器並不會為此提供任何保障或約束。

Optional 不可序列化

Optional 不可序列化,這就意味著需要序列化的場景還得用 null 來表達可選值,這也是上文特別提到由 Optional 轉換可空值的主要原因。 鑑於此,Optional 不適合作為類的欄位,也不適合作為方法的入參,只適用於區域性變數、返回值以及表示式中間結果等場景。 IDEA 也會對 Optional 用作欄位或者引數時標記警告。

不夠簡潔

與受限空值語法相比,Optional 用法要冗長的多,當然這很符合 Java 的歷史風格。 對於受限空值的 ?. 語法,Optional 要用 map()flatMap();對於 ?:/?? 語法要用 orElse()orElseGet()。 其實對於採用可選值型別的其他語言來說可能都有類似問題,但是 HaskellScala 有推導式,HaskellScalaOCamlF# 等語言還支援自定義操作符,從而簡化可選值的用法。 不幸的是 Java 不具備這些語法。

可空性註解

在 Java 中沒辦法強制區分可空與非空型別,而有時又希望能在欄位、引數或者返回值上標註是否可空,以便 IDE 或者其他靜態檢查工具能夠識別並給出提醒。

我們看個示例,實現一個簡陋版的 ?:/??

public static <T> T defaultWith(T obj, T defaultVal) {
    return Optional.of(obj).orElse(defaultVal);
}

obj 非空時該方法返回 obj,否則返回 defaultVal。 但是實際上事與願違,這個方法在 obj 為空時不會返回 defaultVal,而是拋 NPE。 原因是 Optional.of() 只接受非空引數,如果引數為空就會拋 NPE。 遺憾的是編寫與編譯這段程式碼都不會收到任何警告或提醒,因為無論 IDE 還是編譯器都無從知曉這個方法的入參 obj 可能為空。

此時,如果我們給 obj 加一個 edu.umd.cs.findbugs.annotations.Nullable 註解,IDEA 就會對方法中使用 obj 呼叫 Optional.of() 標出警告提醒:“Argument 'obj' might be null”。 改為呼叫 Optional.ofNullable() 警告就消失了。 而如果希望 defaultVal 要求非空的話,還可以對 defaultVal 標註 edu.umd.cs.findbugs.annotations.NonNull,這樣一來方法的返回值也會非空,同樣可以標註 NonNull

@NonNull
public static <T> T defaultWith(@Nullable T obj, @NonNull T defaultVal) {
    return Optional.ofNullable(obj).orElse(defaultVal);
}

當然,這只是關於可空性註解使用場景的一個示例,實際上並不需要我們自己造一個這樣的輪子,因為有現成的輪子可用(見下文)。 上述 NullableNonNull 兩個註解來自於 spotbugs-annotations。類似的還有 Checker Framework 的 Nullness CheckerJetBrains 的 java-annotationsLombok 的 NonNull 等。

Apache Commons

Apache Commons 有多個庫提供了簡化空值使用的工具。例如實現類似 ?:/?? 的功能,使用 Optional 需要這樣寫:

var nonNullVal = Optional.ofNullable(obj).orElse(nonNullDefault);

而使用 Apache Commons 只需呼叫一個類似上文實現的靜態方法就可以了。

StringUtils

StringUtilsCommons Lang 中的一個工具類,其中包含一系列與字串空值相關的方法。

isEmpty()isNotEmpty()

前者判斷一個字元序列是否為 null 或空序列,後者與之相反——判斷一個字元序列既非 null 也非空序列。 這兩個方法非常實用,現實業務中對字串為 null 或空串時走同樣處理分支的場景很常見。

defaultString()

defaultString(str, defaultStr) 如果 strnull 返回 str,否則返回 defaultStr。 還有一個單參過載版本:defaultString(str) 如果非 null 返回 str,否則返回空串。

defaultIfEmpty()

defaultIfEmpty(str, defaultStr) 如果 str 既非 null 也非空串返回 str,否則返回 defaultStr

getIfEmpty()

相當於預設值採用惰性求值的 defaultIfEmpty()getIfEmpty(str, () -> ……)str 既非 null 也非空串時返回 str,否則對 lambda 表示式求值並以其返回值作為 getIfEmpty() 的返回值。

firstNonEmpty()

對於一系列值,返回第一個既非 null 也非空串的。

isAllEmpty()isAnyEmpty()isNoneEmpty()

對於一系列值,判斷是否全都是其中有全都不是 null 或空串。

除了這些明顯與空值相關的方法外,StringUtils 的其他方法也都會對 null 特殊處理而不是引發 NPE(個別的會拋其他異常)。

Commons Collections

StringUtils 類似,Commons Collections 中的 CollectionUtilsIterableUtilsListUtilsSetUtilsMapUtils 等也有提供 null 與空集合合併處理的方法,只是沒有那麼豐富。

emptyIfNull()

CollectionUtilsIterableUtilsListUtilsSetUtilsMapUtils 等均有提供該方法,如果引數非 null 返回引數本身,否則返回對應型別的空集合。

isEmpty()isNotEmpty()

CollectionUtils(可以用於 Collection 及其子型別如 ListSet) 與 MapUtils 提供了這兩個方法。前者判斷是否為 null 或為空集合,後者相反——判斷既非 null 也非空集合。

CollectionUtilsIterableUtilsListUtilsSetUtilsMapUtils 等提供的大多數其他方法也都會對 null 特殊處理而不是引發 NPE,個別會拋 NPE 的方法文件中也有說明。

ObjectUtils

更通用的情況還可以用 Commons Lang 中的工具類 ObjectUtils

defaultIfNull()

類似上文自行實現的 defaultWith(),不過並沒有標可空性註解。 defaultIfNull(obj, defaultVal)obj 非空時返回 obj 否則返回 defaultVal

getIfNull()

相當於預設值採用惰性求值的 defaultIfNull()getIfNull(obj, () -> ……)obj 非空時返回 obj,否則對 lambda 表示式求值並以其返回值作為 getIfNull() 的返回值。

firstNonNull()

對於一系列值,返回第一個非空的。

getFirstNonNull()

可以看作是惰性求值版的 firstNonNull(),對於一系列求值過程返回第一個求值結果非空的結果。 例如 getFirstNonNull(() -> null, () -> "hello", () -> throw new IllegalStateException()) 會返回 "hello" 而不會執行後面的求值過程,因此不會拋 IllegalStateException

allNotNullallNullanyNotNullanyNull

對於一系列值,判斷是否全都非全都是其中有非其中有空值。

ObjectUtils 中的大多數其他方法也都會對空值特殊處理而不是引發 NPE。除了 StringUtilsObjectUtils 之外,Commons Lang 中的 BooleanUtilsNumberUtils 也都提供了一系列空安全的工具方法。

其他

拋磚引玉,歡迎補充。

小結

Java 語言自身目前沒辦法徹底解決空值問題,不過有一些方法、工具可以用: - NPE 防禦:空值比較、不可變集合不支援空值 - Optional - 可空性註解 - Apache Commons

光說不練無異於紙上談兵,接下來的練習才是重中之重。

練習

以下練習中未標註解的變數,如果變數名以 x 開頭也表示可能為空。

為什麼不直接用 nullableXyz 這種更明顯的方式?因為現實程式碼中通常更不明顯。

1、 糾錯

if (xMethod.equals("POST")) {
   doPost();
}

if (xArg1.equals(xArg2)) {
    System.out.println("arg1 == arg2");
}

2、糾錯

var map1 = Map.of("abc", 10, "def", 20, xStr, 30);

var list1 = Arrays.asList(1, 2, -3, 9, null, 15);
var set1 = Set.copyOf(list1);

if (Map.of("hello", 1, "world", 2).containsKey(xStr)) {
    System.out.println("either 'hello' or 'world'");
}

3、加註可空性註解

public static <T, U> U mapSome(T x, Function<T, U> mapper) {
    return x == null ? null: mapper.apply(x);
}

4、用 Optional 重構上題 mapSome()

注:只是練習 Optional 的使用,上題的實現並不需要以 Optional 取代。

5、用 Optional 重構

Integer xInt = Math.random() > 0.8 ? null : Math.random() > 0.5 ? 5 : 12;

// 重構以下程式碼
var x1 = (xInt == null || xInt % 2 != 0) ? null : xInt / 2;
if (x1 != null) {
    System.out.println(x1);
}

6、用 Optional 重構 getTitledContent()

@NonNull
public static String getUpperTitle(@Nullable Post post) {
    if (post == null || post.getTitle() == null) {
        log.warning("no title")
        return "- UNTITLED -";
    }

    return post.getTitle().toUpperCase();
}

public class Post {
    @Nullable
    public String getTitle();
}

7、用 StringUtilsgetChoice() 的實現重構成一行程式碼

String getChoice(String choice, boolean highest) {
    if (choice != null && !choice.isEmpty())
        return choice;

    if (highest)
        return "High";

    return "Low";
}

8、使用 Common Collections 重構 getIdsString()

private static final List<Integer> IMPLICIT_IDS = List.of(101, 111, 191);
public static String getIdsString(@Nullable Collection<@NonNull Integer> ids) {
    if (ids == null) {
        return IMPLICIT_IDS.stream()
                .map(Object::toString)
                .collect(Collectors.joining());
    }

    return Stream.concat(ids.stream(), IMPLICIT_IDS.stream())
            .map(Object::toString)
            .collect(Collectors.joining());
}

9、使用 BooleanUtils 重構

public static String toHex(int n, @Nullable Boolean useUpper) {
    String s = Integer.toString(n, 16);
    return useUpper != null && useUpper ? s.toUpperCase() : s;
}

10、使用 ObjectUtils 重構

public static Instant tomorrowOf(@Nullable Instant x) {
    if (x == null) {
        log.debug("the base Date is null");
        x = Instant.now();
    }

    return x.plus(Duration.ofDays(1));
}

相關文章