文章

搞懂 Java 与 Kotlin 泛型

本文讨论的「泛型」只局限于 Java / Kotlin 中。

什么是泛型

泛型本质上是一个确保类型安全的手段,它属于那种没有也罢有则更佳的特性。泛型几乎没有扩展 Java 能力的边界,而是提高了编码效率与可维护性,减少模板代码并降低出错机率。这么说的原因是使用 Object 就可以传递任意数据,没有必要使用泛型。

所有写 Java 的人都离不开泛型,比如 List<String> 但主动写一个泛型类的人就不多了,其中一个原因是对泛型的能力缺乏清晰的认识,所以不知道什么时候可以使用这个利器。

泛型适用场景有以下几个:

  • 类/接口内部的数据或操作限制于某种数据类型,但这个类型本身可以有多种选择。List 就是这个一个典型用例。
  • 方法的参数/返回值限制于某种类型,但每次调用的时候这个类型可能不一样。(泛型方法)
  • 对参数或返回值类型施加额外的限制。 比如有这样一个方法 void merge(List l1, List l2),希望限制它两个参数的元素必须是同一个类型的且必须实现序列化接口,就可以借助泛型做到:<T extends Serializable> void merge(List<T> l1, List<T> l2) 这个用例中我们并不关心类型参数被实例化为何物,仅仅是用来施加限制。

泛型的实例化

泛型类

泛型只是一个占位符,在使用的时候(编译期)需要被实例化为具体的类型才有意义。这有点像形参与实参的区别。因为它只是一个占位符,所以定义泛型时候的名字无所谓,就像参数名一样。例如下面两种泛型定义等价:

public interface Shop<T> {
    T get();
}
public interface Shop<balabala> {
    balabala get();
}

所谓泛型的实例化,就是给它确定具体类型的过程。但这个具体类型也可以是另一个泛型提供的,比如下面这个例子:

public class LargeShop<T> implements Shop<T>

左边的 T 是声明的类型参数,右边的 T 是用 LargeShop 的类型去实例化 Shop 泛型。同样类比传参:

public class Dog extends Pet {
  Dog(String name) {  // 这里是参数声明
    super(name);      // 用自己接收的参数实例化父类参数
  }
}

如果用 Kotlin 的语法,参数和泛型就更像了:

class Dog(name: String) : Pet(name) // name 是构造参数

泛型的实例化依赖于类的实例化,所以静态字段不能使用泛型,静态方法不能使用类定义的泛型,但可以用自己的泛型,称为「泛型方法」。

泛型方法

既然泛型与类实例息息相关,静态字段/方法就不可以使用类定义的泛型(因为类没有实例化时泛型也不会实例化)。比如一个错误的例子:

class Wrapper<T> {
    static T data; // 编译错误
    static void set(T data){} // 编译错误
}

但方法可以有自己的泛型定义(静态/非静态都可以)。 写法如下:

static <E> E setAndGet(E data) {} // 自己声明泛型的类型参数 E 并使用

上面说过,泛型在使用的时候必须实例化。泛型方法也不例外,它的实例化直接用尖括号语法。

class Wrapper<T> {
  static <E> E take() {
  }
}

Mango mango = Wrapper.<Mango>take();

但这么写太麻烦了,编译器其实可以根据传入参数的类型,或者是接受返回值的变量类型,自动确定类型参数的值,从而自动实例化,这个过程叫做「类型推断」。所以通常只需要这么写:

Mango mango = Wrapper.take();

泛型实例化限制

父类的泛型不是父类

这个标题比较拗口,看一个实例就明白了:

// 无法编译通过
List<Fruit> fruits = new ArrayList<Mango>();

显然 FruitMango 的父类,但是它们的泛型 List<Fruit> 却不是 List<Mango> 的父类。相信很多人都遇到过这个问题,按照网上说的依葫芦画瓢加个边界限定就对付过去了。这里就解释一下为什么编译器不允许这种赋值操作。

