稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

深入浅出Java泛型

温故而知新,可以为师矣!

前面的一篇文章中学习了Kotlin的泛型知识,但总感觉还不够深入,因为一些深入的话题和高级的特性并未有讲清楚。但在继续深入之前还是有必要重温一下Java的泛型知识,这是因为Kotlin是基于JVM的语言,并且与Java关系暧昧,它可以与Java混合使用,可以相互调用,在某种程度上讲Kotlin可以视为Java的一种『方言』。所以,我们先回顾Java的泛型,夯实基础,并弄清楚Java泛型遗留了哪些问题,然后再看看Kotlin是如何解决这些问题的。

基础使用方法

还是要从基本的使用方法来谈起。

泛型(Generics)就是在类或者方法定义的时候并不指定其操作数据的具体类型,而是用一个虚拟的名字<T>代替,类的使用者或者方法的调用在使用时提供具体的类型,以达到类和方法能对所有的类型都能使用的目录。可以把泛型理解为参数化,也就是说定义的时候把其操作的数据类型视为一种参数,由使用者在使用时具体指定(创建对象时或者调用方法时),因此泛型也可以称为参数化类型。有3个地方可以使用泛型,类,接口和方法,接下分别来看一下具体如何使用。

泛型类

泛型类,也即参数化类型的类,是最为常见的一种泛型的使用方式。这些类可以视为元类,它会操作另一个类型,比如存储或者加工,类本身的实现重点在于如何操作,而对于这个『另一个类型』具体是什么,并不关心。这时就可以用泛型,在定义类的时候并不指定具体的类型,而是用一个虚拟的类型来代替,由类的使用者在使用的时候来指定具体的类型:

1
2
3
4
class ArrayList<E> {
  public void add(E e) { ... }
  public E get(int index) { ... }
}

这里ArrayList是一个容器,可以以线性的方式来存储任意其他类型,具体是啥其实ArrayList并不关心,所以这里用泛型,E就是参数化类型,代指某一个类型。使用时需要提供具体的类型,可以Integer,String,或者定义好了的任何一种类型(Class):

1
2
3
4
5
6
7
ArrayList<String> players = new ArrayList<>();
players.add("James");
players.add("Kevin");
System.out.println("#1 is " + players.get(0));
System.out.println("#2 is " + players.get(1));
// #1 is James
// #2 is Kevin

小结 一下,泛型是为了增强代码的复用,定义时用尖括号<>表示的参数化类型Parameterized type,拼接在类名字的后面,使用时再指定具体的类型。并且,当编译器能推断出参数类型时,可以用钻石符号(Diamond operator)<>来省略参数类型名字。

泛型接口

泛型可以用于接口的声明,与类一样,把类型参数化即可:

1
2
3
interface Consumer<T> {
  void consume(T t);
}

泛型方法

除了类和接口,方法也可以使用泛型,把用尖括号表示的参数化类型<T>放在方法的返回类型之前就可以了:

1
2
3
4
public <T> ArrayList<T> fromArrayToList(T[] a) { ... }

String[] names = {"James", "Kevin", "Harden"};
ArrayList<String> players = fromArrayToList(names);

需要注意的是,因为Java的方法必须声明在类里面,但这并不意味着方法的泛型一定要与类的类型参数一致,当然了,方法可以直接使用类的类型参数,也可以自己再定义一个另外的类型参数,注意这是方法自定义的泛型与其所在的类的泛型没啥关系,如:

1
2
3
class ArrayList<E> {
  public <T> ArrayList<T> transfer(E e) { ... }
}

注意,为了可读性方法自定义的泛型最好不要与其所在类使用的泛型一样,比如类用T,方法也用T,虽然这是可以的,因为这个替代类型名字随便取为啥非要弄的容易混淆呢?

多元类型参数

类型参数可以有多个,用不同的代号名字并用逗号隔开就可以了,就比如哈希表:

1
class HashMap<K, V> { ... }

