首页 > 编程语言 >换个姿势,十分钟拿下Java/Kotlin泛型

换个姿势,十分钟拿下Java/Kotlin泛型

时间:2023-06-22 10:35:00浏览次数:62  
标签:Java Kotlin public entity 类型 泛型 Response


0x1、引言

解完BUG,又有时间摸鱼学点东西了,最近在复习Kotlin,跟着朱涛的 《Kotlin 编程第一课》查缺补漏。

换个姿势,十分钟拿下Java/Kotlin泛型_父类

看到泛型这一章时,想起之前面一家小公司时的面试题:

说下你对泛型协变和逆变的理解

读者可以试试在不查资料的情况下能否答得上来?

反正我当时是没想起来,尽管写过一篇《Kotlin刨根问底(三):你真的懂泛型,会用吗?》,我以为自己对泛型了然于胸。

究其根源,对概念名词的理解浮于表面,模棱两可,知道有这个东西,但本质是什么?为啥要用?怎么用?并没有二次加工形成自己的思考和理解,所以印象不深刻。加之 泛型平时开发用的不多记和忆 两个要素都没做到,久了自然会忘。

换个姿势,十分钟拿下Java/Kotlin泛型_父类_02

而网上关于泛型讲解的文章大都千篇一律:集合 存取元素类型异常引出泛型,不变、协变、逆变、型变一把梭,什么能读不能写,能写不能读,读者看完好像懂了,又好像没懂,这很正常,毕竟作者自己都可能弄不明白,2333。就问你一句:泛型只能用在集合上吗

换个姿势,十分钟拿下Java/Kotlin泛型_android_03

综上原因,有了这篇文章,本节换个角度,从根上理解泛型,少说废话掐要点,这次一定拿下Java/Kotlin泛型。


0x2、泛型到底是什么?

直接说结论:

泛型的本质类型参数化要操作的数据类型 可以通过 参数的形式来指定

说人话:把数据类型变成参数

难理解?类比 函数/方法,定义时指定 参数类型(形参),调用时传入 具体参数实例(实参)

换个姿势,十分钟拿下Java/Kotlin泛型_Java_04

泛型 也是如此,定义时指定 数据类型(形参),调用时传入 具体数据类型(实参)

换个姿势,十分钟拿下Java/Kotlin泛型_android_05

非常相似,只是 数据类型 的定义和传递都是通过 <>,而不是(),那 泛型的作用是什么呢?直接说结论:

语法糖 → 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>

② 不变、协变、逆变

泛型是不变的!这句话怎么理解?看下这段代码:

换个姿势,十分钟拿下Java/Kotlin泛型_kotlin_06

咋回事?Entity和Article不是有 继承关系 吗?为啥不能互相替代?因为能替换的话 的时候有问题:

换个姿势,十分钟拿下Java/Kotlin泛型_Java_07

为了避免这两个问题,编译器直接认为 Response<Entity> 和 Response<Article> 不存在继承关系,无法相互替代,即 只能识别具体的类型,这就是 泛型的不变性

而在有些场景,这样的特性会给我们带来一些不便,可以通过 型变扩展参数的类型范围,有下面两种形式:

协变父子关系一致子类也可以作为参数传进来<? extends Entity>上界通配符

换个姿势,十分钟拿下Java/Kotlin泛型_android_08

Tips:Kotlin中使用 out 关键字表示协变 → Response

逆变父子关系颠倒父类也可以作为参数传进来<? super Article>下界通配符

换个姿势,十分钟拿下Java/Kotlin泛型_Java_09

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)

难不成还有更详细的规则?是的!先提一嘴介个:

  • 向上转型 → 子类转换成父类 (隐式),安全,可以访问父类成员;
  • 向下转型 → 父类转换成子类 (显式),存在安全隐患,子类可能有一些父类没有的方法;

接着改下例子:

换个姿势,十分钟拿下Java/Kotlin泛型_android_10

先是 协变能读不能写 (能用父类型去获取数据,不确定具体类型,不能传)

换个姿势,十分钟拿下Java/Kotlin泛型_泛型_11

接着是 逆变能写不能读 (能传入子类型,不确定具体类型,不能读,但可以用Object读)

换个姿势,十分钟拿下Java/Kotlin泛型_Java_12

没看懂的话,多看几遍,实在不行,那就背:PECS法则 (Producer Extends,Consumer Super)

  • 生产者extends/out协变对象只作为返回值传出
  • 消费者super/in逆变对象只作为参数传入

Tips:Kotlin官方文档写的 Consumer in, Producer out!,好像更容易理解和记忆~

另外,在某些特殊场景,泛型参数 同时作为参数和返回值,可以使用 @UnsafeVariance 注解来解决 型变冲突,如 Kotlin\Collections.kt 中的:

换个姿势,十分钟拿下Java/Kotlin泛型_泛型_13

到此,泛型的规则就讲解完毕了,纸上得来终觉浅,绝知此事要躬行,建议自己写点代码试试水,加深印象,如:

换个姿势,十分钟拿下Java/Kotlin泛型_泛型_14

当然阅读源码也是一个很好的巩固方式,Java\Kotlin集合类相关代码大量使用了泛型~


0x4、一些补充

① Java假泛型

和C#等编程语言的泛型不同,Java和Kotlin中的泛型都是 假泛型,原理 → 类型擦除(Type Erasure)

