泛型擦除到底擦除了啥

2023年 8月 18日 22.3k 0

前言

通过对比其他语言中的泛型机制,简单了解一下 Java 泛型擦除会有什么影响。

Java 泛型擦除

我们知道 Java 通过泛型机制,实现了参数化类型。使用泛型可以写出更通用的代码,Java 集合类就是最好的范例,泛型使我们写出的代码不再依赖具体的类型,甚至不是 Object ,而是更具有约束性的类型,可以说是非常强大的特性。

但是,在我们学习 Java 泛型的时候,所有教材都会提及 Java 泛型擦除这个概念。

Java 泛型是使用擦除实现的。这意味着当你在使用泛型时,任何具体的类型信息都
被擦除了,你唯一知道的就是你在使用一个对象。

坦白说初学者看到这样的解释是很懵逼的,任何具体的类型信息都被擦除了 ?那我用泛型岂不是用了个寂寞。对于没有使用过其他语言中泛型机制的同学,这样的解释让本就对泛型机制一知半解的同学更加迷惑了。总得知道一个东西是什么样子的,才能感受到没有了之后的感觉吧。

当然,在日常开发中随着逐渐使用 Java 集合或者是 RxJava 这类通过泛型机制封装的框架,也开始逐渐感受到了泛型机制的魅力和强大之处,对于类和方法之上声明的各类 , 也不再感到别扭,对于 PECS 机制的理解也逐渐加深。但是,对于泛型还有一个疑问,没有擦除机制的泛型又是什么样的呢?

Dart 泛型

这里以 Dart 泛型为例,来感受一下未经擦除的泛型是什么样的,有何神奇之处。

我们看下面这段代码。

import 'dart:collection';

class Holder {
  HashMap map = HashMap();

  void putValue(T value) {
    print("put a $T");
    map[T.hashCode] = value;
    print(map);
  }

  T getResult() {
    print("getResult Type is $T");
    return map[T.hashCode];
  }
}

class People {}

class Animal {}

void main() {
  Holder demo = Holder();

  demo.putValue(People());
  demo.putValue(Animal());

  People p = demo.getResult();
  print(p);
  Animal m = demo.getResult();
  print(m);
}

可以用 dartpad 直接运行看一下输出结果。

