一些Java 泛型使用经验,使用泛型优化接口设计

3,288 阅读14分钟

1.方法泛型与强转

泛型最常见的用法是为容器添加类型变量,让编译器提供类型检查和自动类型转换
比如常见的返回值包装类:

@Data
public class Result<T> {
    private Integer code;
    private String message;
    private T data;
}

方法泛型

因为这是一个非常常用的类,所以我们经常添加 静态工厂方法链式调用/流式编程

@Data
@Accessors(chain = true)//设置 lombok 生成链式调用的 setter 方法,setter方法的返回值从 void 变为 Result<T>
public class Result<T> {
    private Integer code;
    private String message;
    private T data;

    public static <O> Result<O> ok() {
        //生产用的ok()还会进行一些其他的优化,比如因为存在大量的 return Result.ok(),避免每次都new一个对象
        return new Result<O>().setCode(200).setMessage("success");
    }
    //这里还会有其他的静态方法 如 ok(O data), err(), err(String message)等
}

这里我们就用到了方法泛型,因为静态方法只能通过定义方法泛型才能使用类型变量,而如果不使用类型变量,就只能返回Result<?>Result<Object>,或者原始类型Result,这都会导致你没办法像使用构造函数Result<String> = new Result<>();那样方便的使用静态工厂方法 ——Result<String> ok = Result.ok();

利用方法泛型进行强转

但是定义了方法泛型之后,我们面临第二个问题就是,我们没办法通过链式调用返回任意泛型

    public Result<String> getString1(){
        return Result.ok();//这是可以的,类型参数被自动推断为String
    }
   public Result<String> getString2(){
        return Result.ok().setData("hello");//这不行,类型参数被自动推断为 Object ,与返回值不兼容,无法通过编译
   }

这个时候,我们只能在方法名前加上尖括号,手动指定类型变量的具体类型:

public Result<String> getString3(){
    return Result.<String>ok().setData("hello");//麻烦的要死
}

这个时候就需要强转了,我们把 lombok 生成的setData改造一下
原本的setData:

public Result<T> setData(T data) {
    this.data = data;
    return this;
}

强转后的setData:

@SuppressWarnings("unchecked")
public <O> Result<O> setData(O data) {
    this.data = (T) data;
    return (Result<O>) this;
}

正因为Result<T>中的T只跟data字段有关,所以更改data字段的方法就可以使用方法泛型来强转 这样一来,链式调用就行得通了

public Result<String> getString3(){
    return Result.ok().setData("hello");//行得通的了
}

public Result<Result<String>> getString4() {
    return Result.ok()
            .setData("hello") //此时引用是 Result<String>
            .setData(1) //此时引用是 Result<Integer>
            .setData(1L) //此时引用是 Result<Long>
            .setData(Result.ok().setData("hello")) //此时引用是 Result<Result<String>>
            //想怎么set就怎么set,链式调用顺滑多了
            ;
}

这种链式调用+方法泛型和强转是优秀API设计常用的手法,比如大名鼎鼎的Caffeine在builder的时候就有这样的代码:

@NonNull
public <K1 extends K, V1 extends V> Caffeine<K1, V1> expireAfter(
    @NonNull Expiry<? super K1, ? super V1> expiry) {
  //...
  @SuppressWarnings("unchecked")
  Caffeine<K1, V1> self = (Caffeine<K1, V1>) this;
  self.expiry = expiry;
  return self;
}

并且提醒我们因为引入了强转,方法调用后泛型参数发生了变化,要链式调用而不是使用之前的引用

? 、 supper 、extends 、&

jdk中和泛型有关的类大量使用了 ? supper? extends
比如List<E>addAll方法:

addAll(Collection<? extends E> c);

Iterable<T>中的forEach方法:

default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}

那么问题来了,为什么List的add方法还是boolean add(E e);,这里却要用Collection<? extends E>Consumer<? super T>呢?

