kotlin入门潜修之类和对象篇—泛型及其原理

本文收录于 kotlin入门潜修专题系列,欢迎学习交流。
创作不易,如有转载,还请备注。
写在前面 物损于彼者盈于此,成于此者亏于彼。——与君共勉。
泛型 如果我们了解java中的泛型,那么本篇文章提到的kotlin泛型我们也不会陌生。但是如果之前没有接触过泛型或者没有真正理解泛型,本篇文章理解起来可能有些困难,不过我会尽量阐述的通俗易懂。
java中的泛型 前面一直有提到,kotlin是运行于jvm上的语言,其对标的语言就是java,因此我们先来讲一下java的泛型,了解了java泛型的优缺点之后,我们就很容易明白kotlin中泛型的设计初衷了。
首先说下泛型的概念,所谓泛型即是类型的参数化。怎么理解呢?想一下以前我们所说的方法,如果方法有入参,那么这些入参前面往往会有类型,这个类型就是为了修饰参数所用。而假如我们在创建类型的时候也为其指定参数,这个参数又是个类型,那么我们就称之为泛型。
那么泛型的作用和意义是什么?使用泛型能够像传递参数一样传递类型,同时保证运行时的类型安全。类型的传递能够让我们写一份代码就能满足各种类型的调用;类型安全是指编译器在编译代码期间会对泛型信息进行检查,只有符合规范的才能编译通过,这样可以有效避免运行时的ClassCastException异常。这也就是和使用Object相比(所有类型都可以用基类Object表示),泛型的一个优势所在。泛型和Object的使用对比示例如下:

