Java 泛型的见解

作者: 北邙山之光 | 来源:发表于2021-02-22 20:05 被阅读0次

    前言

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

    相关文章

      网友评论

        本文标题:Java 泛型的见解

        本文链接:https://www.haomeiwen.com/subject/pnpkfltx.html