Java 缺失的特性:扩展方法

时间:2023-04-03 16:08:21

*作者:周密(之叶)*


## 什么是扩展方法


扩展方法,就是能够向现有类型直接“添加”方法,而无需创建新的派生类型、重新编译或以其他方式修改现有类型。调用扩展方法的时候,与调用在类型中实际定义的方法相比没有明显的差异。


## 为什么需要扩展方法


考虑要实现这样的功能:从 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:


![1.png](~tplv-k3u1fbpfcp-zoom-1.image "1.png")


好吧,ChatGPT 认为 Java 里面的扩展方法就是通过工具类提供的静态方法 :)。所以接下来我将介绍一种全新的黑科技:


Manifold(<)


### 准备条件


Manifold 的原理和 Lombok 是类似的,也是在编译期间通过注解处理器进行处理。所以要在 IDEA 中正确使用 Manifold,需要安装 Manifold IDEA 的插件:


![2.png](~tplv-k3u1fbpfcp-zoom-1.image "2.png")


然后再在项目 pom 的 maven-compiler-plugin 中加入 annotationProcessorPaths:


```

<project xmlns=" xmlns:xsi="

         xsi:schemaLocation="


  ...


    <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 中,String 的 split 方法,使用的是字符串作为参数,即 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 注解  


1.  静态方法中,目标类型的参数,需要使用 @This 注解  


1.  工具类所在的包名,需要以 **extensions.目标类型全限定类名** 结尾 


—— 用过 C# 的同学应该会会心一笑,这就是模仿的 C# 的扩展方法。


关于第 3 点,之所以有这个要求,是因为 Manifold 希望能快速找到项目中的扩展方法,避免对项目中所有的类进行注解扫描,提升处理的效率。


具备了扩展方法的能力,现在我们就可以这样调用了:


Amazing!而且你可以发现,System.out.println(numStrs.toString()) 打印的居然是数组对象的字符串形式 —— 而不是数组对象的地址。查看反编译后的 App.class,发现是将扩展方法的调用,替换为静态方法调用:


![4.png](~tplv-k3u1fbpfcp-zoom-1.image "4.png")


而数组的 toString 方法,使用的是 Manifold 为数组定义的扩展方法 ManArrayExt.toString(@This Object array):


![5.png](~tplv-k3u1fbpfcp-zoom-1.image "5.png")


[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 的方法:


![6.png](~tplv-k3u1fbpfcp-zoom-1.image "6.png")


我们看到 List<@Self(true) Object> 这样的写法:@Self 是用来表示被注解的值应该是什么类型,如果是 @Self,即 @Self(false),表示被注解的值和 @This 注解的值是同一个类型;@Self(true) 则表示是数组中元素的类型。


对于对象数组,我们可以看到 toList 方法返回的就是对应的 List<T>(T 为数组元素的类型):


![7.png](~tplv-k3u1fbpfcp-zoom-1.image "7.png")


但如果是原始类型数组,IDEA 指示的返回值是:


![8.png](~tplv-k3u1fbpfcp-zoom-1.image "8.png")


但是我用的是 Java 啊,擦除法泛型怎么可能拥有 List<char> 这么伟大的功能 —— 所以你只能用原生类型来接收这个返回值 :)


![9.png](~tplv-k3u1fbpfcp-zoom-1.image "9.png")


—— 许个愿,希望 Project Valhalla 早日 GA。


我们经常在各个项目中看到,大家先把某个对象包装成 Optional,然后进行 filter、map 等。通过 @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 来实现扩展方法的功能 —— 当事人表示非常上瘾,已经离不开了。如果你有使用上的建议和疑问,欢迎和我一起讨论。


#### 谨慎添加扩展方法


如果决定在项目中使用 Manifold 实现扩展方法,那么我们**一定要做到 “管住自己的手”** 。


首先,就是上文说的,给 Object 或者其他在项目中使用非常广泛的类添加扩展方法,一定要非常的慎重,最好是要和项目组的同学一起讨论,让大家一起决定,否则很容易让人迷惑。


另外,如果要给某个类添加扩展方法,一定要先认真思考一个问题:“这个方法的逻辑是不是在这个类的职责范围内,是否有掺杂业务自定义逻辑”。例如下面这个方法(判断给定的字符串是不是一个合法的参数):


```

public static boolean isValidParam(String str) {

    return StringUtils.isNotBlank(str) && !"null".equalsIgnoreCase(str);

}

```


很明显,isValidParam 不是 String 这个类的职责范围,应该把 isValidParam 继续放在 XxxBizUtils 里面。当然,如果你把方法名改成 isNotBlankAndNotEqualsIgnoreCaseNullLiteral,那是可以的 :) —— 不过劝你别这么做,容易被打。