Java 缺失的特性:擴充套件方法

阿里巴巴雲原生發表於2023-04-03

什麼是擴充套件方法

擴充套件方法,就是能夠向現有型別直接“新增”方法,而無需建立新的派生型別、重新編譯或以其他方式修改現有型別。呼叫擴充套件方法的時候,與呼叫在型別中實際定義的方法相比沒有明顯的差異。

為什麼需要擴充套件方法

考慮要實現這樣的功能:從 Redis 取出包含多個商品ID的字串後(每個商品ID使用英文逗號分隔),先對商品ID進行去重(並能夠維持元素的順序),最後再使用英文逗號將各個商品ID進行連線。

// "123,456,123,789"
String str = redisService.get(someKey)

傳統寫法:

String itemIdStrs = String.join(",", new LinkedHashSet<>(Arrays.asList(str.split(","))));

使用 Stream 寫法:

String itemIdStrs = Arrays.stream(str.split(",")).distinct().collect(Collectors.joining(","));

假設在 Java 中能實現擴充套件方法,並且我們為陣列新增了擴充套件方法 toList(將陣列變為 List),為 List 新增了擴充套件方法 toSet(將 List 變為 LinkedHashSet),為 Collection 新增了擴充套件方法 join(將集合中元素的字串形式使用給定的連線符進行連線),那我們將可以這樣寫程式碼:

String itemIdStrs = str.split(",").toList().toSet().join(",");

相信此刻你已經有了為什麼需要擴充套件方法的答案:

  • 可以對現有的類庫,進行直接增強,而不是使用工具類
  • 相比使用工具類,使用型別本身的方法寫程式碼更流暢更舒適
  • 程式碼更容易閱讀,因為是鏈式呼叫,而不是用靜態方法套娃

在 Java 中怎麼實現擴充套件方法

我們先來問問最近大火的 ChatGPT:

ChatGPT

好吧,ChatGPT 認為 Java 裡面的擴充套件方法就是透過工具類提供的靜態方法 :)。

人工智障

所以接下來我將介紹一種全新的黑科技:Manifold

準備條件

Manifold 的原理和 Lombok 是一樣的,也是在編譯期間透過註解處理器進行處理。所以要在 IDEA 中正確使用 Manifold,需要安裝 Manifold IDEA 的外掛:

Manifold 的外掛

