稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

这回就好好聊聊Kotlin的泛型

泛型(Generics)是静态强类型编程语言中非常强大的特性,可以极大的加强代码的复用,并增强类型安全,减少运行时的类型转换错误。在这篇文章就来详细的学习一下Kotlin中对泛型的支持情况,并学会写出类型安全的可复用代码。

泛型基础

泛型的本质就是能够创建参数化的对象和函数,以实现复用。比如说,我们最熟悉的集合List,它是与具体类型无关的数据结构,或者叫做对象容器。列表List的重点在于可扩展长度,但里面具体的对象类型并不是重点,只要是一个对象就可以了。假如没有泛型,可能就要写很多重复的代码,比如字符串列表StringList,数字列表NumberList,等等。用泛型,只用一个参数化的List就可以了,用尖括号<>来表示参数化。

1
2
val names: List<String> = listOf("James", "Kevin", "Harden")
val rebounds: List<Int> = listOf(2, 14, 7)

泛型有两种形式,一种是对类进行参数化如List,一种是对函数进行参数化,如max()。

参数化的类

声明方式就是在声明类的时候在类的名字后面用尖括号<>来带上一个类型参数,然后在内部就可以当成一个类型来使用:

1
2
3
class Box<T>(t: T) {
  var value = t
}

这就创建了一个参数化的容器,它可以持有任何指定类型的对象:

1
2
val box: Box<Int> = Box<Int>(1)
val case: Box<String> = Box<String>("Coat")

参数化的函数

除了参数化的类以外,还可以创建参数化的函数,在函数名字的前面用尖括号<>来声明泛型,然后在参数列表以及函数体内就可以当作类型来使用:

1
2
3
fun <T> singleTonList(item: T): List<T> {
  ...
}

调用的时候指定一下具体的类型就可以了:

1
val l = singletonList<Int>(3)

注意:Kotlin语言有强大的类型推断能力,但凡编译器能够推断出类型时,类型的声明都可以省略掉。对于泛型更是如此,比如说,这样写都是合法的:

1
2
3
val names = listOf("James", "Kevin", "Harden")
val rebounds = listOf(2, 14, 7)
val l = singletonList(3)

通常情况下,声明定义赋值三个地方,只要有一个地方能够让编译器知道具体的类型就够了,其他地方都可以把类型的声明省略掉。

泛型的本质与优点

假如不使用泛型,又想写出比较通用的类和函数,唯一可行的方法就是使用通用基类Any当作参数,在Kotlin中Any是所有对象的基类,比如,说想实现一个列表:

1
2
3
4
class AnyList {
  fun add(item: Any)
  fun get(idx: Int): Any
}

这样写可以,但它有很大的问题,就是不能保证类型安全:

1
2
3
4
val list = AnyList()
list.add("James")
list.add(13)
val e = (Int) list.get(1)

一方面我们需要自己进行强行类型转换,但也无法保证你取出来的对象类型与期望的是一致的,更无法保证调用者往里面添加什么对象,因为任何Any的子类都可以让代码通过编译,但在运行时极容易发生类型转换异常ClassCastException。

但用泛型就能很好的解决这个问题,可以得出泛型的优点:

  1. 不需要做类型转换,编译器会根据指定的具体类型自动做类型转换
  2. 类型安全,编译器会帮助做检查,传给泛型的对象必须具有一致的类型,且是指定的类型
  3. 保障了运行时的类型安全,因为编译器在编译时做好了检查,所以不会发生运行时的类型错误

因此,凡是有需要针对 类型复用的地方,都应该用泛型来实现类型参数化。

关键字out和关键字in

大部分情况下,只要给类型和函数加上参数化的类型就够了,但有时候有些复杂情况需要处理。

协变与逆变

协变与逆变Covariance and Contravariance是用来描述具有父子继承关系的简单类型,在通过参数化的方法构造出来的更复杂的类型之间是否能保持父子关系的术语。

比如Dog是Animal的子类,根据继承和多态,Dog可以用在任何声明为Animal的语句和表达式中。变型Variance指的就是根据已知的父子关系Dog和Animal,如何来确定由它们构成的更复杂类型如List<Dog>和List<Animal>之间的关系?

常规泛型是不可变的Invariant,也就是说复杂类型之间的关系与它们具体的参数化类型之间是没有关系的,如List<Dog>并不是List<Animal>,它们之间没有任何关系,不能把List<Dog>当成是List<Animal>,虽然Dog可以被当作Animal。

不可变Invariant有时候会带来不方便,比如说,集合通常都有addAll方法来批量的把对象加入到集合中:

1
2
3
4
5
6
7
8
9
10
class List<T> {
  fun addAll(from: List<T>) {
      for (x in from) {
          add(x)
      }
  }
}
val objs: List<Any> = emptyList()
val names: List<String> = listOf("James", "Kevin", "Harden")
objs.addAll(names) // No go, compile error

