关于泛型你应该知道的

简介

为什么会存在泛型?我们先从一个简要的例子出发。

List Queue Stack等 M 种数据类型,Sort、Search、Find等 N 种操作,那我们需要实现 List X Sort、Stack X Find 等 M X N 种方法。类型与算法紧密地耦合在一起,这样显然不太合理,于是乎为了解决这种问题,在 1989 年的时候,Musser & Stepanov 第一次提出了 Generic Programming(泛型编程) 的概念。

Generic programming centers aroudn the idea of abstracting from concrete, efficient algorthms to obtain generic algorithms that can be combined with different data representations to produce a wide variety of useful software.

个人认为泛型有两个最重要的特点:

  1. 参数类型化。
  2. 屏蔽掉数据细节,让算法更通用。Programmer 能够更关注算法本身,而不是在算法中处理各种数据细节。

开发语言发展至今,泛型在几乎所有主流语言中都得到了不同程度的支持,无论是 C++、Kotlin、Java、C# 等等语言。但泛型的实现方式与语言的关系非常大,有相当多的细节不同。对于同样的内存区域,很难玩出花样,泛型是架构在语言之上的高级特性。

下面咱们看看 Java/Kotlin 中,泛型有哪些需要实现的地方。

泛型擦除

首先我们看看这样一段代码:

1
Person person = gson.fromJson(json, Person.class)

这里的问题在于,为什么还需要手动传入 Person.class 了?不是已经有前面 Person 的类型了吗?这里衍生出一个名词类型擦除

Java 实现的其实是伪泛型,即在编译的时候将类型类型移除掉,这就是类型擦除。

我们来做这样一个测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Box<T> {
private T data;

public Box(T data) {
this.data = data;
}

public T getData() {
return data;
}
}
System.out.println(new Box<String>().getClass());
System.out.println(new Box<Long>().getClass());

输出的都是 Box,并没有泛型的信息,因为这些类型信息都在编译后被抹除掉了,实际编译生成的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class com.uc.vmate.demo.Box<T> {
public com.uc.vmate.demo.Box(T);
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: aload_1
6: putfield #2 // Field data:Ljava/lang/Object;
9: return

public T getData();
Code:
0: aload_0
1: getfield #2 // Field data:Ljava/lang/Object;
4: areturn
}

从中我们可以看到,并没有什么 T 类型,全部都变成了 Object。那么为什么 Java 要这么做呢?为什么要把已经带上的信息抹除掉?原因就是为了向后兼容,兼容以前的 JVM 版本,为了诸如 ArrayList 能够和 ArrayList 共存这样的 Case。但这也导致了 Java 的泛型算半个畸形产物,是个伪泛型,或者你可以这么想想,泛型类内部不知道自己是什么类型。

Java 泛型 ~= 编译器魔法

简单的步骤如下:

  1. 检查 并获得泛型的实际类型, 然后存到class文件中
  2. 擦除原有类型 , 替换为限定类型(T/E等无限定类型, 用Object替换)
  3. 最后, 调用相关函数将结果强制转换为目标类型

而 C# 没有类型擦除,是真正的泛型。C# 的泛型,将泛型编译成元数据,通过 CLR 运行时,JIT 即时编译,将 IL 代码即时编译成相应类型的特化代码。

C# Template


泛型通配

我们再看看泛型通配的问题,首先我们设想这样一种场景。

1
2
3
4
5
6
List<Fruit> fruitList = new ArrayList<Fruit>();
List<Apple> appleList = new ArrayList<Apple>();

// check this case.
fruitList = appleList;
appleList = fruitList;

上面最后两句会编译通过吗?当然不能编译通过。我们简单设想一下,如果 appleList = fruitList 如果这句话编译没有问题,那么 appleList 里面就能放入 pear,这显然不合理。同样 fruitList = appleList 如果成立的话,也能导致 appleList 里面放入 pear。由此我们可以看到,fruitList 与 appleList 是两个完全没有关系的类。

但有另一个问题就冒出来了,看看代码:

1
2
3
4
5
public void static dumpList(List<Fruit> list) {
for (Fruit fruit: list) {
System.out.println(fruit)
}
}

但我们在实际使用的时候,并不能传入 List< Apple >,List< Banner >,因为两种不是同一个东西。

这里我们先引入协变和逆变的概念。

  • 当A ≦ B时,如果有f(A) ≦ f(B),那么f叫做协变
  • 当A ≦ B时,如果有f(B) ≦ f(A),那么f叫做逆变
  • 如果上面两种关系都不成立则叫做不可变

如果把协变的概念,代入到上面的例子中来看看,如果是协变的,那么我们就能将 List< Apple > 传入进去。反过来如果是逆变的,那么我们就能将 List< Food > 传入其中。

那么 Java 是用什么样的语法糖来表达 协变 和 逆变的了?

? 是 java 中的通配符,用来表征任何类型,List<?> 就可以传入任何类型的 List了。如果是 ? extends ,这就是协变,可以把子类类型的 List 传入。如果是 ? super,这是逆变,可以传入父类类型的 List。

在 Kotlin 中简化了协变和逆变的表达,分别用 out 和 in 来表达。

p.s. java 的数组是天生协变的。

1
2
3
4
5
6
7
8
9
10
11
12
// 不可变
List<Fruit> fruits = new ArrayList<Apple>();// 编译不通过
// 协变
List<? extends Fruit> wildcardFruits = new ArrayList<Apple>();
// 协变->方法的返回值,对返回类型是协变的:Fruit->Apple
Fruit fruit = wildcardFruits.get(0);
// 不可变
List<Apple> apples = new ArrayList<Fruit>();// 编译不通过
// 逆变
List<? super Apple> wildcardApples = new ArrayList<Fruit>();
// 逆变->方法的参数,对输入类型是逆变的:Apple->Fruit
wildcardApples.add(new Apple());

咱们还需要考虑返回值参数哦!这里直接抛出结论,Java 对返回值是支持协变的,否则你想想,在一个申明返回 Fruit 的函数里面,返回一个 Apple 是错误的话,那是多么可怕的事情。另一方面,对于参数的类型,Java 是通过方法重载来实现的。直接看看下面的例子。

1
2
3
4
5
6
7
8
9
public List<? extends Fruit> get() {
return new ArrayList<Apple>();
}

public void desc(Object item) {
}

public void desc(String item) {
}

参考文献

  1. https://www.imbajin.com/2018-09-28-%E6%BA%90%E7%A0%81%E9%98%85%E8%AF%BB%E4%B9%8B%E6%B3%9B%E5%9E%8B/
  2. http://blog.zhaojie.me/2010/05/why-java-sucks-and-csharp-rocks-4-generics.html
  3. https://yq.aliyun.com/articles/640124
  4. https://zhanjindong.com/2014/09/21/understand-covariance-and-contravariance-again

文档信息