敲crud代码的时候,想要建一个包含特定key的map,但是这个key所对应的value可能是不一样的类型。因此最原始的解决方案就是给所有类型的value都写一个相应的函数来生成这个map。突发奇想,是否可以写一个函数,通过泛型之类的方法,在函数的参数中指明要生成的类型呢。
原代码:
1 | private Map<String, List<String>> setUpListMap() { |
希望实现的方式
1 | private <T> Map<String, T> setUpMap(Class<T> clazz) { |
这样setUpMap
就能代替setUpArrMap
和setUpStrMap
,如果有新的类需要作为value值生成实例的时候就不用再写一个函数了。
解决方案一:原始类型的反射构造
一开始的想法就是通过传递类的class对象来创造该类的实例,代码如下
1 | private <T> Map<String, T> setUpMap(Class<T> clazz) throws Exception { |
这样的实现方式只适合原始类型以及不实用泛型的自定义类型,但不适合如List这样的嵌套泛型的类型(ParameterizedType)
1 | Map<String, String> stringMap = setUpMap(String.class); // 编译无问题 |
解决方案二:Guava TypeToken
在尝试了第一个方案后发现解决不了这个问题,以为Java不支持这种泛型,但是后来看到fastjson的TypeReference反序列化代码
1 | Map<String, Integer> map = JSON.parseObject(text, new TypeReference<Map<String, Integer>>() {}); |
在这里,fastjson就通过了TypeReference拿到了Map以及它所包含的泛型类型,在构造Map实例的时候就可以通过所获得的这些参数反射构造。
所以如果写的丑一点,我们想要实现的函数可以这样写,也就是通过生成一个目标实例来获取其json格式再用反序列化构造多个同类型的实例
1 | private <T> Map<String, T> setUpMap(String text, TypeReference<T> tp) { |
其实看到这里,就可以想到我们的问题是可解的,各json序列化器在反序列化的必然要拿到类型中的所有嵌套类然后再依次去做实例化。那么我们的问题就是是这一套反序列化中必不可少的一步。然而,fastjson的反序列化对于解决我们的问题还是太重了,我们想要的只是它在构造实例时用的一些方法而不是真的要parseObj。
在进一步探索后,发现了Google的Java工具包Guava就有一个非常好的实现:TypeToken来解决泛型擦拭的问题
在使用了TypeToken之后我们的代码就会变成这样
1 | private <T> Map<String, T> setUpMap(TypeToken<T> typeToken) throws Exception { |
此时,代码就变的简单了许多,和JSON反序列化传入TypeReference似乎还有异曲同工之妙。
可能会有细心的同学发现我们这里用的是ArrayList而不是List,似乎还是有些差距,这是由于List是接口不能直接实例化必须通过实现类构造。但其实这个可以通过我们后期封装,将TypeToken封装在我们自定的一个类比如TypeToken里,然后指定List接口用ArrayList实现,Map接口用HashMap实现等等。
原理分析
虽然已经解决了这个编程问题,但是背后的原理值得深究,最关键的问题在于TypeReference/TypeToken是如何获取到List<Map<String, String>>
这种嵌套类型的各层class的。
在看TypeToken/TypeReference的源代码之前还是先简单介绍一些背景知识(我在查的过程中也顺便补习了一下Java基础知识😓)
有基础的同学可以跳过概念直看源码分析
核心概念
翻译翻译,什么叫类型
类型这个概念在Java中被抽象为一个标记接口Type,这个接口本身除了默认获取类型名称不包含任何方法。
1 | public interface Type { |
而继承它的接口和类们才是我们在代码中真实需要区分的类型。
classDiagram class Type { interface } class GenericArrayType { interface } class ParameterizedType { interface } class WildcardType { interface } class TypeVariable { interface } class Class Type <|-- GenericArrayType Type <|-- ParameterizedType Type <|-- WildcardType Type <|-- TypeVariable Type <|.. Class
Class
(原始/基本类型,也叫raw type
):包含我们平常所指的类、枚举、数组、注解,和基本类型int、float等等- 正例:String, int, String[], int[]
TypeVariable
(类型变量):比如List<T>
中的T,表明该类型为泛型- 正例:
class Group<T extends Student & Man>
中的T
(上界包含Student和Man),类中定义Fieldprivate T person;
那么这个域的类型就是TypeVariable
- 正例:
WildcardType
( 通配符表达式类型):例如- 正例:Map<? extends String, ? super Number> 中的
? extends String
和? super Number
,List<? extends Number>
中的? extends Number
- 正例:Map<? extends String, ? super Number> 中的
List<?> 本身是ParameterizedType,而通过ParameterizedType::getActualTypeArguments可以获取到作为WildcardType声明的?。默认上界(upperBounds)都是Object
ParameterizedType
(参数化类型):就是我们平常所用到带泛型的List、Map- 正例:Map<String, Integer>, List<Integer>, Class<?>, Map.Entry<String, String> (ownerType=Map)
- 反例:Map, List
GenericArrayType
(泛型数组类型):并不是我们工作中所使用的数组String[] 、byte[](这种都属于Class),而是带有泛型的数组- 正例:List<String>[], T[], Map<T,T>[]
- 反例:List[]
那这和我们所要解决的问题有什么关系呢?我们要将一个类型实例化必须首先要知道这个Type的实现类到底是什么。在我们的第一个解决方案中,我们实际上只实现了原始类型(Class)的实例化,但比如List<String>这样的ParameterizedType这样的是无法在方案一中实现的。因此我们引入TypeToken就是为了兼容ParamterizedType,Class这两种类型的实例化。
翻译翻译,什么叫擦拭法泛型
大伙儿都知道Java的泛型是通过擦拭法实现的伪泛型。因为Java在编译期间,所有的泛型信息都会被擦掉,所有的泛型类型都会变为Object,只有在执行的时候在需要转换成特定类型的时候才会进行强转。
简单来说就是(当然实际上可能并没有这么简单)
1 | class Pair<T, K> { |
在编译器眼中几乎等价于
1 | class Pair<Object, Object> { |
因此就会出现一些经典的例子来证实擦拭的现象
1 | List<String> l1 = new ArrayList<>(); |
1 | class A<T extends Number> // is allowed, JVM erases it into Number |
Why super keyword in generics is not allowed at class level?
那这和我们要解决的问题有什么关系呢?正是因为擦拭法的存在解决方案中以传入Class<T>才无法获得T中嵌套类型的信息。在编译器看来不管你传的是Class<Map<Integer, Integer>>还是Class<Map<String, String>>,统统都看作为Object(返回时强转Map),ParameterizedType中的actualTypeArguments信息便会丢失,无法将其创建。
翻译翻译,什么叫匿名内部类
匿名内部类的主要解决的问题是有时候我们想要创建某一个类的子类并重写父类的方法,但是这个子类我们只会使用一次,如果我们按照传统的创建子类的方式去定义会显得过于冗杂,而使用匿名内部类可以免去这些冗杂的代码之间在你的匿名内部类里重写需要的方法即可。
最简单的例子就是启用线程调用Runnable
1 | new Thread(new Runnable(){ |
因此本质上,匿名内部类是一个简化版的子类/实现类。我们可以decompile一下.class字节码来验证这一点
1 | import java.util.*; |
用javac将Test.java
编译成字节码Test.class
和Test$1.class
,再用万能的IDE查看Test$1.class
1 | // Decompiled Test$1.class |
可以非常清楚地看到匿名内部类在编译过程中产生了自己的字节码文件,并且实现了所对应的接口,还拿到了接口里的泛型类型信息。一个小小的匿名类竟然拿到了这么多有用的信息,这为我们获取泛型实现中被擦拭的ParameterizedType信息埋下了伏笔。
注意!:请仔细区分匿名内部类和函数式接口,详情请看二者对比和函数式接口与匿名内部类是同一个东西吗?
哦对了,有些朋友可能喜欢这么创建List(你说的这个朋友是不是你自己)
1 | List<Integer> list = new ArrayList<>(){{add(1); add(2); add(3);}}; |
或者
1 | List<Integer> list = Arrays.asList(1, 2, 3); |
然而这样的写法都是有坑的。第一种写法用了匿名内部类,但是我们知道这个匿名内部类其实是一个子类而不是父类本身,这个实例也是这个子类的实例而不是ArrayList的实例,因此在搭配Spring/Mockito的使用过程中可能会遇到意向不到的情况;第二种写法用了Arrays.asList,乍一看似乎返回了ArrayList实例,但实际上它返回的是java.util.Arrays.ArrayList,是Arrays的静态内部类而不是java.util.ArrayList,这个静态内部类里的数组是被final修饰的,也就是无法实现增加和删除,因此对于Arrays.asList所返回的实例来讲,add()和remove()是会抛UnsupportedOperationException。
因此,个人推荐的懒人写法是
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3)); |
纯正嫡系ArrayList实例,目前还没遇到有啥坑,就是有两次构造还要拷贝,开销比较大。
Fastjson中的TypeReference
相信在使用TypeReference的过程中大家已经发现每次创建的TypeReference都是匿名内部类而不是其类型本身的实例。那么为什么我们在parseObject的时候传入的不是TypeReference的实例而必须是其匿名内部类的实例呢?答案就在于我们之前所提到的,匿名内部类实质上是一个子类,而这个子类在继承其父类的时候同样继承了父类的泛型信息。
比如一个匿名内部类的实例new Pair<String>{};
被创建后,它就会生成一个自己的字节码
1 | class null extends Pair<String> { |
非常清楚的记录了自己的父类是什么类型,嵌套的String
也被记录了下来。这些信息就可以通过getGenericSuperclass()
来获取到。TypeReference就是通过这样的方式来获取我们传入ParameterizedType的。
1 | /** |
可以非常清楚地看到TypeReference中最重要地属性type就是这样得到的。非常有意思的是,TypeReference的所有的构造函数都是protected的,也就是说我们根本无法创建TypeReference的实例,我们只能创建其子类的实例,那么最简单创建其子类的实例的方法就是匿名内部类。
获得到我们想要传入的ParameterizedType的所有信息后,只要递归地去一一进行实例化就可以了,至于parseObject是怎么实现的就不在本篇的讨论范围之内了,因为这还涉及了json的解析。我们倒是可以往下看下Guava是如何递归地解析ParameterizedType所有的嵌套类型并且相应地进行实例化。
TypeToken源码
首先我们看TypeToken的构造函数
1 | // TypeToken.java |
TypeToken继承了TypeCapture,并用其的方法capture()
以匿名内部类的身份获取到参数化类型的信息,这步和TypeReference异曲同工。TypeToken里的runtimeType
和TypeReference的type
是等价的。
接下来我们看TypeToken是如何解析这个runtimeType
,在解决方案二中最重要的一行代码是
1 | static private <T> T createInstance(TypeToken<T> typeToken) throws Exception { |
其中最重要的函数就是getRawType
1 | /** |
然后我们再往里翻getRawTypes
可以找到
1 | private ImmutableSet<Class<? super T>> getRawTypes() { |
可以发现getRawTypes中非常巧妙地使用了TypeVisitor来实现一种类似于DFS的递归方式,这个TypeVisitor可以通过visit不同类型的Type来针对性地采取不同的方法。用官方原话来说就是Based on what a Type is, dispatch it to the corresponding visit method. 并且通过自定义的重写visitTypeVariable(TypeVariable<?> t)
, visitWildcardType(WildcardType t)
等方法我们可以决定什么时候递归。继续细看visit
方法
1 | public final void visit( Type... types){ |
其实可以看出visit就是为了标记访问过的类型以及将相应的类型分配到相应的处理方法。
从源代码里不难看出TypeToken的getRawType和加持TypeReference的JSON.parseObject的目标还是不一样的,对于ArrayList<Map<String, String>> 这样的类型,getRawType其实只取最上层的类型也就是java.util.ArrayList
,而JSON.parseObject(text, new TypeReference<>(){})
则需要递归地去取List,Map,String并且在相应地层级实例化并填入json文本中所对应的值。
就我们的问题而言,实际上我们只需要实例化一个最上层的类型,那么通过getRawType().newInstance()
就可以满足需求。