换个姿势,十分钟拿下Java/Kotlin泛型
0x1、引言
解完BUG,又有时间摸鱼学点东西了,最近在复习Kotlin,跟着朱涛的 《Kotlin 编程第一课》查缺补漏。
看到泛型这一章时,想起之前面一家小公司时的面试题:
说下你对泛型协变和逆变的理解?
读者可以试试在不查资料的情况下能否答得上来?
究其根源,对概念名词的理解浮于表面,模棱两可,知道有这个东西,但本质是什么?为啥要用?怎么用?并没有二次加工形成自己的思考和理解,所以印象不深刻。加之 泛型平时开发用的不多,记和忆 两个要素都没做到,久了自然会忘。
而网上关于泛型讲解的文章大都千篇一律:集合 存取元素类型异常引出泛型,不变、协变、逆变、型变一把梭,什么能读不能写,能写不能读,读者看完好像懂了,又好像没懂,这很正常,毕竟作者自己都可能弄不明白,2333。就问你一句:泛型只能用在集合上吗?
综上原因,有了这篇文章,本节换个角度,从根上理解泛型,少说废话掐要点,这次一定拿下Java/Kotlin泛型。
0x2、泛型到底是什么?
直接说结论:
泛型的本质 → 类型参数化 → 要操作的数据类型 可以通过 参数的形式来指定。
说人话:把数据类型变成参数。
难理解?类比 函数/方法,定义时指定 参数类型(形参),调用时传入 具体参数实例(实参):
泛型 也是如此,定义时指定 数据类型(形参),调用时传入 具体数据类型(实参):
非常相似,只是 数据类型 的定义和传递都是通过 <>,而不是(),那 泛型的作用是什么呢?直接说结论:
语法糖 → Java制定了一套规则 (书写规范),按照这套规则编写代码,编译器会在生成代码时自动完成类型转换,避免手动编写代码引起的类型转换问题。
有点抽象?没关系,来个直观例子对照学习,以解析接口返回数据伪代码为例,先不使用泛型:
public class Article {
public void parseJson(String content) {
System.out.println(content + ":Json → Article");
}
}
public class Banner {
public void parseJson(String content) {
System.out.println(content + ":Json → Banner");
}
}
public class HotKey {
public void parseJson(String content) {
System.out.println(content + ":Json → HotKey");
}
}
public class Response {
private Object entity;
public Response(Object entity) { this.entity = entity; }
public void parseResponse(String response) {
// 手动编写:类型判定 + 强转
if (entity instanceof Article) {
((Article) entity).parseJson(response);
} else if (entity instanceof Banner) {
((Banner) entity).parseJson(response);
} else if (entity instanceof HotKey) {
((HotKey) entity).parseJson(response);
}
}
}
可以看到,为了避免 类型转换异常,需要手动进行 类型判定和强转。毕竟,不判定直接强转,来个null直接就崩了。
面向对象思想,可以抽个父类Entity给Article、Banner、HotKey继承,Response可以少写个强转:
public class Response {
private Entity entity;
public Response(Entity entity) {
this.entity = entity;
}
public void parseResponse(String response) {
if (entity instanceof Article
|| entity instanceof Banner
|| entity instanceof HotKey) {
entity.parseJson(response);
}
}
}
代码稍微清爽了一点,但依旧存在隐患,增删解析实体类型,都要手动修改此处代码。而人是容易犯错的,漏掉类型不自知很正常,编译器也不报错,可能要到 运行时才发现问题。
能否 对数据类型进行范围限定,传入范围外的类型,编译器直接报错,在 编译期 就发现问题呢?
可以,用好 泛型 这枚语法糖,能帮我们提前规避这种风险,稍微改动下代码:
public class Response<T extends Entity> {
private final T entity;
public Response(T entity) {
this.entity = entity;
}
public void parseResponse(String response) {
// 预先知道类型是Entity或其子类,无需类型判断即可放心调用方法
if (entity != null) entity.parseJson(response);
}
}
// 调用处:
public class ResponseTest {
public static void main(String[] args) {
new Response<Article>(new Article()).parseResponse("请求文章接口");
new Response<Banner>(new Banner()).parseResponse("请求Banner接口");
new Response<HotKey>(new HotKey()).parseResponse("请求热词接口");
}
}
此时,修改实体类 (删除、修改继承关系、传入非Enitiy及其子类) 编译器直接报错,而增加实体类,直接传类型参数:
new Response<UserInfo>(new UserInfo()).parseResponse("请求");
增删实体类均无需修改 parseResponse()
方法,还避免了 运行时由于对象类型不匹配引发的异常。
泛型这种 把数据类型的确定 推迟到 创建对象或调用方法时 的玩法跟 占位符
很像。
好处也很明显,逻辑复用、灵活性强,而所谓的泛型边界、不变、型变等,就是围绕着这个 "占位符" 制定的一系列 语法规则 而已。所以,泛型不是非用不可!!!
- 用了 → 可以少写一些代码,可以在编译期提前发现类型转换问题;
- 不用 → 得多写一些类型判定和强转代码,可能存在类型转换问题;
0x3、泛型规则
了解完泛型是啥?有什么用?接着来理解它的规则,即 指定目标数据类型 的一些语法。
① 边界限制
就上面例子里的 <T extends Entity
>,要求传入的泛型参数必须是 Entity类或它的子类,又称 泛型上界。
限制上界的好处:可以直接 调用父类或父接口的方法,如上面直接调 entity.parseJson();
Tips:Kotlin中用冒号:代替extends → <
T:Entity
>
② 不变、协变、逆变
泛型是不变的!这句话怎么理解?看下这段代码:
咋回事?Entity和Article不是有 继承关系 吗?为啥不能互相替代?因为能替换的话 取 的时候有问题:
为了避免这两个问题,编译器直接认为 Response<Entity> 和 Response<Article> 不存在继承关系,无法相互替代
,即 只能识别具体的类型
,这就是 泛型的不变性
。
而在有些场景,这样的特性会给我们带来一些不便,可以通过 型变
来 扩展参数的类型范围
,有下面两种形式:
① 协变:父子关系一致 → 子类也可以作为参数传进来 → <? extends Entity> → 上界通配符
Tips:Kotlin中使用 out 关键字表示协变 → Response<out Entity>
② 逆变:父子关系颠倒 → 父类也可以作为参数传进来 → <? super Article> → 下界通配符
Tips:Kotlin中使用 in 关键字表示逆变 → Response<in Entity>
可以看到,型变 虽然拓展了参数的类型范围,但也导致 不能按照泛型类型读取元素。
除此之外,还有一个 无限定通配符<?>,等价于 <? extends Object>,不关心泛型的具体类型时,可以用它。
Tips:Kotlin中使用 星投影<*> 表示,等价于 <out Any>
再补充一点,根据 定义型变的位置,分为 使用处型变 (对象定义)
和 声明处型变 (类定义)
。
Java只有使用处型变 (例子就是),而Kotlin两种都有,示例如下:
// Kotlin 使用处型变
fun printArticleResponse(response: Response<in Article>) {
response.parseResponse("开始请求接口")
}
// Kotlin 声明处型变
class KtResponse<in T>(private val entity: T){
fun getOut(): T = t
}
③ 何时用协变?何时用逆变?
看到这里,读者可能会疑惑:使用两种 型变 不是为了 扩展参数的类型范围 么?
让子类也能传 → 协变(extends out),让父类也能传 → 逆变(super in)
难不成还有更详细的规则?是的!先提一嘴介个:
- 向上转型 → 子类转换成父类 (隐式),安全,可以访问父类成员;
- 向下转型 → 父类转换成子类 (显式),存在安全隐患,子类可能有一些父类没有的方法;
接着改下例子:
先是 协变 → 能读不能写 (能用父类型去获取数据,不确定具体类型,不能传)
接着是 逆变 → 能写不能读 (能传入子类型,不确定具体类型,不能读,但可以用Object读)
没看懂的话,多看几遍,实在不行,那就背:PECS法则 (Producer Extends,Consumer Super)
- 生产者 → extends/out → 协变 → 对象只作为返回值传出
- 消费者 → super/in → 逆变 → 对象只作为参数传入
Tips:Kotlin官方文档写的 Consumer in, Producer out!,好像更容易理解和记忆~
另外,在某些特殊场景,泛型参数 同时作为参数和返回值,可以使用 @UnsafeVariance
注解来解决 型变冲突,如 Kotlin\Collections.kt 中的:
到此,泛型的规则就讲解完毕了,纸上得来终觉浅,绝知此事要躬行,建议自己写点代码试试水,加深印象,如:
当然阅读源码也是一个很好的巩固方式,Java\Kotlin集合类相关代码大量使用了泛型~
0x4、一些补充
① Java假泛型
和C#等编程语言的泛型不同,Java和Kotlin中的泛型都是 假泛型,原理 → 类型擦除(Type Erasure)
生成Java字节码中是 不包含泛型类型信息的,它只存在于代码编译阶段,进JVM前会被擦除~
写个简单例子验证:
可以看到,此时的 类类型 皆为 Response,那定义的泛型类型都哪去了?
答:被替换成 原始类型,没指定 限定类型 就是 Object,有则为 限定类型。
反编译字节码看看 (安装 bytecode viewer 插件,然后点 View -> Show Bytecode)
可以看到都被替换成 Object,试试加上 泛型上界:
反编译字节码:
可以看到变成了 限定类型 (父类型Entity)。
另外,我们可以通过 反射 的反射绕过Java的假泛型:
到此,你可能还有一个疑问:为什么Java不实现真泛型?
答:向前兼容,使得Java 1.5前未使用泛型类的代码,不用修改仍可以继续正常工作。
详细历史原因讲解可自行查阅:《Java 不能实现真正泛型的原因是什么?》
② Java为什么不支持泛型数组
在Java中,允许把子类数组赋值给父类数组变量,所以下面的代码是可行的:
如果我们往Object数组里放一个Entity实例,编译器提示,但不报错:
但运行时会检查假如数组的对象类型,然后抛出异常:
回到问题,假如 Java支持泛型数组,那下面的代码会怎样?
Response<Article>[] articles = new Response<Article>[10];
Response<Entity>[] entities = articles;
entities[0] = new Response<Banner>();
类型擦除,Article、Entity、Banner都变成Object,这个时候,只要是Response,编译器都不会报错。
本来定义的Response<Article>,但现在什么Response都能放,代码还按原有方式取值,就很有可能异常了。
这就违背了泛型引入的原则,所以,Java不允许创建泛型数组。
③ Java/Kotlin获取泛型类型
Java会在编译期进行泛型擦除,所以无法对泛型做类型判断,除了 另外传递一个Class类型参数 外,还有下述两种方法可以获取泛型类型 (Java只支持第一种):
方法一:匿名内部类 + 反射
获取运行时泛型参数类型,子类可以获得父类泛型的具体类型,代码示例如下:
// 定义匿名内部类
val response = object : Response<Article>() {}
// 反射获取当前类表示的实体的直接父类,这里就是:得到泛型父类
val typeClass = response.javaClass.genericSuperclass
println(typeClass) // 输出:test.Response<test.Article>
// 判断是否实现ParameterizedType接口,是说明支持泛型
if (typeClass is ParameterizedType) {
// 返回此类型实际类型参数的Type对象数组,里面放的是对应类型的Class,泛型可能有多个
val actualType = typeClass.actualTypeArguments[0]
print(actualType.typeName) // 输出:test.Article
}
Tips:Gson库就有用到了这种方法,反序列化时要定义一个 TypeToken
的匿名内部类:
看下构造方法:
非常简单~
方法二:inline内联函数 + reified关键字(类型不擦除)
inline fun <reified T : Activity> Activity.startActivity(context: Context) {
startActivity(Intent(context, T::class.java))
}
// 调用
startActivity<MainActivity>(context)
④ 泛型命名
泛型类型的命名不是必须为T,没有强制的命名规范,可以用其他字母,甚至T1、T2、VB等都可以,毕竟对于Java编译器来说,只是起到一个 占位作用。当然为了便于阅读,有一些约定成俗的命名 (根本目的还是 见名知意):
- 通用泛型类型:T,S,U,V
- 集合元素泛型类型:E
- 映射键-值泛型类型:K,V
- 数值泛型类型:N
0x5、要点提炼
泛型本质:类型参数化,要操作的数据类型可以通过参数指定,类比函数,定义指定形参(数据类型),调用传入实参(具体类型)。
语法表现:类比占位符,把类型确定推迟到创建对象或调用方法时,然后就是围绕这个占位符制定的一系列语法规则。
泛型上界<T extends Entity>,传入类型参数需为Entity类或其子类,限制上界的好处:直接调父类成员。
不变:编译器只能识别具体类型,Response<Entity>不等于Response<Article>,不能互相替换。
型变:扩展参数的类型范围,但也导致不能按照泛型类型读取元素,根据定义型变位置分为:使用处型变 和 声明处型变(Java没有)。
协变:Response<? extends Entity> 父子关系一致,能读不能写 (能用父类型获取,不确定具体类型不能传)
逆变:Response<? super Article> 父子关系颠倒,能写不能读 (能传子类型,不确定具体类型不能读,但可用Object读)
PECS法则:生产者 → out/extends → 协变 → 返回值;消费者 → in/super → 逆变 → 入参
泛型同时作为参数和返回值,没实际写入行为,可用@UnsafeVariance注解解决 型变冲突。
Java假泛型 → 类型擦除 → 进JVM前用Object或限定类型替换 → 反射绕过 → 不实现真泛型原因:向前兼容。
获取泛型具体类型 → 匿名内部类 + 反射,inline内联函数 + reified关键字(类型不擦除)
参考文献:
转载自:https://juejin.cn/post/7133125347905634311