生成Java字节码中是 不包含泛型类型信息的,它只存在于代码编译阶段,进JVM前会被擦除~

写个简单例子验证:

换个姿势,十分钟拿下Java/Kotlin泛型_kotlin_15

可以看到,此时的 类类型 皆为 Response,那定义的泛型类型都哪去了?

答:被替换成 原始类型,没指定 限定类型 就是 Object,有则为 限定类型

反编译字节码看看 (安装 bytecode viewer 插件,然后点 View -> Show Bytecode)

换个姿势,十分钟拿下Java/Kotlin泛型_父类_16

可以看到都被替换成 Object,试试加上 泛型上界

换个姿势,十分钟拿下Java/Kotlin泛型_父类_17

反编译字节码:

换个姿势,十分钟拿下Java/Kotlin泛型_Java_18

可以看到变成了 限定类型 (父类型Entity)。

另外,我们可以通过 反射 的反射绕过Java的假泛型:

换个姿势,十分钟拿下Java/Kotlin泛型_Java_19

到此,你可能还有一个疑问:为什么Java不实现真泛型

答:向前兼容,使得Java 1.5前未使用泛型类的代码,不用修改仍可以继续正常工作。

详细历史原因讲解可自行查阅:《Java 不能实现真正泛型的原因是什么?》


② Java为什么不支持泛型数组

在Java中,允许把子类数组赋值给父类数组变量,所以下面的代码是可行的:

换个姿势,十分钟拿下Java/Kotlin泛型_父类_20

如果我们往Object数组里放一个Entity实例,编译器提示,但不报错:

换个姿势,十分钟拿下Java/Kotlin泛型_泛型_21

但运行时会检查假如数组的对象类型,然后抛出异常:

换个姿势,十分钟拿下Java/Kotlin泛型_Java_22

回到问题,假如 Java支持泛型数组,那下面的代码会怎样?

Response<Article>[] articles = new Response<Article>[10];
Response<Entity>[] entities = articles;
entities[0] = new Response<Banner>();

类型擦除,Article、Entity、Banner都变成Object,这个时候,只要是Response,编译器都不会报错。

本来定义的Response


,但现在什么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

换个姿势,十分钟拿下Java/Kotlin泛型_泛型_23

看下构造方法:

换个姿势,十分钟拿下Java/Kotlin泛型_Java_24

非常简单~


方法二: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关键字(类型不擦除)
复制代码

参考文献

  • kotlin修炼指南7之泛型
  • Java泛型06 : 通配符:上边界、下边界与无界
  • 10 | 泛型:逆变or协变,傻傻分不清?

作者:coder_pig

标签:Java,Kotlin,public,entity,类型,泛型,Response
From: https://blog.51cto.com/u_16163453/6534316

相关文章

  • Java—多线程
    ......
  • Java—反射与注解
    ......
  • Java—集合框架
    什么是集合......
  • Javascript
    什么是Javascript概述javaScript是一门世界上最流行的脚本语言Java,JavaScript10天一个合格的后端人员,必须精通JavaScript历史ECMAScript它可以理解为JavaScript的一个标准最新版本已经到es6版本~但是大部分浏览器还只停留在支持es5代码上!开发环境–线上环境,版本不一致......
  • 转Java路线语法注意点
    面向对象编程概念:面向对象编程——以类的方式组织代码,以对象的组织数据。1.类和对象类是一个模板:抽象,对象:一个具体的实例。2.方法就是函数。3.对象的引用引用类型:基本类型(8)对象是通过引用来操作的:栈——>堆。4.属性在Java里叫字段。5.对象的创建和使用必须使用new关键......
  • Java—面向对象
    概念:面向对象编程——以类的方式组织代码,以对象的组织数据。1.类和对象类是一个模板:抽象,对象:一个具体的实例。2.方法就是函数。3.对象的引用引用类型:基本类型(8)对象是通过引用来操作的:栈——>堆。4.属性在Java里叫字段。5.对象的创建和使用必须使用new关键字构建对象,构......
  • Java—IO流
    流的概念内存与存储设备之间传输数据的通道流的分类按方向<重点>输入流:将<存储设备>中的内容读到<内存>中输出流:将<内存>中的内容写到<存储设备>中按单位字节流:以字节为单位,可以读写所有数据字符流:以字符为单位,只能读写文本数据按功能节点流:具有实际传输数据......
  • Java-Hello World
    Java-HelloWorld安装JDK......
  • 关于 Java 和 node affinity 这个概念的联系
    在Java开发领域,术语"nodeaffinity"通常不是与Java语言本身直接相关的概念。然而,在某些特定的上下文中,可以将"nodeaffinity"应用于Java应用程序的部署和调度方面。在Java开发中,"nodeaffinity"可能指的是以下几个方面:服务器亲和性:在分布式环境中部署Java应用程序时,可以考虑将......
  • Java环境的搭建
    首先我们应该对JDK,JRE,JVM有所认知,下面这个图就可以清晰的表述他们的关系:JDK,JRE,JVM的关系一个更复杂的图:这是J2SE1.5 具体功能图表,在图的左边,我们可以清晰的看出哪些是JVM的范畴,哪些是JRE的范畴,哪些是JDK的范畴。Java程序的执行逻辑下面图演示的是如何打造一个个性化的......