就是一个使用二元类型参数的类。

以上就是泛型的基础使用方法。

理解泛型的本质

通过以上的介绍可以得出泛型的根本目的是加强复用,让类和方法不受类型的限制,可以应用于任何类型,并且是以一种安全的方式,受到编译器的支持。

泛型的优势

如果不用泛型,想要让类或者方法通用,即对任何对象都能生效,那只能把其参数的类型声明为顶层基类Object,然后在某些地方做手动类型转换(type casting)。很明显,这非常容易出错,并且非常的不安全, 一旦某些地方忘记了检查,就会有运行时的类型转换异常(ClassCastException)。

使用了泛型后,编译器会帮助我们对类型对待检查和自动转换,在完成代码复用的同时,又能保证运行时的类型安全,减少运行时的类型转换错误,所以我们应该尽可能多的使用泛型。

命名规范

虽然说参数化类型可以用任何名字,但为了可读性还是要遵从比较流行的规范:

  • T 类型
  • E 集合里面元素的类型
  • K 哈希表,或者其他有键值的键的类型
  • V 哈希表中值的类型
  • N 数字类型
  • S, U, V等多元参数类型时使用

泛型高级特性

指定参数类型的界限

泛型在定义的时候用虚拟的类型表示参数化的类型,使用的时候传入具体的类型,但有些时候需要对可以传入的具体类型做限制,这时可以用类似<T extends Number>来限定可以使用的类型参数的界限(上界),这里的Number可以是任意已知的类型。并且与类的多继承规则一样,这里可以指定多个类型上限,但只能有一个类且要放在最前面后面的只能是接口,用&来连接,如<T extends ClassA & IfaceB & IfaceC>,比如:

1
2
3
4
5
6
7
class Calculator<T extends Number & Runnable & Closeable> {
    private T operand;

    public static <S extends Number & Runnable & Comparable> S plus(S a, S b) {
        //
    }
}

指定泛型中参数型的限制在实际项目中是很有用的,它可以加强代码复用,把一些公共的代码从子类中抽出来,比如像一个列表中的Item有不同的数据类型和不同的布局样式,常规的多态是说让每个子类去实现自己的布局样式,但如果共性太多,这时就可以在创建一个泛型的类或者方法来做,而这个类或者方法就可以指定基类作为泛型类型界限。这样可以加强代码的类型安全,避免调用者传入代码不认识和不能处理的参数类型。

界限通配符来实现协变与逆变

协变与逆变是用来描述对象的继承关系在使用这些对象为类型参数的泛型中的联系。比如说Dog是Animal的子类,那么使用这两个类型为参数的泛型对象之间的关系应该是会么呢?如List<Dog>是否也是List<Animal>的子类?Java中的泛型是不可变的Invariant,即泛型对象之间的关系与它们的类型参数之间的关系是没有联系的,即List<Dog>与List<Animal>之间没关系。

不可变Invariant是为了类型安全,编译器检查泛型类型参数必须严格匹配,但在有些时候会带来极大的不方便,因为面向对象的两大基本特性继承和多态保证了子类对象可以当作其基类使用,换句话说能用Animal的地方,放一个Dog对象应该完全合法。但因为泛型不可变,一个声明为addAll(List<Animal>)的方法,是没有办法传入List<Dog>的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Animal {}
class Dog extends Animal {}
class List<E> {
  private E[] items;
  private int size;
  public void addAll(List<E> b) {
      for (E x : b) {
          items[size++] = x;
      }
  }
  
  public void getAll(List<E> b) {
      for (E e : items) {
          b.add(e);
      }
  }
}

List<Animal> animals = new List<>();
List<Dog> dogs = new List<>();
animals.addAll(dogs); // compile error
dogs.getAll(animals); // compile error

