「Java 路线」| 泛型(含Kotlin)

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 「Java 路线」| 导读 —— 他山之石,可以攻玉 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)

前言

  • 泛型(Generic Type)无论在哪一门语言里,都是最难语法的存在,细节之繁杂、理解之困难,令人切齿;
  • 在这个系列里,我将总结Java & Kotlin中泛型的知识点,带你从语法 & 原理全面理解泛型。追求简单易懂又不失深度,如果能帮上忙,请务必点赞加关注!
  • 首先,尝试回答这些面试中容易出现的问题:
1、下列代码中,编译出错的是:
public class MyClass<T> {
    private T t0; // 0
    private static T t1; // 1 
    private T func0(T t) { return t; } // 2
    private static T func1(T t) { return t; } // 3
    private static <T> T func2(T t) { return t; } // 4
}
2、泛型的存在是用来解决什么问题?
3、请说明泛型的原理,什么是泛型擦除机制,具体是怎样实现的?

相关文章


目录


1. 泛型基础

  • 问:什么是泛型,有什么作用?

答:在定义类、接口和方法时,可以附带类型参数,使其变成泛型类、泛型接口和泛型方法。与非泛型代码相比,使用泛型有三大优点:更健壮(在编译时进行更强的类型检查)、更简洁(消除强转,编译后自动会增加强转)、更通用(代码可适用于多种类型)

  • 问:什么是类型擦除机制?

答:泛型本质上是 Javac 编译器的一颗 语法糖,这是因为:泛型是 JDK1.5 中引进的新特性,为了 向下兼容,Java 虚拟机和 Class 文件并没有提供泛型的支持,而是让编译器擦除 Code 属性中所有的泛型信息,需要注意的是,泛型信息会保留在类常量池的属性中。

  • 问:类型擦除的具体步骤?

答:类型擦除发生在编译时,具体分为以下 3 个步骤:

  • 1:擦除所有类型参数信息,如果类型参数是有界的,则将每个参数替换为其第一个边界;如果类型参数是无界的,则将其替换为 Object
  • 2:(必要时)插入类型转换,以保持类型安全
  • 3:(必要时)生成桥接方法以在子类中保留多态性

举个例子:

源码:
public class Parent<T> {
    public void func(T t){
    }
}

public class Child<T extends Number> extends Parent<T> {
    public T get() {
        return null;
    }
    public void func(T t){
    }
}

void test(){
    Child<Integer> child = new Child<>();
    Integer i = child.get();
}
---------------------------------------------------------
字节码:
public class Parent {
    public void func(Object t){
    }
}

public class Child extends Parent {
    public Number get() {
        return null;
    }
    public void func(Number t) {
    }
    
    桥方法 - synthetic
    public void func(Object t){
        func((Number)t);
    }
}

void test() {
    Child<Integer> child = new Child();
    // 插入强制类型转换
    Integer i = (Integer) child.get();
}

步骤1:Parent 中的类型参数 T 被擦除为 Object,而 Child 中的类型参数 T 被擦除为 Number;

步骤2:child.get(); 插入了强制类型转换

步骤3:在 Child 中生成桥方法,桥方法是编译器生成的,所以会带有 synthetic 标志位。为什么子类中需要增加桥方法呢,可以先思考这个问题:假如没有桥方法,会怎么样?你可以看看下列代码调用的是子类还是父类方法:

Parent<Integer> child = new Child<>();
Parent<Integer> parent = new Parent<>();
        
child.func(new Object()); // Parent#func(Object); 
parent.func(new Object()); // Parent#func(Object); 

这两句代码都会调用到 Parent#func(),如果你看过之前我写过的一篇文章,相信难不到你:《Java | 深入理解方法调用的本质(含重载与重写区别)》。在这里我简单分析下:

1、方法调用的本质是根据方法的符号引用确定方法的直接引用(入口地址)

2、这两句代码调用的方法符号引用为:

child.func(1) => com/xurui/Child.func(Object)
parent.func(1) => com/xurui/Parent.func(Object)

3、这两句方法调用的字节码指令为 invokevirtual

4、类加载解析阶段解析类的继承关系,生成类的虚方法表

5、调用阶段(动态分派):Child 没有重写 func(Object),所以 Child 的虚方法表中存储的是Parent#func(Object);Parent 的虚方法表中存储的是Parent#func(Object);

