美文网首页Android技术知识Android开发
Java | 关于泛型能问的都在这里了(含Kotlin)

Java | 关于泛型能问的都在这里了(含Kotlin)

作者: 彭旭锐 | 来源:发表于2020-08-20 02:33 被阅读0次

    前言

    • 泛型(Generic Type)无论在哪一门语言里,都是最难语法的存在,细节之繁杂、理解之困难,令人切齿;
    • 在这个系列里,我将总结Java & Kotlin中泛型的知识点,带你从语法 & 原理全面理解泛型。追求简单易懂又不失深度,如果能帮上忙,请务必点赞加关注!
    • 首先,尝试回答这些面试中容易出现的问题:
    1、下列代码中,编译出错的是:
    public class MyClass<T> {
        private T t0; // 0
        private static T t1; // 1 
        private T func0(T t) { return t; } // 2
        private static T func1(T t) { return t; } // 3
        private static <T> T func2(T t) { return t; } // 4
    }
    2、泛型的存在是用来解决什么问题?
    3、请说明泛型的原理,什么是泛型擦除机制,具体是怎样实现的?
    

    延伸文章


    目录

    1. 泛型基础

    • 问:什么是泛型?
      答:在定义类、接口和方法时,可以附带类型参数,使其变成泛型类、泛型接口和泛型方法。与非泛型代码相比,使用泛型有三大优点:更健壮(在编译时进行更强的类型检查)、更简洁(消除强转,编译后自动会增加强转)、更通用(代码可适用于多种类型)

    • 问:什么是类型擦除机制?
      答:泛型本质上是 Javac 编译器的一颗语法糖,这是因为:泛型是 JDK1.5 中引进的新特性,为了向下兼容,Java 虚拟机和 Class 文件并没有提供泛型的支持,而是让编译器擦除Code 属性中所有的泛型信息,需要注意的是,泛型信息会保留在类常量池的属性中。

    • 问:类型擦除的具体步骤?
      答:类型擦除发生在编译时,具体分为以下 3 个步骤:

      • 1:擦除所有类型参数信息,如果类型参数是有界的,则将每个参数替换为其第一个边界;如果类型参数是无界的,则将其替换为 Object
      • 2:(必要时)插入类型转换,以保持类型安全
      • 3:(必要时)生成桥接方法以在子类中保留多态性

      举个例子:

    源码:
    public class Parent<T> {
        public void func(T t){
        }
    }
    
    public class Child<T extends Number> extends Parent<T> {
        public T get() {
            return null;
        }
        public void func(T t){
        }
    }
    
    void test(){
        Child<Integer> child = new Child<>();
        Integer i = child.get();
    }
    ---------------------------------------------------------
    字节码:
    public class Parent {
        public void func(Object t){
        }
    }
    
    public class Child extends Parent {
        public Number get() {
            return null;
        }
        public void func(Number t) {
        }
        // 桥方法 - 暂略
    }
    
    void test() {
        Child<Integer> child = new Child();
        // 插入强制类型转换
        Integer i = (Integer) child.get();
    }
    

    步骤1:Parent 中的类型参数 T 被擦除为 Object,而 Child 中的类型参数 T 被擦除为 Number;
    步骤2:child.get(); 插入了强制类型转换
    步骤3:为什么子类中需要增加桥方法呢?可以这么理解:假如没有桥方法,下列代码调用的是子类还是父类方法:

    Parent<Integer> child = new Child<>();
    Parent<Integer> parent = new Parent<>();
            
    child.func(1); // Parent#func(); 若不理解,可以阅读延伸文章《Java | 深入理解方法调用的本质(含重载与重写区别)
    parent.func(1); // Parent#func(); 
    

    很明显,这里调用的都是父类的方法,这样就失去了多态性。因此,才需要在泛型子类中添加桥方法:

    public class Child extends Parent {
        public Number get() {
            return null;
        }
        // 桥方法 - synthetic
        public void func(Object t){
            func((String)t);
        }
        public void func(Number t) {
        }
    }
    
    • 问:为什么擦除后,反编译还是看到类型参数 T ?
    反编译Parent.class,可以看到 T ,不是已经擦除了吗?
    
    public class Parent<T> {
        public Parent() {
        }
    
        public void func(T t) {
        }
    }
    

    答:泛型中所谓的类型擦除,其实只是擦除Code 属性中的泛型信息,在类常量池属性中其实还保留着泛型信息,这也是在运行时可以反射获取泛型信息的根本依据。具体来说:
    Signature属性、LocalVariableTypeTable属性

    Editting...

    • 问:泛型的限制 & 类型擦除会带来什么影响?
      由于类型擦除的影响,在运行期是不清楚类型实参的实际类型的。为了避免程序的运行结果与程序员语义不一致的情况,泛型在使用上存在一些限制。好处是类型擦除不会为每种参数化类型创建新的类,因此泛型不会增大内存消耗。
    泛型的限制

    2. Kotlin的实化类型参数

    前面我们提到,由于类型擦除的影响,在运行期是不清楚类型实参的实际类型的。例如下面的代码是不合法的,因为 T 并不是一个真正的类型,而仅仅是一个符号:

    在这个函数里,我们传入一个List,企图从中过滤出 T 类型的元素:
    
    Java:
    <T> List<T> filter(List list) {
        List<T> result = new ArrayList<>();
        for (Object e : list) {
            if (e instanceof T) { // compiler error
                result.add(e);
            }
        }
        return result;
    }
    ---------------------------------------------------
    Kotlin:
    fun <T> filter(list: List<*>): List<T> {
        val result = ArrayList<T>()
        for (e in list) {
            if (e is T) { // cannot check for instance of erased type: T
                result.add(e)
            }
        }
        return result
    }
    

    Kotlin中,有一种方法可以突破这种限制,即:带实化类型参数的内联函数

    Kotlin:
    inline fun <reified T> filter(list: List<*>): List<T> {
        val result = ArrayList<T>()
        for (e in list) {
            if (e is T) {
                result.add(e)
            }
        }
        return result
    }
    

    关键在于inlinereified,这两者的语义是:

    • inline(内联函数):Kotlin编译器将内联函数的字节码插入到每一次调用方法的地方
    • reified(实化类型参数):在插入的字节码中,使用类型实参的确切类型代替类型实参

    规则很好理解,对吧。很明显,当发生方法内联时,方法体字节码就变成了:

    调用:
    val list = listOf("", 1, false)
    val strList = filter<String>(list)
    ---------------------------------------------------
    内联后:
    val result = ArrayList<String>()
    for (e in list) {
        if (e is String) {
            result.add(e)
        }
    }
    

    需要注意的是,内联函数整个方法体字节码会被插入到调用位置,因此控制内联函数体的大小。如果函数体过大,应该将不依赖于T的代码抽取到单独的非内联函数中。

    注意,无法从 Java 代码里调用带实化类型参数的内联函数

    实化类型参数的另一个妙用是代替Class对象引用,例如:

    fun Context.startActivity(clazz: Class<*>) {
        Intent(this, clazz).apply {
            startActivity(this)
        }
    }
    
    inline fun <reified T> Context.startActivity() {
        Intent(this, T::class.java).apply {
            startActivity(this)
        }
    }
    
    调用方:
    context.startActivity(MainActivity::class.java)
    context.startActivity<MainActivity>() // 第二种方式会简化一些
    

    3. 变型:协变 & 逆变 & 不变

    变型(Variant)描述的是相同原始类型的不同参数化类型之间的关系。说起来有点绕,其实就是说:IntegerNumber的子类型,问你List<Integer>是不是List<Number>的子类型?

    变型的种类具体分为三种:协变型 & 逆变型 & 不变型

    • 协变型(covariant):子类型关系被保留
    • 逆变型(contravariant):子类型关系被翻转
    • 不变型(invariant):子类型关系被消除

    在 Java 中,类型参数默认是不变型的,例如:

    List<Number> l1;
    List<Integer> l2 = new ArrayList<>();
    l1 = l2; // compiler error
    

    相比之下,数组是支持协变型的:

    Number[] nums;
    Integer[] ints = new Integer[10]; 
    nums = ints; // OK 协变,子类型关系被保留
    

    那么,当我们需要将List<Integer>类型的对象,赋值给List<Number>类型的引用时,应该怎么做呢?这个时候我们需要限定通配符

    • <? extends> 上界通配符
      要想类型参数支持协变,需要使用上界通配符,例如:
      List<? extends Number> l1;
      List<Integer> l2 = new ArrayList<>();
      l1 = l2; // OK
      
      但是这会引入一个编译时限制:不能调用参数包含类型参数 E 的方法,也不能设置类型参数的字段,简单来说,就是只能访问不能修改(非严格):
      // ArrayList.java
      public boolean add(E e) {
          ...
      }
      
      l1.add(1); // compiler error
      
    • <? super> 下界通配符
      要想类型参数支持逆变,需要使用下界通配符,例如:
      List<? super Integer> l1;
      List<Number> l2 = new ArrayList<>();
      l1 = l2; // OK
      
      同样,这也会引入一个编译时限制,但是与协变相反:不能调用返回值为类型参数的方法,也不能访问类型参数的字段,简单来说,就是只能修改不能访问(非严格):
      // ArrayList.java
      public E get(int index) {
          ...
      }
      
      Integer i = l1.get(0); // compiler error
      
    • <?> 无界通配符
      <?>其实很简单,很多资料其实都解释得过于复杂了。<?> 其实就是 <? extends Object>的缩写,就是这样,没了,例如:
      List<?> l1;
      List<Integer> l2 = new ArrayList<>();
      l1 = l2; // OK
      
      理解了这点,这个问题就很好回答了:
      • 问:List 与 List<?>有什么区别?
        答:List 是原生类型,可以添加或访问元素,不具备编译期安全性,而 List<?> 其实是 List<? extends Object>的缩写,是协变型的(可引出协变型的特点与限制);从语义上,List<?> 表明使用者清楚变量是类型安全的,而不是因为疏忽而使用了原生类型 List。

    泛型代码的设计,应遵循PECS原则(Producer extends Consumer super):

    • 如果只需要获取元素,使用 <? extends T>
    • 如果只需要存储,使用<? super T>

    举例:

    // Collections.java
    public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    }
    

    在 Kotlin 中,变型写法会有些不同,但是语义是完全一样的:

    协变:
    val l0: MutableList<*> 相当于MutableList<out Any?>
    val l1: MutableList<out Number>
    val l2 = ArrayList<Int>()
    l0 = l2 // OK
    l1 = l2 // OK
    ---------------------------------------------------
    逆变:
    val l1: MutableList<in Int>
    val l2 = ArrayList<Number>()
    l1 = l2 // OK
    

    另外,Kotlin 的in & out不仅仅可以用在类型实参上,还可以用在泛型类型声明的类型参数上。其实这是一种简便写法,表示类设计者知道类型参数在整个类上只能协变或逆变,避免在每个使用的地方增加,例如 Kotlin 的List被设计为不可修改的协变型:

    public interface List<out E> : Collection<E> {
        ...
    }
    

    注意:在 Java 中,只支持使用点变型,不支持 Kotlin 类似的声明点变型

    小结一下:


    参考资料

    • 《Kotlin实战》 (第9、10章)—— [俄] Dmitry Jemerov,Svetlana Isakova 著
    • 《Java编程思想》 (第19、20、23章)—— [美] Bruce Eckel 著
    • 《深入理解Java虚拟机(第3版本)》(第10章)—— 周志明 著

    推荐阅读

    感谢喜欢!你的点赞是对我最大的鼓励!欢迎关注彭旭锐的简书!

    相关文章

      网友评论

        本文标题:Java | 关于泛型能问的都在这里了(含Kotlin)

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