但这其实是很安全的,因为我们把Dog从列表中取出,然后当作Animal使用,这是向上转型(Upcasting)是完全安全的。但因为泛型是不可变的,编译器必须要保证泛型的类型参数必须完全一致,因此会给出编译错误,但这显然不方便,会让泛型的作用大打折扣。再比如Object是所有对象的基类,但是当把Object作为类型参数时,这个泛型并不是其他泛型的父类,如List<String>并不是List<Object>的子类。

实际上这里需要的是协变(Covariance)与逆变(Contravariance),也就是让使用类型参数的泛型具有其类型参数一致的继承关系,就要用到界限通配符(Bounded Wildcards)。一共有三种:

  • 上界进行协变Covariant,参数化类型<? extends T>表示可以是以T为基类的任意子类类型,当然也包括T本身,泛型<S>会变成<? extends T>的子类,如果S是T的子类。
  • 下界进行逆变Contravariant,参数化类型<? super T>表示可以是T或者T的基类类型泛型<B>会变成<? super T>的基类,如果B是T的基类。
  • 无界,参数化类型<?>表示可以是任何类型,可以理解为泛型里的顶层基类(就像Object之于其他对象一样)。

使用界限通配符来修改上述🌰:

1
2
3
4
5
6
7
8
9
10
class List<E> {
  public void addAll(List<? extends E> b) { ... }
  
  public void getAll(List<? super E> b) { ... }
}

List<Animal> animals = new List<>();
List<Dog> dogs = new List<>();
animals.addAll(dogs); // 0 warnings, 0 errors!
dogs.getAll(animals); // 0 warnings, 0 errors!

需要特别注意的是界限通配符解决的问题是协变与逆变,也即让两个泛型之间的关系与其参数类型保持一致,但具体的这一对类型参数仍可以是任何类型。这与前一小节讨论的参数类型界限是完全不同的概念,不是同一码事儿,参数类型界限是限制使用泛型时可以传入的类型的限制。

界限通配符解决的是泛型之间的关系,每当需要进行协变与逆变的时候就需要用到通配符,以让代码更通用更合理。还需要特别注意的界限通配符只能用于方法的参数,大神Joshua Bloch在《Effective Java》中给出的建议通配符要用于方法的输入泛型参数,如果参数是生产者用extends(即从里面读取对象),如果是消费者用super(即往里面写数据)

运行时的泛型擦除

泛型是为了以类型安全的方式实现代码复用,但是在Java 1.5版本时引入的,为了保持向后兼容性,编译器会对泛型的类型信息进行擦除(type erasure),使其变成常规的对象,这样运行时(JVM)就不用处理新增加的类型了,保持了字节码的兼容性。比如List<String>与List<Integer>在运行时都变成了List对象,JVM并不知道它们的参数类型。泛型的类型参数检查,以及类型的转换都是发生在编译时,是编译器做的事情。

泛型擦除带来的一个问题就是泛型不能使用类型判断符(instanceof),以及不能进行强制类型转换,比如这样写是不合法的:

1
2
3
4
// Compile error: Illegal   generic type for instanceof
if (list instanceof List<Dog>) {
  List<Dog> ld = (List<Dog>) list;
}

很显然,反射(Reflect)是完全没有办法用泛型的,因为反射是在运行时,这时泛型都被擦除了。如果非要使用泛型,必须要把其类型参数的Class传入作为参数(也即把T的具体参数的class对象传入如String.class),以此来区分不同的泛型,可以参考泛型工厂方法的实现

Java泛型的问题

泛型不支持基础类型

Java为了效率和兼容性保留了基础数据类型,如int, boolean, float,但它们并不是对象。而泛型的类型参数必须是对象,因此基础类型是不能用在泛型上面的,比如不能用List<int>,而只能用List<Integer>,好在有自动装箱autoboxinng和拆箱unboxing,所以List<Integer>也可以可以直接用于整数类型的。

泛型不支持数组

这里的意思是指不能用泛型去声明数组,比如List<String>[],这是不允许的。(不要搞混混淆了,数组当作泛型的类型参数是完全可以的,如List<int[]>,因为数组是一个类型。)

参考资料

Comments