这是因为,这里的List<E>类型变量EIterable<T>T还会出现在另外一个对象(Collection<?>Consumer<?>)的泛型参数的尖括号<>中的,此时存在两个泛型,一个是List<?> 一个是Collection<?>

泛型的不变性

而涉及到第二个泛型时,就会发生两个泛型边界的兼容问题,而除非通过extendssuper显式声明了泛型边界,否则只有两个泛型严格对齐才能通过编译——即,泛型是类型不变的,泛型类不因参数类型之间的关系而具备关系,List<String>List<Object> 之间不具备List - List以外的类型关系,List<Object> a = new ArrayList<String>()无法像Object[] a = new String[0]那样通过编译

extends :为泛型引入协变性

协变: 变型 (variance)时,保留了类型的顺序(≤),将类型从更具体到更通用的顺序:若类型关系中A ≤ B 可得 I<A> ≤ I<B> ,则 A => I<A> 为协变。
String ≤ Object => String[] ≤ Object[],所有Object[]可以存在的地方,都可以是String[]

即,如果List<E>addAll方法是addAll(Collection<E> c);的话

public static class MyList<E> {
    void add(E e){}
    void addAll(Collection<E> c) {}
    void forEach(Consumer<E> consumer){}
}

@Test
void test_2022_08_07_23_29_24() {
    MyList<Object> list = new MyList<>();
    list.add("strings");//这样可以,因为存在向上转型
    list.addAll(Arrays.asList("1", "2"));//这样也是可以的,因为Arrays.asList存在方法泛型所以发生了类型推导
    List<String> strings = Arrays.asList("1", "2");
    list.addAll(strings);//尴尬了,居然无法通过编译
    list.addAll(Arrays.<String>asList("1", "2"));//这样也不行,显式指定方法泛型,没有了类型推导
}

List<Object>可以addString,但居然没办法addAll List<String> 这不就太难受了吗

所以根据addAll的语义,通过Collection<? extends E>定义泛型变量的边界 ,使其方法参数获得获得协变性

super :为泛型引入逆变性

逆变: 变型 (variance)时,反转了类型的顺序:若类型关系中A ≤ B 可得 I<A> ≥ I<B> ,则 A => I<A> 为逆变。

Consumer<? super T>也是同理 ,使方法参数在Consumer<String>Consumer<String> 之间达成String ≥ Object的逆变

@Test
void test_2022_08_07_23_43_15() {
    MyList<String> list2 = new MyList<>();
    Consumer<Object> consumer = Object::toString;
    list2.forEach(consumer);//尴尬了,居然无法通过编译
    list2.forEach(Object::toString);//这样可以,因为方法引用的类型推断,自动转为Consumer<String>了
}

&

& 用于 extends 中,用于标明该类型实现了多个接口,比如Collections中的minmax方法,就要求参数的集合中的元素必须是Object的子类,并且实现Comparable接口,并且Comparable接口的泛型参数是<? super T>,即元素T不能是抽象类或者接口,且必须能够对元素自身进行比较

public static <T extends Object & Comparable<? super T>> T min(Collection<? extends T> coll) {
    Iterator<? extends T> i = coll.iterator();
    T candidate = i.next();

    while (i.hasNext()) {
        T next = i.next();
        if (next.compareTo(candidate) < 0)
            candidate = next;
    }
    return candidate;
}

2.自限定泛型

在刚才的Collections,我们看到一个复杂的泛型定义——“不能是抽象类或者接口,且能够排序自身”,后者这点其实就有点自限定泛型的意味了
不过由于Comparable接口只要求排序某个对象,而不是排序自身:

public interface Comparable<T> {
    public int compareTo(T o);
}

那么怎么才能定义一个能够排序自身的Comparable呢?这就用到一个烧脑的泛型定义——自限定泛型了:

interface SelfComparable<T extends SelfComparable<T>> extends Comparable<T> {
}