这是参数化列表集合,先创建一个具体类型为Any的列表,然后尝试把一个String列表添加到Any列表中,其实这么做是完全安全的,因为String对象是完全可以当作其基类Any来使用的,但泛型的不可变性阻止了我们这么做。

这时就需要协变逆变了,也就是通过一定的方法让复杂类型的行为与其参数化类型之间进行协同。

关键字out进行协变

使用out关键能够让泛型进行协变。比如上面例子理想的情况应该是,只要能当作T的类型,都应该能用在addAll中,换句话说把T的子类的列表也应该能够支持,即objs.addAll(names)应该能正常编译并正常运行。使用关键out即可达到这样的效果:

1
2
3
4
5
6
7
8
9
10
class List<out T> {
  fun addAll(from: List<T>) {
      for (x in from) {
          add(x)
      }
  }
}
val objs: List<Any> = emptyList()
val names: List<String> = listOf("James", "Kevin", "Harden")
objs.addAll(names) // Okay

这里的泛型参数from: List其实是一个生产者,它生产类型为T的对象,所以这里用out来修饰,产出的对象是T或者是T的子类都是会是合法的。或者说当我们想把一个子类的泛型赋给父类的泛型时,就需要对泛型声明为out,以进行协变。

注意:关键字out与Java泛型中的extend通配符的作用是一样的,指定参数的上限,生产者产生的对象都会向上转型(upcast)为基类,所以需要指定一个上限。

与之相对的,还有in逆变。

关键字in进行逆变

有时候情况是相反的,也就是说我们持有的是父类的泛型,但 我们想把它赋给其子类的泛型,这时就可以用in进行逆变。而且必须注意in只能用在消费者中,也就是说是在真实消费对象,为什么呢?其实这里真实发生的是向下转型(downcast)–把父类的对象赋给子类的引用上面,而向下转型不一定保证是安全的。所以,必须是在真实消费这个对象的地方,只有是期望的真实对象才能被消费。

1
2
3
4
5
6
7
8
9
class ParameterizedConsumer<in T> {
    fun toString(value: T): String {
        return value.toString()
    }
}

val parameterizedConsumer = ParameterizedConsumer<Number>()

val ref: ParameterizedConsumer<Double> = parameterizedConsumer

注意:关键字in与Java泛型中的super是一样的,指定一个下限,因为在消费对象时会转成T,用T来限制成为下限,那么向下转型(downcast)就是安全的。

任意类型的泛型

有些比较简单粗暴的场景,就是单纯的想让任意类型的泛型都可以使用,这时关键字out和关键字in可能都不太合适,因为它们只能用于生产者和消费者场景,用以指定类型上限和类型下限。这时可以用星号*来当用泛型参数,以表示任意具体类型的泛型都可以使用。

1
2
3
4
5
6
fun printArray(array: Array<*>) {
    array.forEach { println(it) }
}

val array = arrayOf(1,2,3)
printArray(array)

关键字reified

运行时泛型擦除

需要注意的是泛型类型在运行时会被擦除(erased),也就是说在运行时任何对象都是不带有其泛型类型的,具体点的,就是List<String>和List<Int>在运行时,它们的对象实例是一样的,无法知道它们的具体的泛型参数类型。前面讲的各种规则都是发生在编译时间,编译器帮助检查传入的泛型对象是否符合规划,并进行类型转换。到了运行时,泛型类型会被擦除。(为啥会被擦除呢?因为JVM要保持向后兼容,早期的Java没有泛型,只有原始的类型对象(raw type),所以后来1.5版本后加入的泛型只有擦除掉变成raw type才能保持兼容。)

关键字reified

泛型类型擦除会带来一个问题,就是对于泛型类型对象,无法做类型检查(is T),无法做类型转换(as T),因为运行时的对象根本不知道它的泛型类型是什么,这会带来极大的不方便,特别是工厂方法就无法使用泛型了,因为无法做类型检查 和转换。

这时inline再加上关键字reified就能完美的解决问题,它们两个配合起来运行时就能保留泛型类型了:

1
2
3
4
5
inline fun <reified T> Iterable<*>.filterIsInstance() = filter { it is T }

>> val set = setOf("1984", 2, 3, "Brave new world", 11)
>> println(set.filterIsInstance<Int>())
[2, 3, 11]

可以看到类型判断起来作用了。再看一个泛型工厂方法的例子:

1
2
3
4
5
6
inline fun <reified T> logger(): Logger = LoggerFactory.getLogger(T::class.java)

class User {
    private val log = logger<User>()
    // ...
}

练习

这里强烈推荐谷歌官方给出的关于Kotlin语言中的类型相关的小练习,可以用来巩固加强一下所学的知识。

参考资料

Comments