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 的时候又该怎么改呢?

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容