目录

Java 泛型的见解

前言

写 RecyclerView 的 Adapter 时,感觉到了泛型理解不够深刻,也不够熟练,看了几天的泛型文档

https://docs.oracle.com/javase/tutorial/java/generics/index.html

下面的总结均是对于文档的学习和一些代码示例的运行。

为什么要使用泛型

代码复用

通常的代码复用是提取一个公共参数的函数,函数中的参数传的是各种不同的值。泛型也是类似,只不过泛型可以用于定义 class、interface、method 等等,泛型传递的是不同的 type。

减少强转

如果没有泛型,很多时候我们都需要类型强转。但是,使用了泛型以后,因为编译时有 type check,所以自然可以不用写类型强转的代码。

泛型类、接口、方法的声明

在我们声明泛型的时候经常带着绑定的类型参数,比如 List<E> 等等,这里的 E 就是类型参数,类型参数有一些 约定(conventions),如下:

  • E - Element (used extensively by the Java Collections Framework)
  • K - Key
  • N - Number
  • T - Type
  • V - Value
  • S,U,V etc. - 2nd, 3rd, 4th types

但是好像平时写的时候,也很少有人遵守。比如我就用过一个 VH 的类型参数,只是因为继承了一个叫做 ViewHolder 的类,我的使用就是个反例···

声明没什么好说的,思路清晰即可。

绑定的类型参数有一个点,支持多绑定(Multiple Bounds)

T extends A & B & C

原始类型(Raw Type)

原始类型在 JDK 5.0 的时候是合法的,但是现在我们使用原始类型编译器均会报 warning,Raw use of parameterized class 'ItemViewBinder'

所以原始类型是不建议使用的,但是我们的各种泛型轮子中可能充斥着 warning,虽然运行时 可能 不存在问题,但是其实是不规范的。

因为使用原始类型绕过了编译器的类型检查,而让你的代码变得不再安全。比如下面这段被各种泛型文章用烂了的代码

List names = new ArrayList(); // warning: raw type!
names.add("John");
names.add("Mary");
names.add(Boolean.FALSE); // not a compilation error!
for (Object o : names) {
    String name = (String) o;
    System.out.println(name);
} // throws ClassCastException!
  //    java.lang.Boolean cannot be cast to java.lang.String

上面代码使用了原始类型 List,绕过了编译器的检查,你可以加入任何类型,但是当你取出 List 中的元素时,却完全不知道类型,很容易就会产生 ClassCastException。

泛型的继承和子类型

/img/in-post/generics-subtypeRelationship.gif

可以看到 Integer extends Number,但是 Box<Integer>Box<Number> 却不是继承关系。

看看下面的代码

public static void main(String[] args) {
    Integer[] integers = new Integer[0];
    List<Integer> integerList = new ArrayList<>();
    testGenericInheritance(integerList); // compile error
    testArrayInheritance(integers); // ok
}

private static void testArrayInheritance(Number[] numbers) {}

private static void testGenericInheritance(List<Number> integerList) {}

这也是常说的 java 数组是 协变(covariant) 的,但是这么看泛型就不行了?也不是,通配符(Wildcards) 帮我们完成这件事。

还是上面的代码,改一下

public static void main(String[] args) {
    Integer[] integers = new Integer[0];
    List<Integer> integerList = new ArrayList<>();
    testGenericInheritance(integerList); // ok
}

private static void testGenericInheritance(List<? extends Number> integerList) {}

这样就编译通过了。

但是为什么 List<Integer> 却不是 List<Number> 的子类呢?在语义层面和数学逻辑看完全是正确的。

可能是害怕这种语义的出现

public static void main(String[] args) {
    List<Integer> integerList = new ArrayList<>();
    List<Number> numberList = integerList;
    numberList.add(0f);
}

如果 List<Integer> 是 List<Number> 的子类,那么我们可以使用 List<Number> 接收 List<Integer>,多态的体现。

这个时候,numberList.add(double) 完全正确,但是 List 确是 Integer,互相矛盾。

类型推断(Type Inference)

看看下面的代码

public static void main(String[] args) {
    Serializable s = pick("d", new ArrayList<String>()); // ok
    String s1 = pick("d", new ArrayList<String>()); // compile error
    List<String> s2 = pick("d", new ArrayList<String>()); // compile error
}

private static <T> T pick(T a1, T a2) {
    return a2;
}

当使用泛型时,编译器会自动帮我们做类型推导,

通配符(Wildcards)

通配符相关的子类型关系如下图:

/img/in-post/generics-wildcardSubtyping.gif

所以当使用通配符时,是存在继承关系的。

上界通配符(Upper Bounded Wildcards)

? extends Type 即为上界通配符

看下面这段代码

public static void main(String[] args) {
    List<? extends Number> numbers = new ArrayList<>();
    List<? extends Number> numbers2 = new ArrayList<>();
    numbers.add(1); // compile error
    numbers.add(new Object()); // compile error
    numbers.add(null); // ok
    numbers2.add(numbers2.get(0)); // compile error
}

