Java防止范型擦除的方法
防止范型擦除的方法
前言
java的范型我最喜欢的东西, 他可以把代码变得更精简, 但是也可能会是代码中的一个小陷阱.
如果让我去比喻一下范型在java是一个什么角色.
如果java项目工程是一个小区(爪哇小区), 那各种class 可能就是小区的住户了. 范型就是这个小区的门卫大爷. 他可以拦住那些看起来就是不法分子的入侵者. 但是还是有办法偷偷进入小区, 或者像外卖小哥进入小区.
作为小区的“物业”的我, 应该对范型的行为有所掌握, 至少出了问题, 我有方向可寻.
范型举例
// 这可能是我最常用的范型应用场景了
List<String> list = new HashList<>(15);
范型擦除场景
故名思义, 范型被干掉了.
范型擦出发生在什么阶段呢??
背景设置
这里有两个class
class A {
@Override
public String toString() {
return "A";
}
}
class B {
@Override
public String toString() {
return "B";
}
}
class C {
@Override
public String toString() {
return "C";
}
}
class D {
@Override
public String toString() {
return "D";
}
}
以及2个范型使用的class
public class Test<T> {
T obj;
Test(T obj) {
this.obj = obj;
}
Test() {}
public void setObj(Object obj) {
// 注意该方法的区别
this.obj = (T)obj;
}
@Override
public String toString() {
return obj.toString();
}
}
public class Test2<T> {
T obj;
Test(T obj) {
this.obj = obj;
}
Test2() {}
public void setObj(T obj) {
// 注意该方法的区别
this.obj = obj;
}
@Override
public String toString() {
return obj.toString();
}
}
以及一个测试输出方法
public static void show(String mark, Object o) {
System.out.println("[ " + mark + " ] " + o);
}
场景1-被忽略的范型
注意方法
B b = new B();
A a = new A();
Test<A> test1 = new Test<>();
test1.setObj(b);
show("1", test1);
Test<A> test2 = new Test<>(a);
test2.setObj(b);
show("2", test2);
结果
[ 1 ] B
[ 2 ] B
编译通过了也没有出现了报错, Test<A> 范型我也写了, 但是B还是被set进去.
与预想中 可能会报错的地方(T)obj;
可以看出 这个过程中并未发生 B->A 的强制转换.
(原因: 发现在编译后的文件被擦除, 该处(T)为 (Object), 验证在后面)
场景2-常规操作
B b = new B();
A a = new A();
Test2<A> test3 = new Test2<>();
/// 编译不通过
// test3.setObj(b);
Test2<A> test4 = new Test2<>(a);
/// 编译不通过
// test4.setObj(b);
以上两种情况均出现了编译不通过的情况. 这就是我前言所说看起来就是不法分子的入侵者
那么有办法, 越过这道防线嘛??
答案是可以的(场景3).
场景3-反射操作
B b = new B();
A a = new A();
Test2<A> test6 = new Test2<>(a);
/// 这一步会发现 这样是找不到方法的
/// Exception in thread "main" java.lang.NoSuchMethodException: cn.net.bale.demo.reflect.Test2.setObj(cn.net.bale.demo.reflect.ModifiedClass$A)
// Test2.class.getMethod("setObj", A.class);
Method method = Test2.class.getMethod("setObj", Object.class);
method.setAccessible(true);
method.invoke(test6, b);
show("6", test6);
输出结果
[ 6 ] B
这种方式就非常暴力的用反射把调用 setObj 方法把 b放了进去.
但是我们可以看到, 反射的是getMethod并不是通过 getMethod("setObj", A.class)
(进一步佐证, 范型在编译过后被擦除, 验证在后面)
这种方式太暴力, 一般的场景用不到, 那么什么方式能“委婉”一些呢?
见 场景4
场景4-多次范型擦除
引入工具类
public class ObjectUtil {
private ObjectUtil() {}
/**
* 类型转换
* WARNING: 确保强制转换正确无误方可使用
*
* @param object
* @param <T>
* @return (T) object
*/
@SuppressWarnings("unchecked")
public static <T> T cast(Object object) {
return (T) object;
}
}
使用场景
B b = new B();
A a = new A();
Test2<A> test5 = new Test2<>(a);
test5.setObj(ObjectUtil.cast(b));
show("5", test5);
输出结果
[ 5 ] B
我们可以看到, 没有编译出错, 也没有使用反射. 但是就这样, 一路通行的进入了“爪哇小区”??
这个方法在编译的过程中发生了 2次范型擦除.
- Test2方法 中的T都被擦除变为 Object
- cast()方法中发生了一次范型擦除 变为 return Object.
b 首先进入 cast方法成为Object 伪装自己. 然后两次范型擦除 cast()与setObj()打通, 让b成功被set.
这个过程中, 没有发生我们想象中的, B -> A 发生强制转换报出运行时异常.
(如果没有发生范型擦除, 那么我们设想的“强转报错”的情况就会发生, 但是事实与设想截然相反)
场景5-创建实例
该场景下, 来验证一下, 范型信息会不会存储在object中.
引入输出方法
/**
* 输出 obj.class
*
* @param mark 标记区分
* @param test
*/
public static void showObjClass(String mark, Test test) {
try {
System.out.println("[ " + mark + " ]Test.class: " + test.getClass());
System.out.println("[ " + mark + " ]toString: " + test);
System.out.println("[ " + mark + " ]Test.obj.class: " + test.obj.getClass());
} catch (Exception e) {
System.out.println("[ " + mark + " ]Test.obj.class: " + e.getMessage());
} finally {
System.out.println();
}
}
场景应用
Test test1 = new Test<>("bale");
Test test2 = new Test<>(1);
Test test3 = new Test();
// 强制转换一下
Test test4 = new Test((String) null);
Test.showObjClass("1", test1);
Test.showObjClass("2", test2);
Test.showObjClass("3", test3);
System.out.println("Test1.class == Test2.class: " + test1.getClass().equals(test2.getClass()));
输出结果
[ 1 ]Test.class: class cn.net.bale.demo.reflect.Test
[ 1 ]toString: bale
[ 1 ]Test.obj.class: class java.lang.String
[ 2 ]Test.class: class cn.net.bale.demo.reflect.Test
[ 2 ]toString: 1
[ 2 ]Test.obj.class: class java.lang.Integer
[ 3 ]Test.class: class cn.net.bale.demo.reflect.Test
[ 3 ]Test.obj.class: null
Test1.class == Test2.class: true
从最后的结果看到, Test1.class == Test2.class: true.
Test.class 无论范型是否相同, 但是class都是相同的.
范型擦除验证
通过以上场景, 可以看出, 范型在编译之后的class似乎就不起作用了.
通过反编译来看一下class是否和我们的场景保持一致, 还是在什么地方发生了奇妙的反应, 让范型被擦除.
引入命令
// javap 反编译输出class
javap -v ClassName.class
class 结构参考如下
// todo jvm文档在补ing
验证1-范型类
Test.class 反编译之后筛选出有用的信息
#9 = Utf8 TT;
#19 = Utf8 (TT;)V
// ... 基本参数与常量池省略 ...
{
T obj;
descriptor: Ljava/lang/Object;
flags: (0x0000)
Signature: #9 // TT;
cn.net.bale.demo.reflect.Test2(T);
descriptor: (Ljava/lang/Object;)V
flags: (0x0000)
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: aload_1
6: putfield #2 // Field obj:Ljava/lang/Object;
9: return
// 省略临时变量区
0 10 1 obj TT;
Signature: #19 // (TT;)V
// 省略 无参数构造器
public void setObj(T);
descriptor: (Ljava/lang/Object;)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: putfield #2 // Field obj:Ljava/lang/Object;
5: return
LineNumberTable:
line 19: 0
line 20: 5
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this Lcn/net/bale/demo/reflect/Test2;
0 6 1 obj Ljava/lang/Object;
LocalVariableTypeTable:
Start Length Slot Name Signature
0 6 0 this Lcn/net/bale/demo/reflect/Test2<TT;>;
0 6 1 obj TT;
Signature: #19 // (TT;)V
// 省略 toString方法
}
Signature: #24 // <T:Ljava/lang/Object;>Ljava/lang/Object;
SourceFile: "Test2.java"
我们通过反编译结果可以看到 所有范型出现的地方 都是 Ljava/lang/Object;
T obj;
descriptor: Ljava/lang/Object; // Object属性
flags: (0x0000)
Signature: #9 // TT;
cn.net.bale.demo.reflect.Test2(T);
descriptor: (Ljava/lang/Object;)V // Object参数无返回值的构造器方法
Signature: #19 // (TT;)V
public void setObj(T);
descriptor: (Ljava/lang/Object;)V // Object参数无返回值的setObject方法
Signature: #19 // (TT;)V
可以看出, 编译后的结果, 所有范型的位置都被Object擦除
验证2-调用范型
引入class用于反编译观察Test<T>的调用情况
public class NewClass {
public static void main(String[] args) {
Test test1 = new Test<>("bale");
Test test2 = new Test<>(1);
Test test3 = new Test();
}
}
反编并省略无关紧要的信息
// 省略基础信息以及常量池
{
// 省略构造器
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=4, args_size=1
0: new #2 // class cn/net/bale/demo/reflect/Test
3: dup
4: ldc #3 // String bale
6: invokespecial #4 // Method cn/net/bale/demo/reflect/Test."<init>":(Ljava/lang/Object;)V
9: astore_1
10: new #2 // class cn/net/bale/demo/reflect/Test
13: dup
14: iconst_1
15: invokestatic #5 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
18: invokespecial #4 // Method cn/net/bale/demo/reflect/Test."<init>":(Ljava/lang/Object;)V
21: astore_2
22: new #2 // class cn/net/bale/demo/reflect/Test
25: dup
26: invokespecial #6 // Method cn/net/bale/demo/reflect/Test."<init>":()V
29: astore_3
30: return
// ...省略...
}
SourceFile: "NewClass.java"
重点关注内容如下
// 调用1, 直接调用 Test构造器, 参数为Object
6: invokespecial #4 // Method cn/net/bale/demo/reflect/Test."<init>":(Ljava/lang/Object;)V
// 调用 valueOf 将数字包装成包装类
15: invokestatic #5 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
// 调用2, 直接调用 Test构造器, 参数为Object
18: invokespecial #4 // Method cn/net/bale/demo/reflect/Test."<init>":(Ljava/lang/Object;)V
可以看到调用方法使用范型的部分也都被擦除为Object
(感兴趣的同学可以观察 场景4cast()方法的范型擦除情况, 不过也都大同小异)
范型防止擦除的应用
通过以上的场景观察, 以及反编译验证可以了解到范型在编译之后会被擦除, 那如和防止这种情况的发生呢?
换一种说法, 范型我们如何去使用它, 就目前来看, 范型仅仅做到类型限制的作用, 如何让范型变得很灵活呢??
(要不我凭什么喜欢范型)
如果只做到这一步, 岂不是直接用Object也没什么问题, 只要开发的时候注释写好一些不就可以了??
不是这样的的, 见 应用1
应用1-extends指定擦除类型
使用 extend 关键字来指定擦除类
在不使用extends的时候, 默认使用 Object 作为擦除类(毕竟 超类是万能的)
引入class
class C {
}
public class Test3<T extends C> {
T obj;
}
反编译结果
// 省略基础信息和常量池
{
T obj;
descriptor: Lcn/net/bale/demo/reflect/C;
flags: (0x0000)
Signature: #7 // TT;
// 省略构造器
}
Signature: #17 // <T:Lcn/net/bale/demo/reflect/C;>Ljava/lang/Object;
SourceFile: "Test3.java"
结果很明显, extends 关键字之后的范型擦除的位置不在是 Ljava/lang/Object; 而变为指定的 C
应用2-extend保存范型
!!! 该处extends 不同于 应用1
此处为class的 extend继承
此处应用场景最为广泛且实用
应用如下
public class ExtendTest extends Test<C>{
}
此处通过 extends Test<C> 将范型的信息以 标签的形式存储在 ExtendTest中
那么如何使用它?
在普通的情况下 Who extends Test<C> 那么在Who 中一定就知道C是谁. 如何灵活使用这个关系呢?
引入帮助接口
package cn.net.bale.demo.reflect;
import cn.net.bale.demo.util.ObjectUtil;
import java.lang.reflect.ParameterizedType;
/**
* TClass, 用于辅助范型获取 class
*
* @author bale 2019-06-11
*/
public interface Clazz<T> {
/**
* 获取 ParameterizedType 辅助获取 T class
*
* @return ParameterizedType
*/
default ParameterizedType getParameterizedType() {
return ObjectUtil.cast(getClass().getGenericSuperclass());
}
/**
* 获取 T class
*
* @return Class<T>
*/
default Class<T> getTClass() {
// [0]为<T> 复杂情况, 按需求改写
return ObjectUtil.cast(getParameterizedType().getActualTypeArguments()[0]);
}
}
假设一个场景, 对mongoTemplate 二次封装, 每一个mongoCollection都对应一个 class entity
这样我们可以通过 class entity的属性名,生成通用的查询方法
代码如下
引入辅助注解
/**
* Mongo json集(表)
*
* @author wentao.liu01@hand-china.com 2019-05-22
*/
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface MongoCollection {
String name() default "";
}
public abstract class BaseMongoRepositoryImpl<T> implements Clazz<T> {
// 这里可以根据情况改写 spring 中可以 @Autowired
private final MongoTemplate mongoTemplate;
/**
* 获取 mongo 集合 name
*
* @return String
*/
protected String getCollectionName() {
Class<T> tClass = getTClass();
MongoCollection mongoCollection = tClass.getAnnotation(MongoCollection.class);
if (Objects.nonNull(mongoCollection)) {
return mongoCollection.name();
}
return null;
}
/**
* mongo find
* (封装一个最简单的find方法, 更多的黑科技可以在这里使用)
* @param query
* @return
*/
public List<T> find(final Query query) {
List<T> list = this.mongoTemplate.find(query, getTClass(), getCollectionName());
setMulFieldFunction(list);
return list;
}
}
对应接口
public interface BaseMongoRepositoryI<T> {
List<T> find(final Query query);
}
使用情况:
entity:
@MongoCollection(name = "集合名称")
public class ClassName {
// ...
}
impl:
public class Impl extends BaseMongoRepositoryImpl<ClassName> implements BaseMongoRepository {
}
使用:
Impl impl = 想办法获取到 Impl
Query query= new Query();
impl.find(Query)
这里已经实现最简单的版本, 让mongoTempalte 自带collectionName, 而不用每次查询都手动指定collectionName
还有更多的功能, 比如多语言, 根据属性自动生成查询方法等通用的功能, 来方便接下来的开发.
总结
范型是一个好东西, 了解范型的使用与擦除, 那么在编写通用的代码的时候是非常有意义的.
并且可以极大的缩短代码的行数, 让代码更有效.