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

c9e65b82f44b2e1cb61b45bae2563e2d.jpeg

/   今日科技快讯   /

近日,根据Global Data公布的数据,卡塔尔世界杯期间,中国企业共赞助了13.95亿美元,超过美国的11亿美元,成为本届世界杯最大赞助商。英国《金融时报》分析表示,在当前国际大环境下,跨国制造业不再将一味地扩张作为发展目标,激烈的竞争形势驱使跨国企业创造出卓越的产品,在海外建立更好的品牌价值。同时,《金融时报》报道,以中国企业海信为例,目前全球影响力是其创新的主要驱动力之一。目前,海信的产品已进入日本、欧洲和美国等地大型商场。

/   作者简介   /

本篇文章转自coder_pig的博客,文章主要分享了 Java/Kotlin 开发中泛型相关的原理,相信会对大家有所帮助!

原文地址:

https://juejin.cn/post/7133125347905634311

/   前言   /

解完 BUG,又有时间摸鱼学点东西了,最近在复习 Kotlin,查缺补漏。看到泛型这一章时,想起之前面一家小公司时的面试题:

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

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

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

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

203a1aea59ce4453e362aeb0a445b03f.png

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

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

/   泛型到底是什么?   /

直接说结论:

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

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

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

fd3b63169024994dd67d1991dd49f568.png

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

a20a1d545eee42303bc4826cfc227b59.png

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

语法糖 → 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() 方法,还避免了运行时由于对象类型不匹配引发的异常。

泛型这种把数据类型的确定推迟到创建对象或调用方法时的玩法跟占位符很像。

好处也很明显,逻辑复用、灵活性强,而所谓的泛型边界、不变、型变等,就是围绕着这个“占位符”制定的一系列语法规则而已。所以,泛型不是非用不可!!!

  • 用了 → 可以少写一些代码,可以在编译期提前发现类型转换问题;

  • 不用 → 得多写一些类型判定和强转代码,可能存在类型转换问题;

/   泛型规则   /

了解完泛型是啥?有什么用?接着来理解它的规则,即指定目标数据类型的一些语法。

① 边界限制

就上面例子里的 <T extends Entity>,要求传入的泛型参数必须是 Entity 类或它的子类,又称泛型上界。

限制上界的好处:可以直接调用父类或父接口的方法,如上面直接调 entity.parseJson();

Tips:Kotlin中用冒号代替 extends → <T:Entity>

② 不变、协变、逆变

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

c0074456187d6f4bbd15709faa474914.png

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

42f4c3661ef6784ea628a1e91c987eba.png

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

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

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

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

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

dc3006ad94f4a6a25a3eb0041fddb062.png

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)

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

  • 向上转型 → 子类转换成父类(隐式),安全,可以访问父类成员;

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

接着改下例子:

f48bdfe7130ad8498946aa5111dbe78e.png

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

bcb8732673e2d18d478a5657d60ea9c1.png

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

350b81a9eaebf4317578f58b9c634c9d.png

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

  • 生产者 → extends/out → 协变 → 对象只作为返回值传出

  • 消费者 → super/in → 逆变 → 对象只作为参数传入

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

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

da03ffbaa3deea60b3063fc449b06ebb.png

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

7b3d8892a2832d750fff8b9e601c1563.png

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

/   一些补充   /

① Java假泛型

和 C# 等编程语言的泛型不同,Java 和 Kotlin 中的泛型都是假泛型,原理 → 类型擦除(Type Erasure)。生成Java字节码中是不包含泛型类型信息的,它只存在于代码编译阶段,进JVM前会被擦除~

写个简单例子验证:

e239bb3acd1d3f1dec3fe5ac7c3af949.png

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

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

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

a24a3c051e0627a675a1dc46179215a2.png

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

445ff13c99a1895c94c75d5408d54df4.png

反编译字节码:

25c1b9df65881aa5801eeabfbf05b1c3.png

可以看到变成了限定类型(父类型Entity)。另外,我们可以通过反射的反射绕过Java的假泛型:

350471bff41102effd4c6c1aa46f4e31.png

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

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

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

https://www.zhihu.com/question/28665443/answer/118148143

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

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

617fae5a7bbcab148b58543f03500ec8.png

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

4ffc90c54ff65dcc7fdc4fd1d6ce6f71.png

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

ce892003e4b314d0fd1deebb9053d6b6.png

回到问题,假如 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 的匿名内部类:

f52cccd1cb64349e2ef2cc3fc6048893.png

看下构造方法:

a7b1aa3b60d9cbe362c172d810e8a971.png

非常简单~

方法二: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

/   要点提炼   /

泛型本质:类型参数化,要操作的数据类型可以通过参数指定,类比函数,定义指定形参(数据类型),调用传入实参(具体类型)。

语法表现:类比占位符,把类型确定推迟到创建对象或调用方法时,然后就是围绕这个占位符制定的一系列语法规则。

泛型上界<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 关键字(类型不擦除)

推荐阅读:

我的新书,《第一行代码 第3版》已出版!

Kotlin Flow响应式编程,操作符函数进阶

深入探究Kotlin的可见性控制,从internal入手

欢迎关注我的公众号

学习技术或投稿

cdbd71c3442a94ca8f8c058acf0e3ec2.png

2062768ca4608ffca8718026d4133128.jpeg

长按上图,识别图中二维码即可关注

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值