换个姿势,十分钟拿下Java/Kotlin泛型
Posted 嘴巴吃糖了
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了换个姿势,十分钟拿下Java/Kotlin泛型相关的知识,希望对你有一定的参考价值。
0x1、引言
解完BUG,又有时间摸鱼学点东西了,最近在复习Kotlin,跟着朱涛的 《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
② 逆变:父子关系颠倒 → 父类也可以作为参数传进来 → <? super Article> → 下界通配符
Tips:Kotlin中使用 in 关键字表示逆变 → Response
可以看到,型变 虽然拓展了参数的类型范围,但也导致 不能按照泛型类型读取元素。
除此之外,还有一个 无限定通配符<?>,等价于 <? extends Object>,不关心泛型的具体类型时,可以用它。
Tips:Kotlin中使用 星投影<*> 表示,等价于
再补充一点,根据 定义型变的位置,分为 使用处型变 (对象定义)
和 声明处型变 (类定义)
。
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
以上是关于换个姿势,十分钟拿下Java/Kotlin泛型的主要内容,如果未能解决你的问题,请参考以下文章
Java 泛型泛型用法 ( 泛型编译期擦除 | 上界通配符 <? extends T> | 下界通配符 <? super T> )