这就要求,在具体化这个泛型参数时,必须填入一个能够排序自身的排序类,比如

static abstract class MyNumber1 implements SelfComparable<MyNumber1> {

}

当然,这并不能完全限制必须是排序自身:

static abstract class MyNumber2222 implements SelfComparable<MyNumber1> {

}

虽然大部分时候,这种限制都足够了,但如果需要完全的自限定,则需要运行时检查,或者像Collections.min一样转换为主动的方法调用利用方法泛型来进行额外的泛型边界限定,比如:

interface SelfComparable<T extends SelfComparable<T>> extends Comparable<T> {
    @SuppressWarnings("unchecked")
    default boolean check() {
        return check(getClass());
    }
    //如果非反射的编程方式调用此方法,在不使用原始类型和强转的情况下,方法参数的泛型类型不是自限定则不能通过编译
    //这里只是一个示例,没有检测父类接口情况和其他的一些例外情况,实际情况中通过第三方库(如 typetools )解析更为方便
    static <T extends SelfComparable<T>> boolean check(Class<T> clazz) {
        for (Type type : clazz.getGenericInterfaces()) {
            if (type instanceof ParameterizedType) {
                ParameterizedType parameterizedType = (ParameterizedType) type;
                if ((parameterizedType).getRawType().equals(SelfComparable.class)) {
                    Type[] arguments = parameterizedType.getActualTypeArguments();
                    if (arguments.length > 0 && arguments[0].equals(clazz)) {
                        return true;
                    }
                }
            }
        }
        return false;
    }
}

3.泛型参数协变性与协变返回类型

如果不使用泛型,java的方法参数也是不变的,既不具备协变性,也不具备逆变性:

class TestA{
    public void fun(Number num){}
    public Number fun2(Number num){}
}
class TestB extends TestA{
    //@Override // 无法重写使参数为父类,只能重载
    public void fun(Object num){}
    //@Override // 无法重写使参数为子类,只能重载
    public void fun(Integer num){}
    @Override // 可以重写使返回类型为子类,方法返回值是协变的
    public Integer fun2(Number num){return null;}
}

但是在引入泛型之后,就可以在子类重写方法的参数类型了:

class TestA<N extends Number> {
    public void fun(N str) {
    }
}
class TestB extends TestA<Integer> {
    @Override // 可以重写为Number的子类了
    public void fun(Integer str) {
    }
}
class TestC<O extends AtomicLong> extends TestA<O> {
    @Override
    public void fun(AtomicLong str) {//然而,如果子类也用了泛型,重写时会发生边界问题
    }
//  @Override
//  public void fun(O str) {//这两种重写都是可以的,但是后者不接受 AtomicLong 类型,只接受AtomicLong的子类
//  }
}

使用泛型作为参数类型和返回类型的好处在于,可以为方法的参数和返回值通过泛型显式引入协变性

4.利用自限定泛型和泛型参数协变性设计通用链式调用API接口

如果我们想设计一个上传的API

FileInfo uploder(Object...args);

由于上传本身,数据来源常见的有5种byte[]MultipartFileInputStreamFileURL

确定好数据之后,为上传的文件设置的属性常见的也有5种filename(String filename)fileFullName(String fileFullName)fileExtension(String fileExtension)basePath(String basePath)directory(String directory)

而像这种存在大量可选参数设置的API,如果没有分阶段的链式调用,比如某SDK:

image.png 简直是重载地狱

简洁的API设计方法时通过分阶段分解上传调用,再结合Builder模式和链式调用,只需要完成5+5=10个方法即可完美调用,而不是

重载方法数量: 1个(10参数方法,绝地天通) ~ 5个(6参数方法,丑陋之极)~ 5*5=25个(1~6参数,勉强能用)~ 5*(2^5-1)=155个(保证不存在某个调用的某个参数一直为null的全吻合调用,但接口地狱绘卷)