可以看到,即使使用对象的实际类型为 Child ,这里调用的依旧是父类的方法。这样就失去了多态性。因此,才需要在泛型子类中添加桥方法。

  • 问:为什么擦除后,反编译还是看到类型参数 T ?
反编译Parent.class,可以看到 T ,不是已经擦除了吗?

public class Parent<T> {
    public Parent() {
    }

    public void func(T t) {
    }
}

答:泛型中所谓的类型擦除,其实只是擦除Code 属性中的泛型信息,在类常量池属性(Signature属性、LocalVariableTypeTable属性)中其实还保留着泛型信息,这也是在运行时可以反射获取泛型信息的根本依据,我在第 4 节说。

  • 问:泛型的限制 & 类型擦除会带来什么影响?

由于类型擦除的影响,在运行期是不清楚类型实参的实际类型的。为了避免程序的运行结果与程序员语义不一致的情况,泛型在使用上存在一些限制。好处是类型擦除不会为每种参数化类型创建新的类,因此泛型不会增大内存消耗。

泛型的限制

2. Kotlin的实化类型参数

前面我们提到,由于类型擦除的影响,在运行期是不清楚类型实参的实际类型的。例如下面的代码是不合法的,因为T并不是一个真正的类型,而仅仅是一个符号:

在这个函数里,我们传入一个List,企图从中过滤出 T 类型的元素:

Java:
<T> List<T> filter(List list) {
    List<T> result = new ArrayList<>();
    for (Object e : list) {
        if (e instanceof T) { // compiler error
            result.add(e);
        }
    }
    return result;
}
---------------------------------------------------
Kotlin:
fun <T> filter(list: List<*>): List<T> {
    val result = ArrayList<T>()
    for (e in list) {
        if (e is T) { // cannot check for instance of erased type: T
            result.add(e)
        }
    }
    return result
}

Kotlin中,有一种方法可以突破这种限制,即:带实化类型参数的内联函数

Kotlin:
inline fun <reified T> filter(list: List<*>): List<T> {
    val result = ArrayList<T>()
    for (e in list) {
        if (e is T) {
            result.add(e)
        }
    }
    return result
}

关键在于inlinereified,这两者的语义是:

  • inline(内联函数):Kotlin编译器将内联函数的字节码插入到每一次调用方法的地方
  • reified(实化类型参数):在插入的字节码中,使用类型实参的确切类型代替类型实参

规则很好理解,对吧。很明显,当发生方法内联时,方法体字节码就变成了:

调用:
val list = listOf("", 1, false)
val strList = filter<String>(list)
---------------------------------------------------
内联后:
val result = ArrayList<String>()
for (e in list) {
    if (e is String) {
        result.add(e)
    }
}

需要注意的是,内联函数整个方法体字节码会被插入到调用位置,因此控制内联函数体的大小。如果函数体过大,应该将不依赖于T的代码抽取到单独的非内联函数中。

注意,无法从 Java 代码里调用带实化类型参数的内联函数

实化类型参数的另一个妙用是代替 Class 对象引用,例如:

fun Context.startActivity(clazz: Class<*>) {
    Intent(this, clazz).apply {
        startActivity(this)
    }
}

inline fun <reified T> Context.startActivity() {
    Intent(this, T::class.java).apply {
        startActivity(this)
    }
}

调用方:
context.startActivity(MainActivity::class.java)
context.startActivity<MainActivity>() // 第二种方式会简化一些

3. 变型:协变 & 逆变 & 不变

变型(Variant)描述的是相同原始类型的不同参数化类型之间的关系。说起来有点绕,其实就是说:IntegerNumber的子类型,问你List<Integer>是不是List<Number>的子类型?

变型的种类具体分为三种:协变型 & 逆变型 & 不变型

  • 协变型(covariant):子类型关系被保留
  • 逆变型(contravariant):子类型关系被翻转
  • 不变型(invariant):子类型关系被消除

在 Java 中,类型参数默认是不变型的,例如:

List<Number> l1;
List<Integer> l2 = new ArrayList<>();
l1 = l2; // compiler error

相比之下,数组是支持协变型的:

Number[] nums;
Integer[] ints = new Integer[10]; 
nums = ints; // OK 协变,子类型关系被保留