写出这行代码时,内心 OS 大概是:从 List<Mango> 里任取一个都是 Fruit,所以应该允许赋值。这个想法错在不够全面,泛型 List 其实有两个语义:

  • List<Mango> 里任取一个都是 Mango(也是 Fruit)。
  • 只允许把 Mango 添加进 List。

List<Fruit> 的语义是「可以把任何 Fruit 添加进 List」。我们知道 java 的赋值是引用赋值,本质上是一个对象。如果允许这么转换,结果就是允许把其他水果加入 List<Mango> 中,显然这违背了限制。

实例化上界/协变

知道了原因,大概也有和我一样的小伙伴还是不服气:我只想得到一个通用的 List,又没想添加元素,就那么难吗?

所以 Java 提供了一个语法让我们与编译器签订契约成为魔法少女:开发者承诺不会添加元素,而编译器允许我们做类型转换。写法如下:

List<? extends  Fruit> fruits = new ArrayList<Mango>();
fruits.add(new Mango()); // 编译不通过,不可以添加元素,Mango 也不行

这种写法就叫做泛型的实例化上界,也称为「协变」 (covariant),协变只能读取数据,不难修改数据。注意与定义时的上界区分开。定义时上界规定了实例化时可用的类型范围,不会影响添加操作。

定义时上界写法,可以同时施加多个限制(其中最多只能有一个类,且类必须写在最前面),多个限制用 & 连接:

// 这是泛型*定义时*上界,它要求实例化的类型必须是 Fruit 或它的子类,且实现 Plants 与 Life 接口。
public interface Shop<T extends Fruit & Plants & Life> {
}

到这也许有我一样难伺候的开发者又心生不满:我添加一个 Mango 也不行吗,这又没有违反限制。不好意思,也不行。因为经过类型转换后编译器已经无从得知这个变量的原始类型是什么了,自然就无法判断要添加的元素是不是合法。如果非得添加的话那就调用 List<Mango> 的方法吧,反正它们在内存中是同一个东西。

有好事者追问,既然编译器不知道原始类型是啥,它总知道当前限制是 Fruit 吧,那我添加一个 Fruit 可以吗?当然不可以!这不绕回去了嘛,允许添加 Fruit 就相当于允许把 Orange 添加进 List<Mango>,这正是要避免的情况。

上面的代码用 List.add() 做例子,说明设置了实例化上界后就不能添加元素了。但是编译器可没有那么智能,鬼知道你接收一个参数后到底是添加还是干什么。广义来说,「添加」一词的意思是「传入参数」,所以编译器真正的行为是:一旦设置实例化上界,所有含有这个泛型的参数的方法就不允许调用了

有刨根问底者提出了一个棘手的质疑,都是容器,为什么数组就允许子类赋值给父类? 比如下面的代码:

Fruit[] fruits = new Mango[10]; // OK
fruits[0] = new Orange();       // 编译通过,运行时崩溃

首先声明,第二行代码虽然可以编译通过,但会在运行时崩溃,橘子还是不允许插到芒果数组里。至于第一行代码为什么执行,这要牵扯到泛型的类型擦出,后面会讲。

知道了实例化上界的原理,再看看使用场景。一般的代码中我们不会给自己找麻烦,非要把一个 List<Child> 赋值给 List<Parent>,除非... List<Child> 不可控。什么时候数据是未知的呢?当然是形参啦。比如我们有一个函数,计算总字符个数:

int totalLength(List<CharSequence> chars) {
  int total = 0;
  for (CharSequence cs : chars)
    total += cs.length()
  return total;
}

这么写的话,是无法把 List<String> 作为参数传进来计算的,此时就可以使用上界:int totalLength(List<? extends CharSequence> chars)

所谓「上界」就是指,泛型的类型最多是 XXX,或者是它的子类。

实例化下界/逆变

把上面的案例倒过来写:

List<Mango> mangos = new ArrayList<Fruit>(); // 编译错误

当然无法编译,比较符合直觉。但有这样一个反直觉的使用场景:我不关心它里面具体是什么,我只想添加一些东西,而这些东西保证和里面已有的那些,属于同一个大类。这是一个和实例化上界完全相反的需求:

  • 实例化上界希望取值,但不会更改。
  • 实例化下界希望更改内部的值,但不会读取。