class FileStorage {
    UploadStarter uploader();//开始上传,进入分阶段的文件上传链式调用流程
    
    boolean exists(URI fileUri);
    
    void delete(URI fileUri);
    
    InputStream download(URI fileUri);
}
class UploadStarter {
    UploadOptions from(byte[] bytes, String filename);

    UploadOptions from(MultipartFile multipartFile, String filename);

    UploadOptions from(InputStream inputStream, String filename);

    UploadOptions from(File file);

    UploadOptions from(URL url);
}
class UploadOptions {
    UploadOptions filename(String filename);

    UploadOptions fileFullName(String fileFullName);

    UploadOptions fileExtension(String fileExtension);

    UploadOptions basePath(String basePath);

    UploadOptions directory(String directory);

    FileInfo upload();//上传结束,返回最终文件信息
}

然而,当我们使用之后,就会发现一个问题,文件存储本身是有多个实现的——本地存储和云存储之间的API有共同的地方,也有不同的地方,不同云存储之间也存在差异

怎么样让这样一个链式调用的API能够具备多种实现类,并且每种实现类具有自己的额外方法,又能继承相同的公共代码呢?

这就要用到自限定泛型和泛型返回值与参数了:

以文件存储API为例,使用自限定泛型和泛型返回值完成链式调用

/**
 * 文件存储
 */
//为了省去不必要额外泛型参数,这里使用了通配符
public interface FileStorage<L extends FileStorage.UploadStarter<?>> { 

    L uploader();

    boolean exists(String fileUri);

    void delete(String fileUri);

    void rename(String fileUri, String newFilename);

    void copy(String sourceFileUri, String targetFileUri);

    InputStream download(String fileUri);

    /**
     * 上传启动器
     */
    interface UploadStarter<T extends UploadOptions<T>> {
        T from(byte[] bytes, String filename);

        T from(MultipartFile multipartFile, String filename);

        T from(InputStream inputStream, String filename);

        T from(File file);

        T from(URL url);
    }

    /**
     * 上传选项参数
     */
    interface UploadOptions<T extends UploadOptions<T>> {
        T filename(String filename);

        T fileFullName(String fileFullName);

        T fileExtension(String fileExtension);

        T basePath(String basePath);

        T directory(String directory);

        FileInfo upload();
    }

在此基础上,提取公共代码实现抽象类,比如AbstractUploadOptions,具体本地存储或者不同云存储就能实现自己FileStorageUploadStarterUploadOptions

public abstract class AbstractUploadOptions<T extends AbstractUploadOptions<T>> implements FileStorage.UploadOptions<T> {
    protected File file;
    protected InputStream inputStream;
    protected String filename;
    protected String originalFilename;
    protected String fileExtension = "";
    protected URI uri;
    protected String rootPath;
    protected String basePath;
    protected String directory = "";
    protected String fullPath;

    protected AbstractUploadOptions(String rootPath, String basePath, File file) {
        this(rootPath, basePath, Objects.requireNonNull(file), null, file.getName());
    }

    protected AbstractUploadOptions(String rootPath, String basePath, InputStream inputStream, String fileFullName) {
        this(rootPath, basePath, null, Objects.requireNonNull(inputStream), fileFullName);
    }

    protected AbstractUploadOptions(String rootPath, String basePath, File file, InputStream inputStream, String fileFullName) {
        this.rootPath = rootPath;
        this.basePath = basePath;
        this.originalFilename = fileFullName;
        this.inputStream = inputStream;
        this.file = file;
    }


    @Override
    @SuppressWarnings("unchecked")
    public T filename(String filename) {
        String fileExt;
        if (StringUtils.hasText((fileExt = FileUtil.getSuffix(filename)))) {
            this.fileExtension = fileExt;
        } else {
            filename = FileStrUtil.changeFileExtension(filename, this.fileExtension);
        }
        this.filename = filename;
        calculateFullPath();
        if (this.originalFilename == null) {
            this.originalFilename = filename;
        }
        return (T) this;
    }