public void test(){ //使用Object的场景 Map map = new HashMap(); map.put("test", 1); Integer r = (Integer)map.get("test"); //正确!get返回的Object类型可以转换为Integer。因为map中存放的实际类型就是Integer类型。 String r1 = (String) map.get("test"); //错误!运行时会报类型转换异常!因为map中存放的实际类型是Integer类型,而不是String。 //使用泛型 Map map2 = new HashMap(); map2.put("test", 1); Integer r3 = map2.get("test"); //正确!不用考虑任何类型转换 String r2 = map2.get("test"); //编译不通过!因为map2的值只能是Integer,所以返回的是Integer,而不是String }

java中既支持类泛型也支持方法泛型。示例如下:
public class GenericClass {//创建类GenericClass的时候,为其指定了类型参数T。 void test(T t) { } }class GenericClass2 { void test(T t) {//方法泛型化。声明方法的时候为其指定了类型参数T。 } }

上例简单展示了泛型的定义,上面的T可以传入任何类型进行表示,这就相当于一个入参,只不过这个入参是个类型而已。
由于本章节的目的并不是为了阐述java中泛型的语法,而是想发现java中泛型的弊端。所以,下面我们直接使用jdk提供的泛型库来演示下java中泛型的限制。
泛型类型是不可协变的,示例如下:
List ints = new ArrayList<>(); //正确,生成一个类型是Integer的集合 List numbers = ints; //!!!错误,List不是List的子类
按道理来讲,Integer是Object的子类,如果一个集合中的元素都是Integer类型的,那么该集合显然应该能被放到Object集合中。然而java却不允许我们这么做,为什么?
假如java允许这么做,那么会带来什么后果?看下面代码:
public static void main(String[] args) { List ints = new ArrayList<>(); List numbers = ints; //假如java允许这样赋值 numbers.add("hello"); //这句语法显然是成立的,字符串属于Object类型 Integer i = (Integer) numbers.get(0); //!!!错误,这句代码运行的时候会抛ClassCastException异常 }
这就是为什么java不允许我们这么做的原因,就是为了保证运行时类型安全。同时也说明了,java中的泛型不是协变(下面章节会详细介绍什么是协变)的!
上面也说到了,这种限制其实是不合理的。但是我们再来看下面一个例子:
List ints = new ArrayList<>(); List numbers = new ArrayList<>(); numbers.addAll(ints); //!!!正确!
上面代码中最后一句竟然是正确是写法!可以通过addAll将Integer集合加入到Object集合!按照上面的分析,这显然是不可能的!比如我们自己来写一个addAll方法:
interface IList {//我们定义了一个泛型接口IList void addAll(IList list); //我们定义了一个addAll方法,用于添加list集合 } //MyList提供IList的默认实现 class MyList implements IList { @Override public void addAll(IList list) { } } public class Main { public static void main(String[] args) { IList ints = new MyList<>(); IList numbers = new MyList<>(); numbers.addAll(ints); //!!!错误! } }
注意上面 numbers.addAll(ints)这句代码竟然报错了!依然提示类型冲突!那么java list中的addAll为什么可以呢?
让我们来看下list中的addAll方法的定义:
boolean addAll(Collection c);

我们发现addAll方法入参的泛型定义实际上是<?extends E>这个类型,而不是这个类型。这就引出了java中的通配符(使用?表示)概念。
著名的PECS法则 上一章节中引出了java中通配符的概念,java中的通配符可分为三类:
  1. 无界通配符:?
  2. 子类限定通配符:
  3. 父类限定通配符:
首先看下这三个通配符的使用(请仔细阅读代码中的注释):
public class Main { static void test(List list) { //在该方法中测试添加对象,实际上测试的是无界通配符作为类泛型参数的场景,因为list的类型是泛型List即List list.add(null); //可以 list.add(1); //无法添加int list.add(new Test2()); //无法添加自定义Test2类型对象 list.add("test"); //无法添加字符串类型 }static void test1(List list) { //在该方法中测试添加对象,list.add实际上测试的是通配符作为类泛型参数的场景,因为list的类型是泛型List类即List list.add(null); //可以 list.add(1); //无法添加int list.add(new Test2()); //无法添加自定义Test2类型对象 list.add("test"); //无法添加字符串类型 }static void test2(List list) { //在该方法中测试添加对象,list.add实际上测试的是通配符作为类泛型参数的场景,因为list的类型是泛型List类即List list.add(null); //可以 list.add(1); //可以 list.add(new Test2()); //错误 list.add("test"); //错误 } public static void main(String[] args) { List list = new ArrayList(); List list2 = new ArrayList<>(); List list3 = new ArrayList<>(); //test方法的调用,实际上测试的是无界通配符作为方法形参类型一部分的场景 test(list); //正确 test(list2); //正确 test(list3); //正确 //test1方法的调用,实际上测试的是子类限定通配符通配符作为方法形参类型一部分的场景 test1(list); //警告,没有进行类型检测。传入的是List,需要List类型 test1(list2); //正确 test1(list3); //错误,需要List类型,但传入的是List //test2方法的调用,实际上测试的是父类限定通配符通配符作为方法形参类型一部分的场景 test2(list); //警告,没有进行类型检测,传入的是List,需要List list类型 test2(list2); //编译错误,传入的List,然而需要的是List test2(list3); //正确 } }
上面代码我们刻意选择了泛型类型Number以及其子类Integer来进行测试。注释已经比较详细,主要描述了通配符的应用场景。结合上面代码我们可以总结如下:
  1. 对于赋值操作(参数入参也是赋值的一种情形)。无界通配符可以接受任意类型赋值;子类限定通配符可以接受泛型类型为其子类、本身或者没有泛型类型的赋值,其中没有泛型类型赋值时会有编译警告。父类限定通配符可以接受泛型类型为其超类、本身以及没有泛型类型的赋值,其中没有泛型类型赋值时会有编译警告。
  2. 对于读写操作。无界通配符无法添加除了null以外的任何对象。子类限制通配符也无法添加除了null外的任何对象,实际上子类通配符只可读不可写。父类限制通配符允许添加其子类,而不允许添加其父类。
总结已经完毕,主要来看两个点:
  1. 为什么无限制通配符和子类限制通配符只有可读性没有可写性?
  2. 为什么父类限制通配符允许子类类型写入?
这就是我们要讲的PECS原则。什么是PECS?PECS的全称可以理解为Producer-Extends-Consumer-Super,即其描述了子类限制符和父类限制符的使用原则。
  1. 子类限制符,用于生产者场景(Producer),表示可以从容器中取元素。
  2. 父类限制符,用于消费者场景(Consumer),表示可以向容器中存入元素。
  3. 如果既要存元素又要取元素,那么通配符无法满足需求。
    为什么会有上面限制?其实上面已经有所描述。这里再来阐述下。
首先,对于来说,表示的是T及其子类型,如果我们允许向容器中添加元素,那么我们无法确定子类型具体是什么类型,这样在取出元素的时候就有可能报类型转换异常,故为了运行时安全考虑,java直接禁止了元素写入。
对于来说,表示的是T及其T的超类类型,如果是T的子类那么一定也是T的超类的子类,所以将子类元素添加到容器是允许的,因为取出来的时候一定符合T或者T的超类类型。但是如果是T的超类那么是不允许像容器中添加元素的,因为我们无法确定T的超类具体是什么类型,取出来的时候就可能引起类型转换错误。代码示例如下:
List l = new ArrayList<>(); l.add(1); //正确,Integer是Number的子类 l.add(1.1); //正确,Double是Number的子类 l.add(new Object()); //错误,Object不是Number的子类List l2 = new ArrayList<>(); l2.add(1); //错误,子类限制通配符禁止写 l2.add(new Object()); //错误,子类限制通配符禁止写

至此,我们将java中的泛型大概过了一遍。下面来看下kotlin中的泛型。
kotlin中的泛型 声明处变量(Declaration-site variance) 想了解声明处变量是什么,先回到上文提到的java中的泛型问题:
//定义 一个泛型接口IList interface IList { E getE(); //只有一个getE方法,返回了E类型 } //定义了一个test方法,该方法接收元素是String类型的集合 void test(IList strs) { IList objs = strs; //这里我们将String集合赋值于Object集合,这在java中显然是不允许的! }
上面test方法中的写法在java中显然是不允许的,如果要允许我们就必须使用通配符写法:
void test(IList strs) { IList objs = strs; //正确,这里采用了子类限制通配符写法 }

这里问题就来了,子类限制通配符实际上是限制写的,但这里我们并没有写入任何元素(IList也只有一个getE方法,只是java编译器不知道而已),按理讲不使用子类限制通配符也应该能编译才对,然而java却没有通过编译,这就是java泛型中的一个弊端。
kotlin为了解决上面问题,就引入了声明处变量。声明处变量的作用就是在泛型类型参数前添加特定修饰符,来保证只会返回特定元素(即PECS中的生产),而不会消费任何元素(PECS中的消费)。
interface IList {//注意这里使用out修饰,这就是声明处变量 fun getE(): E//注意这个接口只有get方法返回了E,没有其他任何写入的方法。所以我们使用out修饰了IList接口 } fun test(strs: IList) { val objs: IList = strs//正确! }

上面就是kotlin声明处变量的使用,解决了java在没有消费场景的时候无法赋值的问题。
这里可以这么理解,IList在修饰时是协变的,或者说E是个协变类型参数;IList是E的生产者,而不是E的消费者。
什么是协变?所谓协变就是只要参数类型具有继承关系就认为整个泛型类型也有“继承”关系:比如上例中,String继承于Any,那么我们就可以认为IList是IList的子类型,这样就可以让IList类型的变量赋值于IList类型变量,这就是协变。
上面语法中的out被称为变量注解,因为out被定义在类型参数的声明侧(如IList)所以就称为声明处变量。这正是相对于java的“使用侧变量”定义而言的(比如java想要达到这种效果,就必须要在接收处声明为通配符泛型,而不是在IList的定义处: IList objs = strs; )
对比于out修饰符,kotlin还提供了另一个修饰符:in,in修饰符和out修饰符的作用刚好相反,in修饰符主要用于生产者场景,即可以写入。来看下面一个例子:
//这里定义另一个普通的泛型接口 interface Comparable { operator fun compareTo(other: T): Int } //test测试方法 fun test(x: Comparable) { val y: Comparable = x//错误!这里想要进行写操作,kotlin是不允许的!!! }

那么如何解决呢?使用我们的in修饰符即可:
interface Comparable { operator fun compareTo(other: T): Int } //test测试方法 fun test(x: Comparable) { val y: Comparable = x//正确!in修饰符允许我们写 }

这种情况叫做逆变,即我们当类型参数具有继承关系的时候,我们可以认为整个泛型也有继承关系,而使用in修饰后,可以允许父类型变量赋值于子类型变量,如上面代码中,将Comparable类型变量x赋值给了 Comparable类型变量y,这就是逆变。
kotlin中的声明处变量可以相对于java中的PECS理解:可简称为CIPO。C即是Consumer,I表示in,P表示生产者,O表示out。CIPO和java中的PECS一致。
类型映射(Type projections) 类型映射是属于使用侧定义的变量。先来看个例子:
//这里定义了一个数组copy的方法 fun copy(from: Array, to: Array) { //假设这里我们就是正常完成了from元素copy到to元素中 //这显然是合情合理的 } fun test(){ val ints: Array = arrayOf(1, 2, 3) val any = Array(3) { "" } copy(ints, any)//!!!错误,需要Array类型,但是传入的是Array类型 }

上面的代码又复现了经典的问题,即泛型类型是不变因子,即Array不是Array的子类,为什么要这么限制?道理和上面一样,kotlin认为我们有可能会对from进行写操作,比如我们在copy中为from中的一个元素赋值了一个字符串(虽然我们按正常逻辑不会这么写,我们只需要完成copy的功能就行,但是kotlin不这么认为)!这就会引起类型转换异常!所以kotlin对这种情形进行了限制。
解决方法就是禁止从from写入,告诉编译器我只读取from即可!如下所示:
fun copy(from: Array, to: Array) {//这里将from声明为了泛型,表示不可写,只可读。 } fun test(){ val ints: Array = arrayOf(1, 2, 3) val any = Array(3) { "" } copy(ints, any)//正确,编译器已经知道from只可读不可写,所以允许我们这么传入。 }

上面这种写法就是类型映射。目的就是可以使用读操作,而不使用写操作。
当然,我们也可以使用in操作符进行修饰,表示可以使用写操作,如下所示:
//from使用了in修饰,表示可写,类似于java中的 //接收String及其超类。 fun copy(from: Array, to: Array) { }fun test(){ val strs: Array = arrayOf("1") val any = Array(3) { "" } copy(strs, any)//正确,CharSequence是String的超类,符合的限制 }

上面代码需要注意的是,调用方法传递参数时,实际上进行的是赋值操作,这个并不是上面提到的类似于add的这种写操作。in作用于赋值操作时,只允许超类类型或自身类型赋值于其子类类型,而作用于add等写操作时,只允许写入子类类型或者自身类型。
星号映射(Star-projections) 有些时候,我们并不知道类型参数到底是什么,但是我们依然想安全的使用这些类型参数,该怎么办?
正式基于上面的考虑,kotlin为我们提供了星号映射,其修饰符为*。
星号映射的对应的几种泛类型使用场景阐述如下(假设现在我们为类GenericClass定义了几种泛型):
  1. 对于GenericClass这种泛型来讲,GenericClass<>等价于GenericClass,这意味着,如果T类型是未知的,你可以安全的从GenericClass<>中读取TUpper值。
  2. 对于GenericClass这种泛型来讲,GenericClass<>等价于GenericClass,这就意味着当T为未知类型时,你无法安全的向GenericClass<>类型中写入任何数据。
  3. 对于GenericClass这种泛型来讲,GenericClass<*>在读的时候,相当于GenericClass;在写的时候,相当于GenericClass
如果泛型有多个入参类型,比如 GenericClass,那么星映射对应的场景描述如下:
  1. GenericClass<*, String>等价于GenericClass;
  2. GenericClass 等价于GenericClass;
  3. GenericClass<*, *>等价于GenericClass
emm... 上面巴拉巴拉一大堆,说的是什么玩意?
确实,上面的描述枯燥难耐,很难有人能细心看下去,最敞亮的方式,还是要上几个例子,演示下星号映射的使用场景。
class GenericClass(t: T, val e: E) { fun set(t: T) { println(t) }fun get(): E { return e } } //测试方法,详见注释 fun test() { val g1: GenericClass<*, String> = GenericClass(1, "hello")//*代替了in修饰的类型,表示In Nothing val g2: GenericClass = GenericClass(1, "hello")//*代替了out修饰的类型,表示out Any? val g3: GenericClass<*, *> = GenericClass(1, "hello")g1.set(1)//错误。由于*代替了in修饰的类型,表示in Nothing,故没有办法写 val result: String = g1.get()//正确,该方法实际返回String类型g2.set(1)//正确,in修饰的类型,可以传入其子类型,这里为Int,继承与Number g2.set(Any())//错误,in修饰的类型,无法传入其超类类型,Any是Number的超类 val result2: Any? = g2.get()//由于*代替了out修饰的类型,表示out Any?可以读出String的任意父类。换句话说,这个方法本身返回了Any?g3.set(1)//同g1,不能写 val result3: Any? = g3.get()//同g2 }

泛型方法 泛型的概念前面已经介绍很多了,这里简单演示下kotlin中泛型方法的使用:
class GenericClass { fun m1(t: T) {//可以在泛型类中定义方法,只需要方法的入参泛型化即可。 println(t) } }class GenericTest { companion object { fun m1(t: T) {//在普通类中定义方法,除泛型化入参之外,还应该在方法名前增加修饰。 } @JvmStatic fun main(args: Array) { m1(1)//调用方法 m1(1)//可以显示指定泛型类型,但是没有必要,直接m1(1)即可。 } } }

泛型约束 我们来看一个例子:
companion object { fun > sort(list: List) {//sort方法,入参只能是Comparable子类 }@JvmStatic fun main(args: Array) { sort(listOf(1, 2, 3)) // 正确,Int是Comparable的子类 sort(listOf(HashMap())) // !!!错误,HashMap 不是Comparable>的子类 } }

上面展示了超类限制类型的场景,虽然我们期望可以对 HashMap进行排序,但是因为HashMap 没有实现Comparable>接口,所以不允许调用sort方法。
在kotlin中,默认的超类类型上限是Any?,在定义超类型的时候,只能指定一个超类,比如中只能指定T的超类上限是SupperT,而不能指定多个。但是有些时候我们确实需要指定多个超类类型,该怎么办?
为了解决这种情况,kotlin为我们提供了where语句,示例如下:
fun copyWhenGreater(list: List, threshold: T): List where T : CharSequence,//T必须是CharSequence类型的子类型或者CharSequence类型 T : Comparable {//同时T必须是Comparable类型的子类型或者Comparable类型 return list.filter { it > threshold }.map { it.toString() } }

泛型原理 kotlin中的泛型同java一样,都是“假”泛型,为什么这么说?是因为kotlin中的泛型信息同java一样,只在编译器间有,用于编译器做类型检查,而在运行的时候泛型信息就被擦除了,也就是说GenericClass和GenericClass在运行时是无差别的,等同于GenericClass<*>。
所以,我们无法在运行时获取任何泛型信息,也无法在运行时做任何类型转换检查。比如:
fun > sort(list: List) { if(list is List){//错误,在运行时泛型信息已经被擦除(list的类型在运行时都是List<*>),无法使用is进行类型判断 } }

【kotlin入门潜修之类和对象篇—泛型及其原理】至此,我们已经讲完了kotlin中的泛型。本篇文章有点枯燥,体会泛型的最佳路径还是在实践中多用用泛型,用多了自然而然就明白了。

    推荐阅读