那么,当我们需要将List<Integer>类型的对象,赋值给List<Number>类型的引用时,应该怎么做呢?这个时候我们需要限定通配符

  • <? extends> 上界通配符

要想类型参数支持协变,需要使用上界通配符,例如:

List<? extends Number> l1;
List<Integer> l2 = new ArrayList<>();
l1 = l2; // OK

但是这会引入一个编译时限制:不能调用参数包含类型参数 E 的方法,也不能设置类型参数的字段,简单来说,就是只能访问不能修改(非严格):

// ArrayList.java
public boolean add(E e) {
    ...
}

l1.add(1); // compiler error
  • <? super> 下界通配符

要想类型参数支持逆变,需要使用下界通配符,例如:

List<? super Integer> l1;
List<Number> l2 = new ArrayList<>();
l1 = l2; // OK

同样,这也会引入一个编译时限制,但是与协变相反:不能调用返回值为类型参数的方法,也不能访问类型参数的字段,简单来说,就是只能修改不能访问(非严格):

// ArrayList.java
public E get(int index) {
    ...
}

Integer i = l1.get(0); // compiler error
  • <?> 无界通配符

<?>其实很简单,很多资料其实都解释得过于复杂了。<?> 其实就是 <? extends Object>的缩写,就是这样,没了,例如:

List<?> l1;
List<Integer> l2 = new ArrayList<>();
l1 = l2; // OK

理解了这点,这个问题就很好回答了:

  • 问:List 与 List<?>有什么区别?

答:List 是原生类型,可以添加或访问元素,不具备编译期安全性,而 List<?> 其实是 List<? extends Object>的缩写,是协变型的(可引出协变型的特点与限制);从语义上,List<?> 表明使用者清楚变量是类型安全的,而不是因为疏忽而使用了原生类型 List。

泛型代码的设计,应遵循PECS原则(Producer extends Consumer super):

  • 如果只需要获取元素,使用 <? extends T>
  • 如果只需要存储,使用<? super T>

举例:

// Collections.java
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
}

在 Kotlin 中,变型写法会有些不同,但是语义是完全一样的:

协变:
val l0: MutableList<*> 相当于MutableList<out Any?>
val l1: MutableList<out Number>
val l2 = ArrayList<Int>()
l0 = l2 // OK
l1 = l2 // OK
---------------------------------------------------
逆变:
val l1: MutableList<in Int>
val l2 = ArrayList<Number>()
l1 = l2 // OK

另外,Kotlin 的in & out不仅仅可以用在类型实参上,还可以用在泛型类型声明的类型参数上。其实这是一种简便写法,表示类设计者知道类型参数在整个类上只能协变或逆变,避免在每个使用的地方增加,例如 Kotlin 的List被设计为不可修改的协变型:

public interface List<out E> : Collection<E> {
    ...
}

注意:在 Java 中,只支持使用点变型,不支持 Kotlin 类似的声明点变型

小结一下:


4. 使用反射获取泛型信息

前面提到了,编译期会进行类型擦除,Code 属性中的类型信息会被擦除,但是在类常量池属性(Signature属性、LocalVariableTypeTable属性)中还保留着泛型信息,因此我们可以通过反射来获取这部分信息。

获取泛型类型实参:需要利用Type体系

4.1 获取泛型类 & 泛型接口声明

TypeVariable
ParameterizedType
GenericArrayType
WildcardType

Gson TypeToken

Editting....


5. 总结

  • 应试建议
    • 1、第 1 节非常非常重点,着重记忆:泛型的本质和设计缘由、泛型擦除的三个步骤、限制和优点,已经总结得很精华了,希望能帮到你;
    • 2、着重理解变型(Variant)的概念,以及各种限定符的含义;
    • 3、Kotlin 相关的部分,作为知识积累和思路扩展为主,非应试重点。

参考资料

  • 《Kotlin实战》 (第9、10章)—— [俄] Dmitry Jemerov,Svetlana Isakova 著
  • 《Java编程思想》 (第19、20、23章)—— [美] Bruce Eckel 著
  • 《深入理解Java虚拟机(第3版本)》(第10章)—— 周志明 著

创作不易,你的「三连」是丑丑最大的动力,我们下次见!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266