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。
泛型的继承和子类型
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)
通配符相关的子类型关系如下图:
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。
所以很多时候,编译器有着神奇的作用。
全文无关
我最近总是接手一些莫名其妙的 bug,而且十分神奇,比如报 NullPointerException,这本是最简单的异常,但是因为我们的编译过程有什么骚操作么?反正没法还原行号,导致我只能猜···更神奇的是,每一行代码都进行了 null 判断,依然 crash,且无论是自己还是测试都无法复现···
当你们遇到这种 bug 的时候又该怎么改呢?