实例化下界语法如下:

List<? super Mango> mangos = new ArrayList<Fruit>();
mangos.add(new Mango()); // OK
Fruit f = mangos.get(0); // 无法编译
Mango m = mangos.get(0); // 无法编译

实例化下界也称为「逆变」 (contravariant),逆变只能修改数据,不能读取数据。这有什么用呢?和上界的使用场景差不多,只不过也是相反的。例如想把一个对象添加到集合中,而这个集合是外部传进来的:

void addToList(List<? super CharSequence> list, String s) {
  list.add(s);
}

此时如果不加实例化下界就无法编译了。

所谓「下界」就是指,泛型的类型最多是 XXX,或者是它的父类。 与上界不同,父类是有顶级限制的,那就是 Object,所以实例化下界的泛型不是完全不能取值,而是所有返回这个泛型的方法只能取 Object,比如这样:

List<? super Mango> mangos = new ArrayList<Fruit>();
Object o = mangos.get(0); // OK

泛型的实例化下界不能和数组类比了,数组不支持这种操作。

类型擦除

现象

类型擦除指在编译完成后程序运行期间,泛型是不存在的,它只在编译时作为校验使用。类型擦除不是功能,而是一种无奈之举。Java 一开始不支持泛型,后来引入这个功能时为了保证老版本的兼容性,只好擦除相关的东西。除此之外类型擦除也可以提高运行时性能。

有人提出,为啥不学学 Android targetSdk 的思路,只对新的字节码使用新的执行规则。因为 Java 并没有设计这么一个字段告诉虚拟机当前是哪个版本的代码。如果贸然加上这么一个字段,对于老的虚拟机来说可能就因为未知指令而崩溃。

前面说,之所以子类的泛型不被视为子类,是为了防止转为父类泛型时放宽限制,我们修改数据时插入了原来不应该支持的类型。而数组在编译时就没有这一限制。原因就是数组有能力在修改元素时判断,这个新的元素是否符合自己的类型限制。而泛型类不同,实例化的泛型类型参数在编译后被擦除,它运行时的类型其实是 Object,就无法再判断新的数据是否合法,干脆一刀切地禁止转换。

既然要擦除,泛型的意义在哪?意义在于在编译期可以帮助我们检查类型,并自动做强制转换。为了做到这一点,对于那些使用到了泛型参数的方法,会创建一个 bridge method,名字相同,只是参数类型变成了 Object,其方法体更简单,就是强转后调用带有类型的方法。比如下面这样:

public class MyWrapper implements Wrapper<String> {
  @Override
  public void set(String data) {}
  
  // 生成的 bridge method 仅作为演示
  public void set(Object data) {
    set((String) data);
  }
}

类型擦除无可避免,那有什么影响?影响就是无法在运行时获取泛型类型参数的信息,具体表现为:

  • 无法获取 Class

    List<String>.class // 错误
    
  • 无法判断类型

    if (strings instanceof List<String>) // 错误,String 被擦除,无法判断是不是一个 String 的 List
    if (strings instanceof List)         // OK
    
  • 无法实例化(与擦除关系不大,主要因为类型不确定)

    new T()   // 错误 实际的类可能没有这样一个构造函数
    new T[10] // 错误 实际的类未知,无法分配内存空间
    
  • 默认情况下不支持协变。

通过反射获取泛型

泛型的类型擦除指的是运行时,但在字节码文件里相关信息还是存在的,这提示我们可以使用反射来读取。

比如这样:

Wrapper<String> wrapper = new Wrapper<>(){};
ParameterizedType type = (ParameterizedType) wrapper.getClass().getGenericSuperclass();
System.out.println(Arrays.toString(type.getActualTypeArguments()));
// 输出 [class java.lang.String]

仔细看看第一行 new Wrapper<>(){} 后面的大括号是什么鬼?别晕了,这和泛型可没有关系,而是 java 的一个便捷语法:创建一个匿名子类,并实例化它。此时 wrapper 的类型并不是 Wrapper,而是它的一个子类。