一直都有一种思维定式,像代码中的 numbers 应该是存储 Number 以及 Numbers 子类。

但是 add(1) 却编译报错了,add(Object) 也报错了,甚至我创建了和 numbers 一模一样的 numbers2,add(numbers2.get(0)) 也编译报错。

这都是编译器作用的体现,使用了通配符后,List<? extends Number> 在编译器眼中,它的元素类型是 CAP#1,应该是编译器按顺序定的一个值。

所以我们知道了,上界通配符是无法添加任何元素的(null 除外),所以很多文章也说了它是 只读 类型,如果你想随意改动那么直接使用 List<Number>

但是又要记住之前的例子,在 Java 中 List<Number> 和 List<Integer> 和 List<Double> 没任何继承关系,所以如果你想写一段通用逻辑,处理 List<Number> 和 List<Integer> 和 List<Double> 中的 Number 元素,还是逃不开使用通配符。

下界通配符(Lower Bounded Wildcards)

? super Type 即为下界通配符

看下面这段代码

public static void main(String[] args) {
    List<? super Number> numbers = new ArrayList<>();
    List<? super Number> numbers2 = new ArrayList<>();
    numbers.add(1); // ok
    numbers.add(new BigInteger(new byte[]{})); // ok
    numbers.add(new Object()); // compile error
    numbers.add(null); // ok
    numbers2.add(numbers2.get(0)); // compile error
    Number num1 = numbers.get(0); // compile error
    Object num2 = numbers.get(0); // ok
}

使用下界通配符可以 add Number 子类元素,但是 get 读取的时候却只能用 Object 类接收。

无界通配符(unBounded Wildcards)

? 即为无界通配符

List<?>List<Object> 却不相同,List<?> 同样只能添加 null 作为元素

小结

上界通配符通常代表了只读,而下界通配符表示了可写(当然也可读,但是是 Object)。

这里说一说,协变(covariant)逆变(contravariant)

  • 𝑓(⋅)是逆变(contravariant)的,当𝐴≤𝐵时有𝑓(𝐵)≤𝑓(𝐴)成立;
  • 𝑓(⋅)是协变(covariant)的,当𝐴≤𝐵时有𝑓(𝐴)≤𝑓(𝐵)成立;
  • 𝑓(⋅)是不变(invariant)的,当𝐴≤𝐵时上述两个式子均不成立,即𝑓(𝐴)与𝑓(𝐵)相互之间没有继承关系。

所以通过上面的例子,使用通配符后。

上界通配符实现了协变,下界通配符实现了逆变

List<? extends Number> list = new ArrayList<Integer>();
List<? super Number> list = new ArrayList<Object>();

类型擦除和桥方法

首先 Java 的泛型是 编译器(compiler)编译时 帮我们做的严格的类型检查实现的,与之对应的就是 类型擦除(Type Erasure) 和 我们经常说的 伪泛型,因为在运行时,我们声明的类型参数都会被擦除掉。

除此之外,编译器就什么也没有做了么?当然不是,编译器也许还会帮我们生成桥方法。

看这段代码

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }
    
    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }

    @Override
    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

MyNode mn = new MyNode(5);
Node n = mn;            // A raw type - compiler throws an unchecked warning
n.setData("Hello");     
Integer x = mn.data;    // Causes a ClassCastException to be thrown.

这段代码确实有问题,但是是因为 setData 调用了 Node 的 setData(Object data)(类型擦除以后, T 变为 Object) 方法,从而导致 Node.data = String,而 mn 又是 MyNode 类型(extends Node<Integer>),所以 Integer x = mn.data,编译并没有问题,最终运行时报错,报错在了 mn.data 强转 String 上,报错也让人很困惑,不知道发生了什么。且我们以为是重写了 setData 方法,其实不然,直接调用的父类的 setData 方法。

所以,为了解决这个问题,编译器会帮我们生成桥方法。

通过 javap -v MyNode.class 方式,我们可以看到 MyNode 中居然多了一个 setData(Object) 方法

  public void setData(java.lang.Integer);
    descriptor: (Ljava/lang/Integer;)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: invokespecial #2                  // Method Node.setData:(Ljava/lang/Object;)V
         5: return
      LineNumberTable:
        line 18: 0
        line 19: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   LMyNode;
            0       6     1  data   Ljava/lang/Integer;

  public void setData(java.lang.Object);
    descriptor: (Ljava/lang/Object;)V
    flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: checkcast     #3                  // class java/lang/Integer
         5: invokevirtual #4                  // Method setData:(Ljava/lang/Integer;)V
         8: return
      LineNumberTable:
        line 13: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   LMyNode;

可以看到,编译器帮我们给 MyNode 生成了一个 setData(Object) 方法,从而实现了我们调用 setData(“Hello”) 时,调用的是具体的子类的 setData(Object) 方法而不是父类的方法。同时,setData 方法内部强转类型 Integer,然后调用了 setData(Integer) 方法。

虽然最终代码还是报错,但是其符合逻辑,报错位置也在 setData 中,调用的也是自己的 setData 而不是父类的 setData。

所以很多时候,编译器有着神奇的作用。