put a People
{32079754: Instance of 'People'}
put a Animal
{32079754: Instance of 'People', 442803939: Instance of 'Animal'}
getResult Type is People
Instance of 'People'
getResult Type is Animal
Instance of 'Animal'
  • 在 Holder 类中创建了 key-value 均为 dynamic 类型的 Map ,意味着这是一个可以存放任何类型数据的 Map。
  • 这里 putValue 和 getResult 两个泛型方法中,可以直接把泛型参数当做普通的类型使用了,T 不在只是一个符号,而是一个具体的类型,输出其类型,甚至获取其 hash 值。
  • 从输出结果可以看到,在泛型方法中获取到的运行时的真实类型。
  • Kotlin 泛型

    Kotlin 作为一种可以兼容 Java 的语言,其泛型的实现也是擦除实现的。我们直接仿照 Dart 的语法去实现相同的逻辑会怎样呢?

    class EasyHolder {
    
        val map: HashMap = HashMap()
    
        fun  putValue(t: T) {
            println(T::class.java) // lint error
            
        }
    
         fun  getResult(): T? {
             T::class // lint error 
            return null
        }
    }
    

    很明显编辑器会报错,提示 Cannot use 'T' as reified type parameter. Use a class instead. 。也就是说这里 T 只是一个符号而已,他并不能代表一个类型,不是一个类。那么有什么办法解决这个问题吗?当然可以,IDE 会提示我们添加 inlinereified 关键字。

    class SuperHolder {
    
        val map: HashMap = HashMap()
    
        inline fun  put(t: T) {
            println(T::class.java)
            map[T::class.java.hashCode()] = t
    
        }
    
        inline fun  getResult(): T {
            return map[T::class.java.hashCode()] as T
        }
    
        fun printAll() {
            println(map)
        }
    }
    

    可以看到这里已经可以把 T 当做一个普通的 Java/Kotlin 类使用了,他保留了自己作为参数的原始类型。我们可以验证一下。

    data class People(val name: String)
    data class Animal(val age: Int)
    
    fun main() {
        val superHolder = SuperHolder()
        superHolder.put(People("mike"))
        superHolder.put(Animal(1))
    
        superHolder.printAll()
    
        val people: People = superHolder.getResult()
        val animal: Animal = superHolder.getResult()
    
    
        println(people)
        println(animal)
    }
    

    output

    class com.generic.People
    class com.generic.Animal
    
    {1637070917=Animal(age=1), 1848402763=People(name=mike)}
    
    People(name=mike)
    Animal(age=1)
    

    通过结果我们可以得出以下结论

    • put 操作时,可以直接获取到类型参数真实的类型。
    • holder 中 map 内存储的也是真实的类型
    • 从 holder 中获取的内容就是想要的内容。

    这里 getResult() 方法在没有声明具体的类型时,他是怎么知道该返回什么类型的呢?这里其实用到了类型推导这个特性。通过 = 左边的定义的类型,相当于强行约束了 = 右边的返回类型。上面 Dart 也是一样的,类型推导真是 YYDS 。

    
    val people: People = superHolder.getResult()
    
    val people: People = superHolder.getResult()
    

    上面两种写法是等价的,而第一种写法更简单。当然,这里相比 dart ,在 Kotlin 中 getResult 方法需要做一次类型转换,这是因为 Map 本身做了擦除。

    reified

    Kotlin 通过提供 reified 关键字可以说是强化了泛型的能力。通过给方法的泛型参数添加 reified 关键字,就可以在运行时获取到泛型参数真实的类型,这样在很多时候可以解放生产力,写出功能更强大的框架,简化代码逻辑。

    我们以上文 通过 hilt 反观依赖注入 中提到的接口注入为例。我们可以通过 reified 提供的便利性,非常方便的实现一个简单的依赖注入框架。

    接口的定义与实现
    interface IVideoPlayer {
        fun play()
        ...
    }
    
    class IVideoPlayerImpl : IVideoPlayer {
        override fun play() {
            Log.d(TAG, "play() called")
        }
        ...
    
    }
    
    接口的注入与获取(单例)
    object InjectHolder {
    
        val map: HashMap = HashMap()
    
        inline fun  put(t: T) {
            if (T::class.java.interfaces.size > 1) {
                throw IllegalArgumentException("${T::class.qualifiedName} implements too many interface")
            }
            for (clazz in T::class.java.interfaces) {
                map[clazz.hashCode()] = t
            }
    
        }
    
        inline fun  getResult(): T? {
            return map[T::class.java.hashCode()] as T
        }
    
        fun printAll() {
            println(map)
        }
    }
    

    这里 put 方法注入的是接口的具体实现,通过接口实现可以获取到具体的接口。因此,就可以通过 map 存储 接口和接口实现的 key-value 键值对。后续使用时,就可以通过接口的定义类获取到接口的实现类。

    应用层使用
    fun main() {
    
        // 这里只是为了方便,实际上接口实现的注入可以在任意位置
        InjectHolder.put(IVideoPlayerImpl())
        InjectHolder.printAll()
    
        val player: IVideoPlayer = InjectHolder.getResult()
        player?.play()
    
    }
    
    

    di.png

    通过上图可以简单理解一下

    • 应用层 App 只依赖抽象的接口定义和 InjectHoder , InjectHolder 聚合所有接口和其实现类的映射关系,对他来说 map 里存储的内容是黑盒的。
    • 接口注入可以非常灵活的实现,InjectHolder.put(xxxImpl) 可以在任何方便的模块里调用,或者在合适的位置统一注入所有需要的接口,甚至是懒加载使用时再进行注入。
    • 而上层的 App 只需要依赖接口的定义即可。需要什么接口,直接从 InjectHolder 获取就好了,类型信息可以通过定义推导出来。只要之前注入过,就一定可以获取到。这样上层 App 就是完全依赖接口,对接口的实现无感知,从而实现了解耦。

    以上思路参考自 Flutter 开发框架 getx,Get 框架是真的强大。

    Java 泛型

    回过头我们再来看 Java 的泛型。

    我们尝试用 Java 实现上面的特性。

    class SimpleHolder {
    
        private static Map holder = new HashMap();
    
        public static  void setValue(T t) {
            System.out.println(T);//
            holder.put(t.hashCode(),t);
        }
    
        public static  T getResult() {
            T t ;
            System.out.println("");
            return null;
        }
    }
    

    可以看到在 Java 泛型方法中,类型参数的类型被擦除之后,无法基于其做任何操纵。

    Java 中泛型使用最广的无疑是集合了,以我们最熟悉的 ArrayList 为例。

    public class ArrayList extends AbstractList
            implements List, RandomAccess, Cloneable, java.io.Serializable
    {
       transient Object[] elementData; // non-private to simplify nested class access
    
        @SuppressWarnings("unchecked")
        E elementData(int index) {
            return (E) elementData[index];
        }
    
        public E get(int index) {
            Objects.checkIndex(index, size);
            return elementData(index);
        }
    
        public E set(int index, E element) {
            Objects.checkIndex(index, size);
            E oldValue = elementData(index);
            elementData[index] = element;
            return oldValue;
        }
    
        ...
    }
    

    通过将类型参数化,ArrayList 在编译期就可以保证这个集合中类型的一致性。放进去的什么类型,取出来的一定是什么类型。但是,这里要注意的时 elementData 也是通过类型强转返回了结果。

    小结

    至此,我们知道 Java 泛型中所谓的泛型擦除到底是把什么信息给抹掉了。至于 Java 泛型这样做的理由,教科书上也解释过了,纯粹了为了做向前兼容。作为开发者,觉得这也是一个痛苦的选择吧。一个生命周期较长的语言,在发展的过程中必然有很多历史包袱,在各类语言百花齐放,各自争相吸取别人优点的时候,老语言势必会显得更笨重,有些特性只能是阉割过的特性。

    编程语言中泛型出现的初衷是通过解耦类或方法与所使用的类型之间的约束,使得类或方法具备最宽泛的表达力。通过对比不同语言中泛型的特点,可以让我们更好的理解泛型,知道在每一个语言中,泛型能做什么,不能做什么。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论