¶主要内容
- 泛型函数和类的声明
- 类型擦除(erasure)和具现(reified)
- Declaration-site and use-site variance
kotlin的泛型类型并不属于类型系统上的实现,而是尽量往Java方向兼容。所以实现形式上很多概念是等价的。
¶Generic type parameters
泛型指允许你定义的类型有 泛型参数(type parameters) 。当一个该类型的实例被创建了,类型参数被替代为指定的类型称之为 type arguments 。
Kotlin的类型参数在编译器中可自动推断类型,
1 | val authors = listOf("Dmitry", "Svetlana") |
因为传递给函数listOf
的值的类型都是字符串,编译器推断创建了一个List<String>
值对象。但如果创建一个空的list,因为无法进行推断,所以此时必须显式指定类型。
1 | val readers: MutableList<String> = mutableListOf() |
这种声明是等价的。
NOTE 和Java不同,Kotlin要求类型参数要么显式指定,要么由编译器推导。但泛型是从JDK1.5才出现,为了兼容旧的版本,它允许你使用泛型类型时,可以不指定类型参数,叫做——原生类型(raw type)。例如,Java中使用List时,可以不指定类型参数。但Kotlin不同于Java,最开始的版本就提供泛型支持,它是不支持raw type的,泛型参数必须显式指定。
¶Generic functions and properties
当你编写的函数包含List集合时,需要处理泛型参数以使得List对所有类型都生效,这种带有泛型参数的函数称为 泛型函数(generic function)。
以标准库中的泛型函数slice
为例,
该函数的类型参数T
被用作接收类型和返回类型;都为List<T>
。在调用这类函数时,需要显式指定类型参数。但绝大多数情况下可以不指定,由编译器推导即可。
1 | >>> val letters = ('a'..'z').toList() |
这里的结果类型都是List<Chart>
。编译器推导T
替代为Char
作为返回类型List<T>
的表述。
上一章的高阶函数filter
可以进行编译器推导,
1 | val authors = listOf("Dmitry", "Svetlana") |
这里的it
编译器推导为String
,因为实际的接收类型是readers
,它的实际参数类型是List<String>
。
类型参数可以声明在classes、interfaces、top-level functions 或 external functions这些地方。也可以对扩展属性也使用相同的语法。例如,下面是返回最后一个元素的extension property。
1 | val <T> List<T>.penultimate: T // This generic extension property can be called on a list of any kind. |
You can’t declare a generic non-extension property
常规的属性(non-extension)不能有类型参数。因为一个类不能同时存储不同的类型值,因此声明一个泛型非扩展属性没有任何意义。你可以尝试这样做,但编译器报告错误:
1
2 val <T> x: T = TODO()
ERROR: type parameter of a property must be used in its receiver type
¶Declaring generic classes
和Java一样,Kotlin可以使用尖括号来声明泛型类或接口,一旦声明泛型类后,就可以在该类的body内使用该泛型参数。
1 | interface List<T> { // The List interface defines a type parameter T. |
泛型类的继承也和Java一致,
1 | class StringList: List<String> { // This class implements List, providing a speific type argument: String |
StringList
继承泛型了,声明了具体的类型参数,不再是泛型类,而是具体类;ArrayList
执行了泛型参数并传递给父类泛型参数,自身作为泛型类实现。
一个类也可以引用自身作为泛型参数,例如Comparable
接口,
1 | interface Comparable<T> { |
¶Type parameter constraints
Kotlin的类型约束用法和Java无异。只不过是写法上的区别。
要声明一个约束,需要在类型参数后带上冒号(😃,再跟着指定的边界类型,
等价于Java 的写法<T extends Number>
。下面的函数调用是运行的,因为Kotlin中的Int
实际上继承自Number
,
1 | >>> println(listOf(1, 2, 3).sum()) |
一旦指定了类型参数T
的边界,你可以直接使用它的上界类型定义的方法或属性:
1 | fun <T: Number> oneHalf(value: T): Double { // Specifies Number as the type parameter upper bound |
下面编写一个泛型函数查找两值中的最大值。你需要定义Comparable
的上界才能进行比较。
1 | fun <T: Comparable<T>> max(first: T, second: T): T { // The arguments of this function must be comprable elements. |
如果传入的参数类型没有继承Comprable
,编译将不通过,
1 | >>> println(max("kotlin", 42)) |
T
的上界是一个泛型类型Comparable<T>
。前面看到,String
类继承了Comparable<String>
,因此可以传入字符串类型作为参数。
记住,依据Kotlin的operator conventions的规约,first > second
实际上被编译为first.compareTo(second) > 0
。
很少的情况下需要对同一个类型参数指定多个上界的,你需要稍微使用不同的语法。例如,
1 | fun <T> ensureTrailingPeriod(seq: T) |
这个例子中,使用的泛型类型必须同时继承CharSequence
和Appendable
接口。意味着可以对其进行访问(endsWith
)和修改(append
)操作。
¶Making type parameters non-null
如果你声明一个泛型类或函数,任何类型参数,包括可空,可以被替换为它自身的类型参数。实际上,一个类型参数如果不指定上界的话,它的上界则为Any?
。考虑如下例子:
1 | class Processor<T> { |
函数process
的参数value
是可空的,尽管T
没有标记上问号。这种情况下Processor
类可以传入可用类型来替换T
:
1 | val nullableStringProcessor = Processor<String?>() // String?, which is a nullable type, is substituted for T. |
如果你想要确保一个非空类型总是被替换使用。你可以实现这种特定的边界。否则就不指定,使用默认的Any?
实现,
1 | class Processor<T: Any> { // Specifying a non-"null" upper bound |
<T: Any>
约束确保了T
类型总是不为空的参数类型。传入可空参数将发生编译错误:
1 | >>> val nullableStringProcessor = Processor<String?>() |
目前为止,Kotlin的基本泛型语法和Java类似。下面讨论运行时泛型的行为。
¶Generics at runtime: erased and reified type parameters
你可能已经知道,JVM上的泛型通过 类型擦除(type erasure) 实现,意味着一个泛型类的一个实例的类型参数在运行时是不保留的。我们将讨论Kotlin的类型确保的实际意义,以及如何应对inline
函数的限制。你可以声明一个inline
函数,这样它的类型参数不会被擦除erased(或,在Kotlin术语中,叫具现化reified)。
¶Generics at runtime: type checks and casts
和Java一样,Kotlin的泛型也是运行时擦除的。意味着一个泛型类的实例不会携带创建时的类型参数信息。例如,如果你创建了一个List<String>
,里面放了一堆字符串,在运行时你将只能看到它是一个List
。没有任何可能区分到它的实际包含的元素类型(当然,你可以获取到它的元素,校验它的类型,但不会给你任何保障,因为其它元素可能有不同类型)。
考虑如下代码并运行,
1 | val list1: List<String> = listOf("a", "b") |
尽管编译器看出来这两个list有不同的类型,但在执行期间它们实际上是一样的。尽管如此,你可以确定List<String>
仅包含字符串;List<Int>
仅包含整型,因为编译器知道类型参数并且确保元素是被正确存储的。
我们继续讨论类型擦除的约束信息。因为类型参数不会被存储,你不能校验它们——例如,你不能校验一个list是一个字符串list而不是其它呢。通常规定,你无法使用is
关键字来校验类型参数。下面代码不被编译:
1 | >>> if (value is List<String>) { ... } |
尽管在运行时你可以找出该值是一个List
,却没有告知它到底是字符串list还是其它类型的list:类型信息被擦除erased了。泛型类型信息被擦除是有好处的:总体的内存信息将被减少,因为需要尽可能少的存储类型信息到内存中。
Kotlin不允许你使用没有指定泛型参数的泛型类型。这样你可能想知道如何校验list的值,你可能会用到特定的 star projection 语法:
1 | if (value is List<*>) { ... } |
显著地,引入 *
表述它会是任何类型。我们可以认为它表示的是未知类型(类似于Java的 List<?>
)。这样一来,你就可以校验value
是否是一个List
,但仍然无法得知它的元素类型。
你仍然可以使用传统的as
和as?
进行转换。但当该类包含有正确的基础类型和错误的参数类型,类型转换不会失败,因为进行转换时并不知道具体的类型参数。正因为这样,编译器会发出"unchecked cast"这样的警告。但仅仅是警告,后面你还可以继续使用它。
1 | fun printSum(c: Collection<*>) { |
一切顺利:编译器仅产生一个告警,意味着代码是合理的(legitimate)。如果你在一个整型的list或set上调用printSum
,第一种情况工作,后面会抛出异常。如果传递错误的值类型,则抛出ClassCastException
运行期异常:
1 | >>> printSum(setOf(1, 2, 3)) // Set isn't a list, so an exception is thrown. |
Kotlin的智能转换可以在编译期校验它的类型信息,
1 | fun printSum(c: Collection<Int>) { |
这里可以校验c
是否是List<Int>
,因为编译期已经确定了该集合类型。
通常,Kotlin编译期会处理好哪些类型是危险的(禁止is
校验和抛出as
转换的告警)。你仅需要知道告警的含义并理解操作是否是安全的。
前面提及到Kotlin有办法可以指定类型参数,但仅允许在inline
内联函数使用。下面看看是怎么做到的。
¶Declaring functions with reified type parameters
前面提到,Kotlin的泛型和Java一样,都是运行期擦除的,意味着如果一个泛型类的实例,在实例被创建时无法得知它的类型参数。泛型函数也一样。当你调用一个泛型函数时,无法在调用时决定body的类型参数:
1 | >>> fun <T> isA(value: Any) = value is T |
通常这是正确的,但有一种情况可以避免这种限制:内联函数。内联函数的类型参数可以具现化,意味着你可以引用到运行期的真实的类型参数。
Kotlin的内联函数,在编译期是被替换为真实代码实现的。标记一个inline
函数可以提供性能,如果该函数使用lambda表达式作为入参的话:lambda代码被内联,匿名类被创建。这里显式inline
函数另一个有用的场景:类型参数可以被具现(reified)。
更改前面的函数isA
,标记上inline
后,类型参数作为reified
,现在你可以校验value
的实例类型T
。
1 | inline fun <reified T> isA(value: Any) = value is T // Now this code compiles. |
看看一些其它很少用到的具现化的例子。
1 | >>> val items = listOf("one", 2, "three") |
这里指定了<String>
作为类型参数。因此它返回List<String>
。这种情况下,the type argument is known at runtime ,filterIsInstance
被用来校验list实例的具体类型。
下面是它的标准库实现。
1 | inline fun <reified T> // "reified" declares that this type parameter will not be erased at runtime. |
Why reification works for inline functions only
编辑器将字节码插入到对应的内联函数的调用部分。每次调用带有reified 类型参数的函数,编译器都知道它的真实类型。因此编译器可以生成相应特定类型参数对应类的字节码。实际上,
filterIsInstance<String>
调用的等价字节码如下:
1
2
3
4
5 for (element in this) {
if (element is String) { // References a specific class.
destination.add(element)
}
}因为生成的字节码引用一个特定的类,而不是类型参数,它不会在运行时收到类型擦除的影响。
注意带有
reified
类型参数的inline
函数不能被Java所调用。常规的内联函数可以被Java作为普通函数访问——但不是内联的。带有reified类型参数的内联函数要求额外的字节码替换操作,因为它必须是内联的。所以无法从普通的方式调用它,Java代码也一样。
内联函数可以有多个具现化参数类型,也可以没有。因为内联函数带来性能提供的唯一前提是函数的入参是一个lambda表达式,但这里的filterIsInstance
没有任何入参。这种情况下,inline
函数并不是作为性能提升的原因;相反,用于类型参数具现化。
为了确保良好的性能,你仍然需要关注inline
函数编译后的内存占用大小。如果函数变得太大,最好重构提取出不依赖reified类型参数的非内联部分函数。
¶Replacing class references with reified type parameters
具现化类型参数的一个常规用法是为java.lang.Class
构建API适配器。例如来自JDK的API是ServiceLoader
,接收一个java.lang.Class
作为接口或抽象类作为参数并返回该类的一个实例。我们看看具现化类型参数如何调用实现。
要使用Java API类ServiceLoader
,可以如下调用:
1 | val serviceImpl = ServiceLoader.load(Service::class.java) |
::class.java
语法显式了如何获得一个java.lang.Class
对应的Kotlin类。实际上等价于Java的Service.class
。
我们可以使用具现化内联函数重写这个例子。
1 | val serviceImpl = loadService<Service>() |
更简短。现在该类不作为入参而是作为类型参数使用。下面重定义loadService
内联函数:
1 | inline fun <reified T> loadService() { // The type parameter is marked as "refied". |
你仍然可以使用同样的::class.java
语法在具现化类型参数上。因为它是运行期替换的。
Simplifying the startActivity function on Android
如果你是Android开发者,你可能发现另外一个更相似的例子:showing activities. 取代
java.lang.Class
进行传递,你可以使用具现化类型参数:
1
2
3
4
5
6
7 inline fun <reified T : Activity>
Context.startActivity() { // The type parameter is marked as "reified".
val intent = Intent(this, T::class.java) // Accesses the class of the type parameter as T::class
startActivity(intent)
}
startActivity<DetailActivity>() // Invokes the method to show an activity
¶Restrictions on reified type parameters
尽管具现化类型参数非常有用,但它有某些限制。某些继承了这些概念,其它的特性则取决于当前的实现又获取在将来的Kotlin版本不加约束。
更具体来说,下面是如何使用一个具现化类型参数:
- In type checks and casts(
is
、!is
、as
、as?
) - To use the Kotlin reflection APIs(
::class
) - To get the corresponding
java.lang.Class
(::class.java
) - As a type argument to call other functions
你 不可以 如下做:
- Create new instances of the class specified as a type parameter
- Call methods on the companion object of the type parameter class
- Use a non-reified type parameter as a type argument when calling a function with reified type parameter
- Mark type parameters of classes, properties, or non-inline functions as
reified
最后一个约束导致一个有趣的结果:因为具现化类型参数仅能被用于内联函数,使用一个具现化类型参数意味着函数连着所有传入的lambda都是被内联的。如果由于使用方式导致lambda不被内联,或者由于性能的原因不希望其被内联,你可以使用noinline
修改器比较不让其内联。
¶Variance: generics and subtyping
协变(variance) 的概念描述同一个基础类型在不同类型参数如何关联:例如,List<String>
和List<Any>
。首先我们将描述为什么这种关联关系的重要性,以及在Kotlin中如何表述。理解协变是编写泛型类或函数的本质:它帮助你创建API不强制用户以不方便的方式打破他们的类型安全期望。
¶Why variance exists: passing an argument to a function
假设你有一个函数接收一个List<Any>
作为参数。传递List<String>
类型变量给该函数是安全的吗?当然是安全的,因为String
类继承自Any
。但当Any
和String
变为List
接口的类型参数时,语义不再清晰。
例如,考虑一个函数打印内容到控制台。
1 | fun printContents(list: List<Any>) { |
看起来字符串可以工作。该函数把每个元素看做是Any
,然后每个字符串都是Any
,它是安全的。
现在看看另一个函数,它将修改list(所以用到MutableList
作为参数)。
1 | fun addAnswer(list: MutableList<Any>) { |
如果传递字符串list会怎样?
1 | >>> val strings = mutableListOf("abc", "bac") |
你声明一个string
的类型MutableList<String>
变量。然后传递给该函数。如果编译器接收它,你便可以在该list添加一个整数,当尝试访问这个list的内容这将导致运行时异常。正因如此,该调用无法编译。这个例子展示了传递一个MutableList<String>
作为参数但却受到一个MutableList<Any>
是不安全的;Kotlin编译器禁止这样做。
由于类型的不一致性,添加或替换list中的元素是不安全的。其它行为则是安全的。在Kotlin中,如果接口正确可以很好控制。
¶Classes, types, and subtypes
某些情况下type
和class
是等价的,某些情况下不是。
在非泛型类中,类名可以直接用作类型。例如,var x: String
直接声明的变量的类型就是String
类,并且也可以声明为可空类型,var x: String?
。
在泛型类中,要获得有效的类型,需要在运行时替换真实的类型,譬如List
不是一个类型(它是一个类),但下列的情况是有效的类型:List<Int>
、List<String?>
、List<List<String>>
,等等。每个泛型类产生一个可能的无限类型。
为了让我们讨论类型直接的关系,我们需要熟悉属于 subtype 。它的对立是 supertype 。子类型和父类型的关系有如下:
为什么一个类型到底是子类型或其它类型如此重要?编译器每次都对传递给函数的变量进行检查。考虑如下代码:
1 | fun test(i: Int) { |
将一个值存储在变量仅当该值的类型是该变量的子类型;例如,这里的i
的类型是Int
,Int
是Number
的子类型,它可以存储在n
变量中;但它不是String
的子类型,编译将不通过。
某些情况下,子类等同于子类型的特性。例如,Int
是Number
的子类,因此Int
类型是Number
类型的子类型。
非空类型是可空类型的子类型,反之则不是,因为没有包含关系。
1 | val s: String = "abc" |
诸如MutableList
这样的泛型类,它的类型参数是不可变的,对于两个不同的类型A和B,MutableList<A>
不是MutableList<B>
的子类型或父类型。在Java中,所有类型都是不可变的(invariant)。
¶Covariance: preserved subtyping relation
泛型类的协变类遵循下面规则:Producer<A>
是Producer<B>
的子类型当A
是B
的子类型。它是 子类型保留的(subtyping is preserved) 。例如,Producer<Cat>
是Producer<Animal>
的子类型,因为Cat
是Animal
的子类型。
在Kotlin 中声明一个类成为协变类,使用out
关键字在类型参数前:
1 | interface Producer<out T> { // This class is declared as covariant on T. |
使一个类的类型参数协变,可以令参数为该类的函数,入参可以不匹配函数定义。
1 | open class Animal { |
你不能令任何类都是协变的:会导致不安全。
假设一个类声明了类型参数T
并且包含一个函数使用了T
。我们说T
作为函数的返回类型时,是在out
位置。如果T
作为函数的一个参数,是在in
位置。
out
位置要求所有用到的类型参数所对应的T
仅能在声明了out
的类型关联使用。例如,Hered
类使用了T
就在out
的位置。
1 | class Herd<out T: Animal> { |
out
关键字在T
类型参数意味着:
- 子类型是被保留的(
Producer<Cat>
是Producer<Animal>
的子类型) T
仅能被用于out
位置。
现在在看下List<T>
接口。Kotlin的List
是只读的,它只有一个get
方法返回T
类型的元素,它是协变的。
1 | interface List<out T>: Collection<T> { |
注意类型参数不仅是作为参数的类型或返回类型,也可以作为其它类型的类型参数。例如,List
接口包含方法subList
返回List<T>
。
1 | interface List<out T>: Collection<T> { |
这里的T
用在subList
函数的out
位置。
但你不能声明MutableList<T>
作为协变类,因为它包含方法接收T
作为参数并返回该值(T
同时出现在in
和out
位置)。
1 | interface MutableList<T> // MutableList can't be declared a covariant on T ... |
编译器严格执行该约束。如果该类被声明为一个协变会报错误:Type parameter T is declared as 'out' but occurs in 'in' postion.
。
注意构造参数既没有in
也没有out
。即使一个类型参数被声明为out
,你仍然可以使用构造参数的声明:
1 | class Herd<out T: Animal>(vararge animals: T) { ... } |
构造函数不是方法,它在实例被创建后调用,因此不会有潜在的隐患。
你可以在构造参数使用val
或var
关键字,也可以声明一个getter和setter(mutable)。因此,类型参数用在out
位置作为只读属性,同时在out
和in
位置则作为一个可变(mutable)属性:
1 | class Herd<T: Animal>(var leadAnimal: T, vararge animals: T) { ... } |
现在可以令Herd
对T
协变,因为leadAnimal
属性是private的。
¶Contravariance: reversed subtyping relation
逆变(contravariance) 可以认为是协变(covariance)的镜像:对于逆变类,子类型关联的是它的类型参数的子类型的反面。我们以一个例子开始:Comparator
接口。该接口定义一个方法,compare
,比较两个对象:
1 | interface Comparator<in T> { |
可以看到该接口的唯一方法仅消费T
类型。意味着T
仅被用于in
位置,因此它的声明前面是in
关键字。
定义了comparator的类型值,可以对该类型的任意子类型进行比较。
1 | >>> val anyComparator = Comparator<Any> { |
意味着Comparator<Any>
是Comparator<String>
的子类型,然而Any
是String
的超类型。两种类型的子类化关系刚好是相反的。
现在对逆变准备的更充分的定义。一个类如果是逆变的,那么它是一个泛型类(以Consumer<T>
为例),它的泛型参数遵循如下:如果B
是A
的超类型,那么Consumer<A>
是Consumer<B>
的子类型。类型参数A
和B
交换位置,所以我们看到子类化是反过来的。例如,Consumer<Animal>
是Consumer<Cat>
的子类型。
下图显示了协变和逆变的类型参数子类化的不同比较。可以看到,Producer
和Consumer
的子类化关系是相反的。
in
关键字表示相应类型的值被 passed in 到该类的方法消费。类似于协变,对类型参数的约束产生特定的子类化关系。类型T
带有in
关键字意味着子类化被反转,T
仅能被用于in
位置。下标总结了可能的variance的情况。
Covariant | Contravariant | Invariant |
---|---|---|
Producer<out T> |
Consumer<in T> |
MutableList<T> |
Subtyping for the class is preserved: Producer<cat> is a subtype of Producer<Animal> |
Subtyping is reversed: Consumer<Animal> is a subtype of Consumer<Cat> |
No subtyping |
T only in out positions |
T only in in positions |
T in any position |
一个类或接口同时可以对一个类型参数协变(covariant),对另一个类型逆变(contravariant)。最经典的例子莫过于Function
接口,下面是一个类型参数的Function
定义:
1 | interface Function1<in P, out R> { |
上面使用到了operator
规约,说明它有更简单的写法(P) -> R
。这里看到P
标记为in
它是作为入参,而R
则作为返回类型,用out
标记仅能用在out
的位置。意味着对于第一个类型参数P
是逆变的、反转的;而对于第二个类型参数R
是保留的、协变的。
1 | fun enumerateCats(f: (Cat) -> Number) { ... } |
下图为其subtyping relationship。
¶Use-site variance: specifying variance for type occurrences
在类的声明上指定其可变性的能力会变得很方便,因为修改会作用到该类的使用的任何地方。这叫做 declaration-site variance 。如果你熟悉Java的通配符类型(? extends
和 ? super
),你会意识到Java处理可变性的差异。在Java中,每次你都可以使用子类型或超类型来替代。这叫做 use-site variance 。
Declaration-site variance in Kotlin vs. Java wildcards
Declaration-site variance 的方式会使得代码更加简明扼要,因为可变类仅修改一次,其它所有用到的地方都不用考虑。在Java,API的创建行为依据用户的期望,标准库总是使用通配符:
Function<? super T, ? extends R>
。Java8标准库到处都用到Function
接口,例如Stream.map
方法的声明如下,
1
2
3 public interface Stream<T> {
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
}声明地方对可变类型的一次修改即生效可使的代码更加简明和优雅。
Kotlin 支持use-site variance,允许指定当前的类型参数。
1 | fun <T> copyData(source: MutableList<T>, |
该函数将元素从一个集合copy到另一个集合。尽管两个集合都是不可变类型,但源集合仅仅用作读,目标集合仅用作写。这种情况,集合的元素类型并不需要匹配。你可以将一个字符串集合拷贝到一个Any
类型的集合内。
为了便于比较,我们在该函数引入泛型参数,
1 | fun <T: R, R> copyData(source: MutableList<T>, // Source's element type should be a subtype of the destination's element type |
这里要求destination
的元素的类型,需要是source
的元素类型的父类型,否则无法调用add()
。
不过Kotlin提供了更加优雅的方式来表述这种问题。当实现的函数内仅仅进行out
、或仅仅进行in
位置处理时,你可以直接使用可变修改器(variance modifier)定义类型参数。如下,
1 | fun <T> copyData(source: MutableList<out T>, // You can add the "out" keyword to the type usage: no methods with T in the "in" postion are used. |
你可以在一个类型声明的任意类型参数的使用上指定一个可变性修改器:参数类型、本地变量类型、函数返回类型等等。这里出现的称为 类型投影(type projection) :我们说source
不是一个常规的MutableList
,严格来说,是一个 投影(projected) 。你仅能调用那些返回泛型类型参数的方法,又或者,严格来说,仅能在out
位置使用它。编译器禁止调用那些类型用作参数的方法(禁止调用 in
位置的方法):
1 | >>> val list: MutableList<out Number> = ... |
Don’t be surprised that you can’t call some of the methods if you’re using a projected type 。如果你需要调用它们,你需要使用常规类型取代投影类型。这要求你声明第二个参数类型,依赖其投影类型。譬如上述代码的<T: R, R>
。
如果类型声明已经为out
,在使用的使用再声明为协变是没意义的。譬如List<out T>
,实际等同于List<T>
,因为List
的声明类本来就是class List<out T>
。编译器会告警这样的投影是多余的。
类似的情况也出现在逆变类型上。我们可以使用in
可变修改器表明该值是作为一个消费者,消费的类型将被反转(reversed)替换为父类型。如下,
1 | fun <T> copyData(source: MutableList<T>, |
注意:use-site variance的声明在Kotlin是跟Java的通配类型对应的。MutableList<out T>
在Kotlin等同于Java的MutableList<? extends T>
。in
投影MutableList<in T>
等同于MutableList<? super T>
。
¶Star projection: using * instead of a type argument
讨论类型检查和转换的时候,曾提及过 star-projection 这一语法用来告知 no information about a generic argument 。就是无法知道类型参数的信息。例如List<*>
,就是包含未知元素类型的集合。
首先注意,MutableList<*>
不是MutableList<Any?>
(这很重要因为这里的MutableList<T>
是不可变的T
)。一个MutableList<Any?>
告诉了你它包含的元素是任意类型。然而,MutableList<*>
包含的元素是一个特定的元素,但我不清楚它是什么。这种特定的元素类型可能是String
,也可能是其它,这样写的代码仅包含该特定类型的元素。因为你不知道类型是什么,你不能把任意东西都放进去,这会侵犯原来类型的代码调用。但可以从list获取元素,因为你知道所有存储于该list的元素,它都是Any?
的子类型,
1 | >>> val list: MutableList<Any?> = mutableListOf('a', 1, "qwe") |
为什么编译器吧MutableList<*>
引述为一个out
-project类型?在上下文,MutableList<*>
是被投影到MutableList<out Any?>
:你无法知道元素的类型,但对于获取Any?
类型的元素是安全的,而对于向里面添加元素则显得不安全。说到Java的通配类型,MyType<*>
在Kotlin是和Java的MyType<?>
相对应的。
注意 对于逆变类型参数诸如Consumer<in T>
,它的star-projection等价于<in Nothing>
。实际上,你无法调用基于该投影的任何方法。如果类型是逆变的,它仅作为一个消费者,你不知道它实际上可以消费什么。因此,你不能让它消费。
当类型参数并不重要时,你可以使用 star-projection 的语法:在签名部分你不会用到类型参数的任何方法,或者你仅仅只读数据不关心它的具体类型。例如,你可以实现printFirst
函数并接收List<*>
作为参数:
1 | fun printFirst(list: List<*>) { // Every list is a possible argument. |
如果是作为use-site variance,需要引入泛型类型参数:
1 | fun <T> printFirst(list: List<T>) { // Again, every list is a possible argument. |
带有星投影的语法更加简明,但它仅工作在你不需要关心实际泛型类型参数的情况:仅能使用产生该值的方法,不关心这些值的类型。
下面是另外一个星投影的例子,假设我们要验证用户输入,并声明FieldValidator
。它包含的类型仅在in
位置,因此它可以声明为逆变。
1 | interface FieldValidator<in T> { // Interface declared as contravariant on T |
想象你希望在相同的容器存储所有的validator,并根据输入类型获取正确的validator。最先你可能会用一个map来存储。并为任意类型存储validator,你可能需要声明一个KClass
类型的map:
1 | >>> val validators = mutableMapOf<KClass<*>, FieldValidator<*>>() |
一旦这样做了,当使用这些validator时会觉得有困难。你不能用FieldValidator<*>
来校验字符串。这不是类型安全的,因为编译器不知道它是哪种validator:
1 | >>> validators[String::class]!!.validate("") // The value stored in the map has the type FieldValidator<*>. |
这类错误在以前介绍MutableList<*>
描述过了。这类错误意味着输入特定类型到一个未知类型的入口,不是类型安全的。一种方式是,显式转换为指定的类型,但它既不是类型安全的、也不推荐这样做,不过下面可以列出代码作为参考一下:
1 | >>> val stringValidator = validators[String::class] as FieldValidator<String> // Warning: unchecked cast |
编译器发出告警关于unchecked 的转换。注意,这段代码仅会在校验时失败,不是在转换的地方,因为运行时所有泛型信息都被擦除。
1 | >>> val stringValidator = validators[Int::class] as FieldValidator<String> // You get an incorrect validator(may be by mistake), but this code compiles. |
这段代码在编译期仅有一个告警,运行期由于泛型擦除,出现错误。
这种方案不是类型安全的(type-safe)、并且容易出错(error-prone)。因此,需要探讨一下其它可能的方案。
下面代码使用同一个validators
map,但封装了所有的访问到两个泛型方法,仅包含正确的validator的注册和返回。该代码对未校验的转换会发出一个告警,但这里的Validators
控制所有的访问,并确保不可能错误修改map。
1 | object Validators { |
现在你有了一个type-safe API。所有的unsafe逻辑被隐藏在类的body部分;通过本地化实现,确保了它不会被错误使用。编译器禁止你使用不正确的validator,因为Validators
对象总是给定了正确的validator的实现:
1 | >>> println(Validators[String::class].validate(42)) // Now the "get" method returns an instance of FieldValidator<String>. |
严格来说,这是一种设计模式,并不算是Kotlin的特性内容。实现这种模式可以非常容易地对自定义泛型类型进行扩展。将unsafe的代码进行本地化分离避免误用。
Java的泛型和协变性通常被认为是该语言最棘手的部分。在Kotlin中,在Kotlin中我们尽最大努力使其易于理解和易于使用,同时有保留对Java的互操作性。
¶Summary
- Kotlin的泛型和Java的泛型非常相似:你可以以同样的方式声明泛型函数或类。
- 在Java,泛型类型的类型参数(type parameter)仅存在于编译期。
- 在使用类型时,不能同时将类型参数和
is
操作符一起使用,因为 类型参数是运行期擦除的。 - 类型参数和内联函数一起使用时可以标记上
reified
,它允许你在运行时操作is
的校验以及获得java.lang.Class
实例。 - Variance is a way to specify whether one of two generic types with the same base class and different type arguments is a subtype or a supertype of the other one if one of the type arguments is the subtype of the other one.
- 你可以通过声明在类型参数上的类作为协变(covariant),当且仅当该类型参数被用在
out
位置。 - 逆变(contravariant)则是相反:声明的类的类型参数仅用在
in
位置。 - 只读接口
List
在kotlin被声明为协变,意味着List<String>
是List<Any>
的子类型。 Function
类的接口声明就是第一个类型参数声明为逆变(contravariant),以及第二个类型参数声明为协变(covariant),可以使得(Animal) -> Int
是(Cat) -> Number
的类型子类。- Kotlin可以让你同时对泛型类(declaration-site variance)和特定泛型类型(use-site variance)指定可变性(variance)。
- 星投影(star-projection)语法被用于类型参数未知或不重要/不敏感的情况。