    @Override
    @SuppressWarnings("unchecked")
    public T fileFullName(String fileFullName) {
        this.filename = fileFullName;
        this.fileExtension = FileUtil.getSuffix(fileFullName);
        calculateFullPath();
        if (this.originalFilename == null) {
            this.originalFilename = fileFullName;
        }
        return (T) this;
    }

    @Override
    @SuppressWarnings("unchecked")
    public T fileExtension(String fileExtension) {
        if (fileExtension.startsWith(StrUtil.DOT)) {
            fileExtension = fileExtension.substring(1);
        }
        if (StringUtils.hasText(filename)) {
            this.filename = FileStrUtil.changeFileExtension(filename, fileExtension);
        }
        this.fileExtension = fileExtension;
        calculateFullPath();
        return (T) this;
    }

    @Override
    @SuppressWarnings("unchecked")
    public T basePath(String basePath) {
        this.basePath = PathUtil.canonicalizePath(basePath);
        calculateFullPath();
        return (T) this;
    }

    @Override
    @SuppressWarnings("unchecked")
    public T directory(String directory) {
        this.directory = PathUtil.canonicalizePath(directory);
        calculateFullPath();
        return (T) this;
    }

    protected void calculateFullPath() {
        this.fullPath = PathUtil.calculateDir(this.basePath, this.directory) + filename;
    }
}

ps: 抽象类每个方法返回值都需要强转

然后子类就可以继承抽象类

static class OSSUploadOptions extends AbstractUploadOptions<OSSUploadOptions>{

     public OSSUploadOptions(String rootPath, String basePath, File file) {
         super(rootPath, basePath, file);
     }

    public OSSUploadOptions(String rootPath, String basePath, InputStream inputStream, String fileFullName) {
        super(rootPath, basePath, inputStream, fileFullName);
    }

    public OSSUploadOptions(String rootPath, String basePath, File file, InputStream inputStream, String fileFullName) {
        super(rootPath, basePath, file, inputStream, fileFullName);
    }

    public OSSUploadOptions fileAcl(FileAcl fileAcl) { //OSS可以单独设置文件权限
        return null;
    }

    @Override
     public FileInfo upload() {
         // do upload
         return null;
     }
 }
// 子类在调用父类之后,由于泛型作为返回类型,自动协变后,返回类型依然是子类,可以依旧链式调用子类独有的方法
new OSSUploadOptions(null,null,null).filename("").fileAcl(null);

(这此举例中并没有用到泛型参数,不过更为复杂的API,比如通用的 Fulent SQL 查询构建器,就会用到泛型参数了)

这样就通过泛型完成了链式调用的接口和公共代码抽取,并利用泛型自动协变来使子类特性不被链式调用时覆盖

5.泛型参数的“重载”

在泛型之前,java的方法是可以任意重载的:

void fun(Object o);
void fun(String o);

但是到了泛型之后,泛型参数由于会被擦除,导致方法的参数签名完全一样而无法重载:

static <T> void where(Function<T, Long> field) { //无法通过编译,两个方法具有相同的擦除
    System.out.println("Long");
}

static <T> void where(Function<T, String> field) {
    System.out.println("string");
}

但是呢,如果用一些歪门邪道的方法,就可以让泛型参数“重载”,特别是针对函数式接口而言,这种“重载”更为有用:

@FunctionalInterface
interface Function0<T, R> extends Function<T, R> {
}
@FunctionalInterface
interface Function1<T, R> extends Function<T, R> {
}
@FunctionalInterface
interface Function2<T, R> extends Function<T, R> {
}

static <T, V> void where(Function<T, V> field) { //通过简单粗暴的"copy"接口就能“重载”参数啦
    System.out.println("V");
}

static <T> void where(Function0<T, Number> field) {
    System.out.println("Number");
}

static <T> void where(Function1<T, Long> field) {
    System.out.println("Long");
}

static <T> void where(Function2<T, String> field) {
    System.out.println("string");
}

虽然写起来不像泛型的重载,但是用的时候就一模一样了,算是某种形式的“重载”吧:

    @Test
    void test_2022_08_09_16_10_50() {
        where(Date::toInstant);//方法链接到 Function<T, V> field
        where(Integer::intValue);//方法链接到 Function0<T, Number>
        where(Integer::longValue);//方法链接到 Function1<T, Long> field
        where(Object::toString);//方法链接到 Function2<T, String>
    }

更新:使用可变参数替代泛型重载


@UtilityClass
static class Void1 {}
@UtilityClass
static class Void2 {}
@UtilityClass
static class Void3 {}

// 如果存在非可变参数的可调用函数,会优先链接到非可变函数,存在该函数声明时,调用全部链接到这里
//static <T, V> void where(Function<T, V> field) { 
//    System.out.println("V");
//}

//通过可变参数改变签名也可以实现类似重载的效果
static <T, V> void where(Function<T, V> field, Void... ignored) { //这里随便塞一些无意义的类就可以
    System.out.println("V");
}

static <T> void where(Function<T, Number> field, Void1... ignored) {
    System.out.println("Number");
}

static <T> void where(Function<T, Long> field, Void2... ignored) {
    System.out.println("Long");
}

static <T> void where(Function<T, String> field, Void3... ignored) {
    System.out.println("string");
}

虽然这种函数重载可以正常声明,但是由于可变参数本身会导致编译器只接受明确的最佳匹配,使用起来就没有上一种方式丝滑了,对于存在向上转型的类型,不能像第一种方法那样调用

static void test_2022_08_09_16_10_50() {
    where(Date::toInstant);//方法链接到 Function<T, V>
    where(Integer::intValue);//编译错误 对 'intValue' 的引用不明确,'intValue()' 和 'intValue()' 均匹配
    where(Integer::longValue);//编译错误 对 'longValue' 的引用不明确,'longValue()' 和 'longValue()' 均匹配
    where(Object::toString);//编译错误 方法调用不明确
}

只有类型不兼容,区分明显的情况下可以正确编译

@UtilityClass
static class Void1 {}

@UtilityClass
static class Void2 {}

@UtilityClass
static class Void3 {}


static <T, V> void where(Function<T, Instant> field, Void... ignored) {
    System.out.println("V");
}

static <T> void where(Function<T, Integer> field, Void1... ignored) {
    System.out.println("Number");
}

static <T> void where(Function<T, Long> field, Void2... ignored) {
    System.out.println("Long");
}

static <T> void where(Function<T, String> field, Void3... ignored) {
    System.out.println("string");
}

static void test_2022_08_09_16_10_50() {
    where(Date::toInstant);
    where(Integer::intValue);
    where(Integer::longValue);
    where(Object::toString);
}

6.使用泛型跳过受检异常

不使用lombok的@SneakyThrows也能通过泛型抛出受检异常:

public static void main(String[] args) {
    if (args.length != 3) {
        sneakyThrows(new Throwable());
    }
}

@SuppressWarnings("unchecked")
public static <T extends Throwable> void sneakyThrows(Throwable throwable) throws T {
    throw (T) throwable;
}

在调用 sneakyThrows 时, 方法泛型被自动推断为了 RuntimeException 所以不用添加throws,在sneakyThrows中由于类型参数T会被擦除为Throwable,所以(T) throwable强转也不会报错

然后,为了哄编译器开心,我们还可以为 sneakyThrows 方法加上返回值,使得从语法上保持和正常的 throw 一样的用法

public static Object test() {
    throw sneakyThrows(new Throwable());
    //这样后面就不用接个 return null; 了
}

@SuppressWarnings("unchecked")
public static <T extends Throwable> RuntimeException sneakyThrows(Throwable throwable) throws T {
    throw (T) throwable;
}