然後再在專案 pom 的 maven-compiler-plugin 中加入 annotationProcessorPaths

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    ...

    <properties>
        <manifold.version>2022.1.35</manifold.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>systems.manifold</groupId>
            <artifactId>manifold-ext</artifactId>
            <version>${manifold.version}</version>
        </dependency>

        ...
    </dependencies>

    <!--Add the -Xplugin:Manifold argument for the javac compiler-->
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>8</source>
                    <target>8</target>
                    <encoding>UTF-8</encoding>
                    <compilerArgs>
                        <arg>-Xplugin:Manifold no-bootstrap</arg>
                    </compilerArgs>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>systems.manifold</groupId>
                            <artifactId>manifold-ext</artifactId>
                            <version>${manifold.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

如果你的專案中使用了 Lombok,需要把 Lombok 也加入 annotationProcessorPaths

<annotationProcessorPaths>
    <path>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${lombok.version}</version>
    </path>
    <path>
        <groupId>systems.manifold</groupId>
        <artifactId>manifold-ext</artifactId>
        <version>${manifold.version}</version>
    </path>
</annotationProcessorPaths>

編寫擴充套件方法

JDK 中,Stringsplit 方法,使用的是字串作為引數,即 String[] split(String)。我們現在來為 String 新增一個擴充套件方法 String[] split(char):按給定的字元進行分割。

基於 Manifold,編寫擴充套件方法:

package com.alibaba.zhiye.extensions.java.lang.String;

import manifold.ext.rt.api.Extension;
import manifold.ext.rt.api.This;
import org.apache.commons.lang3.StringUtils;

/**
 * String 的擴充套件方法
 */
@Extension
public final class StringExt {

    public static String[] split(@This String str, char separator) {
        return StringUtils.split(str, separator);
    }
}

可以發現本質上還是工具類的靜態方法,但是有一些要求:

  1. 工具類需要使用 Manifold 的 @Extension 註解
  2. 靜態方法中,目標型別的引數,需要使用 @This 註解
  3. 工具類所在的包名,需要以 extensions.目標型別全限定類名 結尾

—— 用過 C# 的同學應該會會心一笑,這就是模仿的 C# 的擴充套件方法。

關於第 3 點,之所以有這個要求,是因為 Manifold 希望能快速找到專案中的擴充套件方法,避免對專案中所有的類進行註解掃描,提升處理的效率。

具備了擴充套件方法的能力,現在我們就可以這樣呼叫了:

Manifold 擴充套件方法呼叫示例

Amazing!而且你可以發現,System.out.println(numStrs.toString()) 列印的居然是陣列物件的字串形式 —— 而不是陣列物件的地址。檢視反編譯後的 App.class,發現是將擴充套件方法的呼叫,替換為靜態方法呼叫:

App.class

而陣列的 toString 方法,使用的是 Manifold 為陣列定義的擴充套件方法 ManArrayExt.toString(@This Object array)

ManArrayExt.toString

[Ljava.lang.String;@511d50c0 什麼的,Goodbye,再也不見~


因為是在編譯期將擴充套件方法的呼叫替換為靜態方法呼叫,所以使用 Manifold 的擴充套件方法,即使呼叫方法的物件是 null 也沒有問題,因為處理後的程式碼是把 null 作為引數傳遞到對應的靜態方法。比如我們對 Collection 進行擴充套件:

package com.alibaba.zhiye.extensions.java.util.Collection;

import manifold.ext.rt.api.Extension;
import manifold.ext.rt.api.This;

import java.util.Collection;

/**
 * Collection 的擴充套件方法
 */
@Extension
public final class CollectionExt {

    public static boolean isNullOrEmpty(@This Collection<?> coll) {
        return coll == null || coll.isEmpty();
    }
}

然後呼叫的時候:

List<String> list = getSomeNullableList();

// list 如果為 null 會進入 if 塊,而不會觸發空指標異常
if (list.isNullOrEmpty()) {
    // TODO
}

java.lang.NullPointerException,Goodbye,再也不見~

陣列擴充套件方法

JDK 中,陣列並沒有一個具體的對應型別,那為陣列定義的擴充套件類,要放到什麼包中呢?看下 ManArrayExt 的原始碼,發現 Manifold 專門提供了一個類 manifold.rt.api.Array,用來表示陣列。比如 ManArrayExt 中為陣列提供的 toList 的方法:

toList

我們看到 List<@Self(true) Object> 這樣的寫法:@Self 是用來表示被註解的值應該是什麼型別,如果是 @Self,即 @Self(false),表示被註解的值和 @This 註解的值是同一個型別;@Self(true) 則表示是陣列中元素的型別。

對於物件陣列,我們可以看到 toList 方法返回的就是對應的 List<T>(T 為陣列元素的型別):

List-String

但如果是原始型別陣列,IDEA 指示的返回值是:

List-char

但是我用的是 Java 啊,擦除法泛型怎麼可能擁有 List<char> 這麼偉大的功能 —— 所以你只能用原生型別來接收這個返回值 :)

RawList

—— 許個願:Project Valhalla 會在 Java21 中 GA。


我們經常在各個專案中看到,大家先把某個物件包裝成 Optional,然後進行 filtermap 等。透過 @Self 的型別對映,你可以這樣為 Object 加入一個非常實用的辦法:

package com.alibaba.zhiye.extensions.java.lang.Object;

import manifold.ext.rt.api.Extension;
import manifold.ext.rt.api.Self;
import manifold.ext.rt.api.This;

import java.util.Optional;

/**
 * Object 的擴充套件方法
 */
@Extension
public final class ObjectExt {

    public static Optional<@Self Object> asOpt(@This Object obj) {
        return Optional.ofNullable(obj);
    }
}

那麼任何物件,都將擁有 asOpt() 方法。

相比於之前的需要包裝一下的不自然:

Optional.ofNullable(someObj).filter(someFilter).map(someMapper).orElseGet(someSupplier);

你現在可以自然而然的使用 Optional

someObj.asOpt().filter(someFilter).map(someMapper).orElseGet(someSupplier);

當然,Object 是所有的類的父類,這樣做是否合適,還是需要謹慎的思考一下

擴充套件靜態方法

我們都知道 Java9 給集合新增了工廠方法:

List<String> list = List.of("a", "b", "c");
Set<String> set = Set.of("a", "b", "c");
Map<String, Integer> map = Map.of("a", 1, "b", 2, "c", 3);

是不是很眼饞?因為如果用的不是 Java9 及以上版本(Java8:直接報我身份證就行),你就得用 Guava 之類的庫 —— 然而 ImmutableList.of 用起來終究是比不上 List.of 這樣的正統來的自然。

沒關係,Manifold 說:“無所謂,我會出手”。基於 Manifold 擴充套件靜態方法,就是在擴充套件類的靜態方法上,也加上 @Extension

package com.alibaba.aladdin.app.extensions.java.util.List;

import manifold.ext.rt.api.Extension;
import manifold.ext.rt.api.This;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

/**
 * List 擴充套件方法
 */
@Extension
public final class ListExt {

    /**
     * 返回只包含一個元素的不可變 List
     */
    @Extension
    public static <E> List<E> of(E element) {
        return Collections.singletonList(element);
    }

    /**
     * 返回包含多個元素的不可變 List
     */
    @Extension
    @SafeVarargs
    public static <E> List<E> of(E... elements) {
        return Collections.unmodifiableList(Arrays.asList(elements));
    }
}

然後你就可以欺騙自己已經用上了 Java8 之後的版本 —— 你發任你發,我用 Java8。

BTW,因為 Object 是所有類的父類,如果你給 Object 新增靜態擴充套件方法,那麼意味著你可以在任何地方直接訪問到這個靜態方法,而不需要 import —— 恭喜你,解鎖了 “頂級函式”。

建議

關於 Manifold

我從 2019 年開始關注 Manifold,那時候 Manifold IDEA 外掛還是收費的,所以當時只是做了簡單的嘗試。最近再看,IDEA 外掛已經完全免費,所以迫不及待地想要物盡其用。目前我已經在一個專案中使用了 Manifold 來實現擴充套件方法的功能 —— 當事人表示非常上癮,已經離不開了。如果你有使用上的建議和疑問,歡迎和我一起討論。

目前 Aone 的程式碼掃描外掛還不支援 Manifold,程式碼掃描會失敗 —— 所以如果確定使用 Manifold,可以找程式碼掃描外掛相關的同學(優勝),先關閉你專案的 Aone 應用的程式碼掃描。

謹慎新增擴充套件方法

如果決定在專案中使用 Manifold 實現擴充套件方法,那麼我們一定要做到 “管住自己的手”

首先,就是上文說的,給 Object 或者其他在專案中使用非常廣泛的類新增擴充套件方法,一定要非常的慎重,最好是要和專案組的同學一起討論,讓大家一起決定,否則很容易讓人迷惑(罵人)。

另外,如果要給某個類新增擴充套件方法,一定要先認真思考一個問題:“這個方法的邏輯是不是在這個類的職責範圍內,是否有摻雜業務自定義邏輯”。例如下面這個方法(判斷給定的字串是不是一個合法的引數):

public static boolean isValidParam(String str) {
    return StringUtils.isNotBlank(str) && !"null".equalsIgnoreCase(str);
}

很明顯,isValidParam 不是 String 這個類的職責範圍,應該把 isValidParam 繼續放在 XxxBizUtils 裡面。當然,如果你把方法名改成 isNotBlankAndNotEqualsIgnoreCaseNullLiteral,那是可以的 :) —— 不過勸你別這麼做,容易被打。

相關文章