十三、关于类型安全:泛型
泛型是一个术语,用来表示一组允许我们向类型添加类型参数的语言特性。例如,考虑一个简单的类,它具有以Int
对象的形式添加元素的功能:
class AdderInt {
fun add(i:Int) {
...
}
}
另一个用于String
对象:
class AdderString {
fun add(s:String) {
...
}
}
除了在add()
函数内部发生的事情之外,这些类看起来非常相似,所以我们可以考虑一个语言特性来抽象要添加的元素的类型。这样的语言特性存在于 Kotlin 中,它被称为泛型。相应的结构如下:
class Adder<T> {
fun add(toAdd:T) {
...
}
}
其中T
是类型参数。在这里,除了T
,任何其他名称都可以用于类型参数,但是在许多项目中,您经常会发现T
、R
、S
、U
、A
或B
作为类型参数名称。
为了实例化这样的类,编译器必须知道该类型。要么必须显式指定类型,如
class Adder<T> {
fun add(toAdd:T) {
...
}
}
val intAdder = Adder<Int>()
val stringAdder = Adder<String>()
或者编译器必须能够推断类型,如
class Adder<T> {
fun add(toAdd:T) {
...
}
}
val intAdder:Adder<Int> = Adder()
val stringAdder:Adder<String> = Adder()
注意
泛型是编译时构造。在编译器生成的代码中,不会出现泛型信息。这种效应通常被称为型擦除。
我们已经在书中多次使用了这种通用类型。您可能还记得,作为两个数据元素的持有者,我们讨论过参数化的Pair
类型:
val p1 = Pair<String, String>("A", "B")
val p2 = Pair<Int,String>(1, "A")
当然,我们也谈到了各种集合类型,例如:
val l1: List<String> = listOf("A","B","C")
val l2: MutableList<Int> = mutableListOf(1, 2, 3)
到目前为止,我们只是照原样接受了泛型,没有进一步解释它们。毕竟写List<String>
,我们说的一串的推演是显而易见的。
一旦我们开始更彻底地审视收藏品,这个故事就变得有趣了。问题是:如果我们有一个MutableList<Any>
和一个MutableList<String>
,它们是如何关联的?我们可以写val l:MutableList<Any> = mutableListOf<String>("A", "B")
吗?或者换句话说,MutableList<-String>
是MutableList<Any>
的子类吗?事实并非如此,在本章的剩余部分,我们将深入讨论泛型,并试图理解类型关系。
简单泛型
首先,让我们解决基本问题。要对类或接口进行类型参数化,可以在类型名后的尖括号内添加一个逗号分隔的形式类型参数列表:
class TheClass<[type-list]> {
[class-body]
}
interface TheInterface<[type-list]> {
[interface-body]
}
在类或接口内部,包括任何构造函数和init{}
块,你可以像其他类型一样使用类型参数。例如:
class TheClass<A, B>(val p1: A, val p2: B?) {
constructor(p1:A) : this(p1, null)
init {
var x:A = p1
...
}
fun function(p: A) : B? = p2
}
练习 1
类似于Pair
类,创建一个可以保存四个数据元素的类Quadruple
。使用示例Int
、Int
、Double
和String
类型元素创建一个实例。
声明方差异
如果我们谈论泛型,术语方差表示在赋值中使用更具体或更不具体类型的能力。知道了Any
比String
更不具体,方差就出现在以下问题中:是否可能出现以下情况之一:
class A<T> { ... }
var a = A<String>()
var b = A<Any>()
a = b // variance?
... or ...
b = a // variance?
为什么这对我们很重要?如果我们看看类型安全,这个问题的答案就变得很清楚了。考虑下面的代码片段:
class A<T> {
fun add(p:T) { ... }
}
var a = A<String>()
var b = A<Any>()
b = a // variance?
b.add(37)
将37
添加到A<Any>
不会造成问题,因为任何类型都是Any
的子类。然而,因为b
通过b = a
指向了A<String>
的一个实例,我们会得到一个运行时错误,因为37
不是一个字符串。Kotlin 编译器认识到了这个问题,不允许使用b = a
赋值。
同样,分配a = b
也会带来一个问题。这一点更加明显,因为a
只适用于String
元素,不能像b
那样处理Int
类型的值。
class A<T> {
fun extract(): T = ...
}
var a = A<String>()
var b = A<Any>()
a = b // variance?
val extracted:String = a.extract()
最后一条语句中的a.extract()
可以同时计算为Any
和String
类型,因为b
和现在的a
可以包含Int
对象,但是a
不允许包含Int
对象,因为它只能处理String
元素。因此 Kotlin 也不允许a = b
。
我们能做什么?不允许任何差异可能是一种选择,但这太苛刻了。同样,查看分配了b = a
的第一个样本,我们可以看到写入b
导致了错误。读书怎么样?考虑一下这个:
class A<T> {
fun extract(): T = ...
}
var a = A<String>()
var b = A<Any>()
b = a // variance?
val extracted:String = b.extract()
就类型而言,最后一个操作是安全的,所以我们实际上在这里应该不会有问题。
完全相反的情况,取a = b
样本并应用写操作而不是读操作,如
class A<T> {
fun add(p:T) { ... }
}
var a = A<String>()
var b = A<Any>()
a = b // variance?
a.add("World")
应该也不成问题。我们可以给a
和b
添加字符串。
为了使这种差异成为可能,Kotlin 允许我们向通用参数添加一个差异注释。如果我们将out
注释添加到类型参数中,第一个带有b = a
的示例会编译:
class A<out T> {
fun extract(): T = ...
}
var a = A<String>()
var b = A<Any>()
b = a // variance? YES!
val extracted:String = b.extract()
// OK, because we are reading!
如果我们将in
注释添加到类型参数中,第二个带有a = b
的示例将会编译:
class A<in T> {
fun add(p:T) { ... }
}
var a = A<String>()
var b = A<Any>()
a = b // variance? YES!.add("World")
// OK, because we are writing!
因此,通过将in
或out
variance 注释添加到类型参数中,并限制类操作只允许泛型类型的输入或泛型类型的输出,在 Kotlin 中就有可能出现差异!如果两者都需要,可以使用不同的构造,如本章后面的“类型投影”一节所述。
注意
类的out
方差也被称为协方差,而in
方差被称为方差 。
名称声明方差异源于在类的声明中声明in
或out
差异。其他语言,比如 Java,使用一种不同类型的方差,这种方差在使用类时生效,因此被称为使用方方差。
不可变集合的差异
因为不可变集合不能被写入,Kotlin 自动使它们协变。如果您愿意,可以考虑将 Kotlin 的out
variance 注释隐式添加到不可变集合中。
由于这个事实,一个List<SomeClass>
可以被分配给一个List<SomeClassSuper>
,其中SomeClassSuper
是SomeClass
的超类。例如:
val coll1 = listOf("A", "B") // immutable
val coll2:List<Any> = coll1 // allowed!
类型投影
在上一节中,我们看到对于out
样式变化,相应的类不允许使用泛型类型作为函数参数,对于in
样式变化,我们相应地不能使用返回泛型类型的函数。当然,如果我们在一个类中需要两种功能,这是不令人满意的。Kotlin 也有这类需求的答案。它被称为型投影,因为它的目标是在使用一个类的不同函数时的方差,所以它是使用方方差的 Kotlin 等价物。
想法如下:我们仍然使用in
和out
方差注释,但是我们没有为整个类声明它们,而是将它们添加到函数参数中。我们稍微改写了上一节的示例,并添加了in
和out
方差注释:
class Producer<T> {
fun getData(): Iterable<T>? = null
}
class Consumer<T> {
fun setData(p:Iterable<T>) { }
}
class A<T> {
fun add(p:Producer<out T>) { }
fun extractTo(p:Consumer<in T>) { }
}
add()
函数中的out
表示我们需要一个产生T
对象的对象,extractTo()
函数中的in
表示一个消耗T
对象的对象。让我们看一些客户端代码:
var a = A<String>()
var b = A<Any>()
var inputStrings = Producer<String>()
var inputAny = Producer<Any>()
a.add(inputStrings)
a.add(inputAny) // FAILS!
b.add(inputStrings) // only because o "out"
b.add(inputAny)
var outputAny = Consumer<Any>()
var outputStrings = Consumer<String>()
a.extractTo(outputAny) // only because of "in" a.extractTo(outputStrings)
b.extractTo(outputAny)
b.extractTo(outputStrings) // FAILS!
你可以看到a.add(inputAny)
失败了,因为inputAny
产生了各种各样的对象,而a
只能接受String
对象。类似地,b.extractTo(outputStrings)
失败,因为b
包含任何类型的对象,而outputStrings
只能接收String
对象。到目前为止,这与方差无关。这个故事对b.add(inputStrings)
来说变得有趣了。允许将字符串添加到A<Any>
的行为当然是有意义的,但是它只在我们将out
投影添加到函数参数时才起作用。类似地,a.extractTo(outputAny)
虽然肯定是可取的,但只是因为in
投影才起作用。
恒星投影
如果您有一个带有in
或out
方差注释的类或接口,您可以使用特殊的通配符*
,其含义如下:
-
对于
out
差异标注,*
表示out Any?
。 -
对于
in
差异标注,*
表示in Nothing
。
记住Any
是任何类的超类,Nothing
是任何类的子类。
例如:
interface Interf<in A, out B> {
...
}
val x:Interf<*, Int> = ...
// ... same as Interf<in Nothing, Int>
val y:Interf<Int, *> = ...
// ... same as Interf<Int, out Any?>
如果您对类型一无所知,但仍然希望满足类或接口声明规定的差异语义,则可以使用星号通配符。
通用函数
Kotlin 中的函数也可以是泛型的,这意味着它们的参数或它们的一些参数可以具有泛型类型。在这种情况下,通用类型指示符必须作为逗号分隔的列表添加到function
关键字之后的尖括号中。泛型类型也可以出现在函数的返回类型中。这里有一个例子。
fun <A> fun1(par1:A, par2:Int) {
...
}
fun <A, B> fun2(par1:A, par2:B) {
...
}
fun <A> fun3(par1:String) : A {
...
}
fun <A> fun4(par1:String) : List<A> {
...
}
要调用这样的函数,原则上必须在尖括号中的函数名称后指定具体类型:
fun1<String>("Hello", 37)
fun2<Int, String>(37, "World")
val s:String = fun3<String>("A")
然而,正如 Kotlin 中经常出现的情况,如果 Kotlin 可以推断类型,则可以省略类型参数。
通用约束
到目前为止,对于泛型类型标识符在实例化期间可以映射到的类型没有任何限制。因此,在class TheClass<T>
中,T
通用类型可以是任何东西,TheClass<Int>
、TheClass<String>
、TheClass<Any>
或其他任何东西。但是,可以将类型限制为某个类或接口或其子类型之一。为了这个目标,你写道
<T : SpecificType>
印度历的 7 月
class <T : Number> { ... }
它将T
限制在一个Number
或它的任何子类中,比如Int
或Double
。
这很有用。例如,考虑一个允许我们向Double
属性添加内容的类。
class Adder<T> {
var v:Double = 0.0
fun add(value:T) {
v += value.toDouble()
}
}
你明白为什么这个代码是非法的吗?我们说value
的类型是T
,但是类不知道在实例化过程中T
是什么,所以不清楚T.toDouble()
函数是否实际存在。因为我们知道在编译之后所有的类型都被删除了,编译器没有机会检查是否有一个toDouble()
,因此它将代码标记为非法。如果你查看 API 文档,你会发现Int
、Long
、Short
、Byte
、Float
和Double
都是kotlin.Number
的子类,它们都有一个toDouble()
函数。如果我们有办法说T
是Number
或者它的子类,我们就可以使代码合法。
Kotlin 确实有一种方法来限制泛型类型,它读起来是<T : SpecificType>
。因为T
然后被限制在SpecificType
或者它在类型层次结构中更低的任何子类型,这也被称为upper type bound
。为了使我们的Adder
类合法,我们所要做的就是写
class Adder<T : Number> {
var v:Double = 0.0
fun add(value:T) {
// T is a Number, so it _has_ a toDouble()
v += value.toDouble()
}
}
这种类型约束也可以添加到泛型函数中,所以我们实际上可以将Adder
类重写为:
class Adder {
var v:Double = 0.0
fun <T:Number> add(value:T) {
v += value.toDouble()
}
}
这具有特别的优点,即在实例化期间不需要解析泛型类型。
val adder = Adder()
adder.add(37)
adder.add(3.14)
adder.add(1.0f)
请注意,与类继承不同,类型界限可以多次声明。这在尖括号内是不可能发生的,但是有一个特殊的构造来处理这种情况。
class TheClass<T> where T : UpperBound1,
T : UpperBound2, ...
{
...
}
或者
fun <T> functionName(...) where T : UpperBound1,
T : UpperBound2, ...
{
...
}
对于一般函数。
你可能不得不习惯的一点是,泛型类可能出现在冒号(:
)的两边,这是完全可以接受的
class TheClass <T : Comparable<T>> {
...
}
来表示 T 必须是Comparable
的子类。
练习 2
用类型参数T
和合适的类型绑定编写一个泛型类Sorter
,它有一个属性val list:MutableList<T>
和一个函数fun add(value:T)
。每次调用函数时,必须将参数添加到列表中,并且必须根据列表属性的自然排序顺序对其进行排序。
版权属于:月萌API www.moonapi.com,转载请注明出处