十三、关于类型安全:泛型

泛型是一个术语,用来表示一组允许我们向类型添加类型参数的语言特性。例如,考虑一个简单的类,它具有以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,任何其他名称都可以用于类型参数,但是在许多项目中,您经常会发现TRSUAB作为类型参数名称。

为了实例化这样的类,编译器必须知道该类型。要么必须显式指定类型,如

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。使用示例IntIntDoubleString类型元素创建一个实例。

声明方差异

如果我们谈论泛型,术语方差表示在赋值中使用更具体或更不具体类型的能力。知道了AnyString更不具体,方差就出现在以下问题中:是否可能出现以下情况之一:

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()可以同时计算为AnyString类型,因为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")

应该也不成问题。我们可以给ab添加字符串。

为了使这种差异成为可能,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!

因此,通过将inout variance 注释添加到类型参数中,并限制类操作只允许泛型类型的输入或泛型类型的输出,在 Kotlin 中就有可能出现差异!如果两者都需要,可以使用不同的构造,如本章后面的“类型投影”一节所述。

注意

类的out方差也被称为协方差,而in方差被称为方差

名称声明方差异源于在类的声明中声明inout差异。其他语言,比如 Java,使用一种不同类型的方差,这种方差在使用类时生效,因此被称为使用方方差。

不可变集合的差异

因为不可变集合不能被写入,Kotlin 自动使它们协变。如果您愿意,可以考虑将 Kotlin 的out variance 注释隐式添加到不可变集合中。

由于这个事实,一个List<SomeClass>可以被分配给一个List<SomeClassSuper>,其中SomeClassSuperSomeClass的超类。例如:

val coll1 = listOf("A", "B") // immutable
val coll2:List<Any> = coll1  // allowed!

类型投影

在上一节中,我们看到对于out样式变化,相应的类不允许使用泛型类型作为函数参数,对于in样式变化,我们相应地不能使用返回泛型类型的函数。当然,如果我们在一个类中需要两种功能,这是不令人满意的。Kotlin 也有这类需求的答案。它被称为型投影,因为它的目标是在使用一个类的不同函数时的方差,所以它是使用方方差的 Kotlin 等价物。

想法如下:我们仍然使用inout方差注释,但是我们没有为整个类声明它们,而是将它们添加到函数参数中。我们稍微改写了上一节的示例,并添加了inout方差注释:

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投影才起作用。

恒星投影

如果您有一个带有inout方差注释的类或接口,您可以使用特殊的通配符*,其含义如下:

  • 对于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或它的任何子类中,比如IntDouble

这很有用。例如,考虑一个允许我们向Double属性添加内容的类。

class Adder<T> {
    var v:Double = 0.0
    fun add(value:T) {
        v += value.toDouble()
    }
}

你明白为什么这个代码是非法的吗?我们说value的类型是T,但是类不知道在实例化过程中T是什么,所以不清楚T.toDouble()函数是否实际存在。因为我们知道在编译之后所有的类型都被删除了,编译器没有机会检查是否有一个toDouble(),因此它将代码标记为非法。如果你查看 API 文档,你会发现IntLongShortByteFloatDouble都是kotlin.Number的子类,它们都有一个toDouble()函数。如果我们有办法说TNumber或者它的子类,我们就可以使代码合法。

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)。每次调用函数时,必须将参数添加到列表中,并且必须根据列表属性的自然排序顺序对其进行排序。