为什么一定要创建子类? 反射确实可以拿到信息,但反射拿到的是类的信息,而不是对象本身的信息(字节码里怎么可能有对象的信息呢,对象是运行时创建的)。比如对象 Wrapper<String> w,对它进行反射拿到的是 Wrapper.clsss 的信息,显然类型参数的值不包含在内,之前已经多次强调了,泛型类的类型参数在类实例化时才被实例化,类本身是没有参数值的。如果是子类的话,情况就不一样了。上面的匿名子类看得不是很清楚,我们来写一个真正的子类:

public static class StringWrapper extends Wrapper<String> {}

这样是不是清楚了?子类根本不是一个泛型类,当提到 StringWrapper 不需要实例化它也知道内部包裹的一定是 String。也就是说这个类就囊括了其父类的类型参数的值。所以反射获取子类的信息,就可以拿到其父类的类型实参。

Gson / Jackson 等库也是使用这个方法拿到泛型真正的值。回想一下 Gson 反序列化到泛型时,是不是要这么写:

new TypeToken<List<Account>>(){}.getType()

不正是创建了一个匿名子类。而且 Gson 为了防止我们意外写错,把构造函数设置为了 protect。也许之前只是按照 IDE 的提示就这么写,现在应该了解其背后的原因了吧。

Kotlin 的泛型

Kotlin 泛型不难,与 Java 主要是语法差异。它们的本质是一样的,所以只要搞清楚 Java 泛型,Kotlin 泛型也就水到渠成了。

语法对比

泛型声明

// java
class Wrapper<T extends Serializable> {}
// kotlin
class Wrapper<T : Serializable> {}

此处的语义就是「继承」,所以两者都沿用了继承的语法,挺好理解。

但是 Kotlin 尖括号语法只允许指定一个限制,多个限制需要用 where 语法:

// java
class Wrapper<T extends Fruit & Serializable> {}
// kotlin
//                |-------------------------------|
class Wrapper1<T> where T : Fruit, T : Serializable {}

协变逆变

Kotlin 协变/逆变的语法比 Java 更直观,使用 out / in 关键字直接表达出了两者各自的特性:

  • out 协变:允许取出(返回),不允许修改(传参)
  • in 逆变:允许修改(传参),不允许取出(返回)
// java
Shop<? extends Fruit> s = new Shop<Mango>();
Shop<? super Mango> s1 = new Shop<Fruit>();
// kotlin
val s: Shop<out Fruit> = Shop<Mango>()
val s1: Shop<in Mango> = Shop<Fruit>()

除此之外,Kotlin 有一个扩展用法,如果在定义类的时候我们就知道,这个泛型只用于传参或只用于返回,那么就可以直接定义为协变/逆变,这样未来转换赋值的时候就不用一遍遍写了。比如这样:

// kotlin ONLY
class Shop<out T> {}
val s: Shop<Fruit> = Shop<Apple>()     // 不需要再协变了
val s1: Shop<in Apple> = Shop<Fruit>() // 错误,只允许协变

无限制

// Java
Shop<?> s = new Shop<String>();
// kotlin
val s: Shop<*> = Shop<String>()

reified

Java 中有些情况下,希望拿到类型实体,但又不想用匿名子类+反射这种看起来不太靠谱的方式,那有一个更直接的选择:传递 Class 对象。

public T parse(String string, Class<T> clazz) 
new Wrapper<Mongo>().parse("Mongo",Mongo.class)

在 Kotlin 中也支持这种用法。除此之外,Kotlin 有 inline 函数,我们知道内联函数有点像 C 的宏定义,它的函数体在编译时会被直接拷贝到调用处,这个函数在字节码中不存在。既然这样的话,内联函数理应可以拿到泛型类型参数的实例,于是 Kotlin 提供了 reified 关键字。

inline fun <reified T> Any.parse( ): T {
  if (this is T) // 此时 T 可用于类型判断
    return this
  // ...
}

reified 没有增强 JVM,类型擦除依然存在。