MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Kotlin泛型约束与变型

2021-09-295.5k 阅读

Kotlin泛型约束基础

在Kotlin中,泛型约束允许我们对类型参数进行限制,确保类型参数满足特定的条件。这在很多场景下非常有用,比如我们希望传入的类型参数必须具备某种特定的行为或者继承自某个特定的类。

上界约束(where 关键字之前的简单形式)

最常见的泛型约束方式是使用 : 来指定类型参数的上界。这意味着类型参数必须是指定类型或者是它的子类型。

class Box<T : Number> {
    private var value: T? = null

    fun setValue(value: T) {
        this.value = value
    }

    fun getValue(): T? {
        return value
    }
}

在上述代码中,Box 类的类型参数 T 被约束为 Number 类型或者 Number 的子类型。这意味着我们只能创建 Box 的实例,其中 TIntDoubleFloatNumber 的子类型。

val intBox = Box<Int>()
intBox.setValue(10)
val intValue = intBox.getValue()

val stringBox: Box<String> = Box() // 这会报错,因为 String 不是 Number 的子类型

这样的约束保证了在 Box 类内部可以安全地使用 T 类型的对象,因为我们知道 T 一定具有 Number 类型的特性和方法。

更复杂的上界约束(where 关键字)

有时候,我们需要对类型参数施加多个约束条件。这时就可以使用 where 关键字。

fun <T> printIfGreaterThanZero(value: T) where T : Number, T : Comparable<T> {
    if (value > 0) {
        println(value)
    }
}

在上述函数中,类型参数 T 被约束为既是 Number 的子类型,又必须实现 Comparable<T> 接口。这样我们才能在函数内部对 T 类型的 value 进行与 0 的比较操作。

printIfGreaterThanZero(5) // 正常,Int 满足约束
printIfGreaterThanZero("hello") // 报错,String 不满足约束

where 关键字让我们可以清晰地罗列多个约束条件,使得代码逻辑更加明确。

泛型变型

泛型变型处理的是类型参数化类型之间的子类型关系。在Kotlin中,有两种主要的变型类型:协变和逆变。

协变(out 关键字)

协变允许我们在类型参数化类型之间建立一种 “父子” 关系,使得一个类型参数化类型的子类型可以被当作其父类型使用。我们使用 out 关键字来声明协变。

interface Producer<out T> {
    fun produce(): T
}

class StringProducer : Producer<String> {
    override fun produce(): String {
        return "Hello, Kotlin!"
    }
}

class AnyProducer : Producer<Any> {
    override fun produce(): Any {
        return "Any value"
    }
}

fun consume(producer: Producer<Any>) {
    val result = producer.produce()
    println(result)
}

在这里,Producer 接口使用 out 关键字声明了类型参数 T 是协变的。这意味着如果 StringAny 的子类型,那么 Producer<String> 就是 Producer<Any> 的子类型。

val stringProducer = StringProducer()
consume(stringProducer) // 这是合法的,因为 Producer<String> 是 Producer<Any> 的子类型

协变的本质在于,它只允许我们从泛型类型中读取数据(就像 produce 方法那样),而不允许写入数据。因为如果允许写入,就可能破坏类型安全。例如,如果 Producer<Any> 可以接受 Producer<String> 的实例,并且允许写入,那么就可能把非 String 类型的数据写入到原本期望 String 类型数据的地方。

逆变(in 关键字)

逆变与协变相反,它允许我们在类型参数化类型之间建立一种 “子父” 关系,使得一个类型参数化类型的父类型可以被当作其子类型使用。我们使用 in 关键字来声明逆变。

interface Consumer<in T> {
    fun consume(value: T)
}

class AnyConsumer : Consumer<Any> {
    override fun consume(value: Any) {
        println("Consumed: $value")
    }
}

class StringConsumer : Consumer<String> {
    override fun consume(value: String) {
        println("Consumed string: $value")
    }
}

fun provide(consumer: Consumer<String>) {
    consumer.consume("Some string")
}

在上述代码中,Consumer 接口使用 in 关键字声明了类型参数 T 是逆变的。这意味着如果 AnyString 的父类型,那么 Consumer<Any> 就是 Consumer<String> 的子类型。

val anyConsumer = AnyConsumer()
provide(anyConsumer) // 这是合法的,因为 Consumer<Any> 是 Consumer<String> 的子类型

逆变的本质在于,它只允许我们向泛型类型中写入数据(就像 consume 方法那样),而不允许从泛型类型中读取数据。因为如果允许读取,就可能返回不符合预期类型的数据。例如,如果 Consumer<String> 可以接受 Consumer<Any> 的实例,并且允许读取,那么就可能读取到非 String 类型的数据。

星投影(*

星投影是Kotlin泛型中的一个特殊概念,用于在我们不知道类型参数的具体类型时,提供一种安全的方式来使用泛型类型。

星投影的基本概念

当我们使用星投影 * 时,它表示我们不知道类型参数的具体类型,但我们可以根据变型规则来推断其行为。

对于一个协变类型 Producer<out T>Producer<*> 表示 Producer<out Nothing>,这意味着我们只能从 Producer<*> 中读取数据,并且读取到的数据类型是 Nothing 的超类型(也就是 Any?)。

对于一个逆变类型 Consumer<in T>Consumer<*> 表示 Consumer<in Any>,这意味着我们可以向 Consumer<*> 中写入任何类型的数据。

星投影的应用场景

假设我们有一个函数,它接受一个 Producer,但我们不知道 Producer 具体生产什么类型的数据。

fun printProducerValue(producer: Producer<*>) {
    val value = producer.produce()
    println(value)
}

在这里,producerProducer<*> 类型,我们可以安全地调用 produce 方法,因为星投影保证了我们可以读取数据,虽然我们不知道具体的数据类型,但返回的数据是 Any? 类型,这在大多数情况下是安全的。

再比如,对于一个接受 Consumer 的函数,如果我们不知道 Consumer 具体消费什么类型的数据:

fun sendToConsumer(consumer: Consumer<*>, value: Any) {
    consumer.consume(value)
}

这里 consumerConsumer<*> 类型,我们可以向其传递任何 Any 类型的数据,因为星投影在逆变类型上允许这样的写入操作。

泛型约束与变型的综合应用

在实际的开发中,我们经常会遇到需要同时使用泛型约束和变型的场景。

结合约束与协变

假设我们有一个 Cache 接口,它用于缓存数据,并且我们希望缓存的数据类型必须是 Serializable 的,同时 Cache 是协变的,这样我们可以安全地读取缓存的数据。

interface Cache<out T : Serializable> {
    fun get(): T?
}

class StringCache : Cache<String> {
    private var value: String? = null

    override fun get(): String? {
        return value
    }
}

fun useCache(cache: Cache<Serializable>) {
    val data = cache.get()
    if (data != null) {
        // 处理数据,这里 data 是 Serializable 类型
    }
}

在上述代码中,Cache 接口的类型参数 T 既被约束为 Serializable 的子类型,又通过 out 关键字声明为协变。这意味着 Cache<String> 可以被当作 Cache<Serializable> 使用,并且我们可以安全地从 Cache 中读取 Serializable 类型的数据。

结合约束与逆变

再假设我们有一个 DataProcessor 接口,它用于处理数据,并且我们希望处理的数据类型必须实现 Comparable 接口,同时 DataProcessor 是逆变的,这样我们可以传递更通用的处理器来处理具体类型的数据。

interface DataProcessor<in T : Comparable<T>> {
    fun process(data: T)
}

class IntegerProcessor : DataProcessor<Int> {
    override fun process(data: Int) {
        // 处理整数数据
    }
}

fun provideData(processor: DataProcessor<Int>) {
    processor.process(5)
}

val anyProcessor: DataProcessor<Any> = object : DataProcessor<Any> {
    override fun process(data: Any) {
        if (data is Comparable<*>) {
            // 进行一些通用的比较处理
        }
    }
}

provideData(anyProcessor) // 这是合法的,因为 DataProcessor<Any> 是 DataProcessor<Int> 的子类型

在这个例子中,DataProcessor 接口的类型参数 T 被约束为实现 Comparable<T> 接口,并且通过 in 关键字声明为逆变。这使得我们可以将 DataProcessor<Any> 类型的处理器传递给期望 DataProcessor<Int> 的函数,只要 Any 的子类型(在这里是 Int)满足 Comparable 约束即可。

泛型约束与变型在集合中的应用

Kotlin的集合框架也广泛应用了泛型约束与变型的概念。

集合的协变

Kotlin的只读集合(如 List)是协变的。例如,List<Int>List<Number> 的子类型,因为 IntNumber 的子类型。

val intList: List<Int> = listOf(1, 2, 3)
val numberList: List<Number> = intList

这种协变关系使得我们可以将一个具体类型的只读集合当作其超类型的只读集合使用,方便了数据的传递和处理。因为只读集合只允许读取数据,所以协变不会破坏类型安全。

集合的逆变

Kotlin中可写集合(如 MutableList)并没有直接使用逆变,因为逆变在可写集合上可能会破坏类型安全。然而,我们可以通过一些间接的方式来实现类似逆变的功能。例如,我们可以定义一个接受 MutableList<in T> 的函数,来处理不同类型但具有继承关系的集合。

fun addStringToList(list: MutableList<in String>) {
    list.add("Hello")
}

val stringList: MutableList<String> = mutableListOf()
val anyList: MutableList<Any> = mutableListOf()

addStringToList(stringList)
addStringToList(anyList) // 这是合法的,因为 MutableList<Any> 可以被当作 MutableList<in String> 使用

这里通过 in 关键字,我们可以将 MutableList<Any> 类型的集合传递给期望 MutableList<in String> 的函数,从而实现了一定程度上的 “逆变” 功能,但要注意这是在确保类型安全的前提下进行的。

泛型约束与变型在函数式编程中的应用

在Kotlin的函数式编程中,泛型约束与变型也起着重要的作用。

函数类型的协变与逆变

Kotlin中的函数类型也遵循协变和逆变的规则。对于一个函数类型 (A) -> B,如果 A 是逆变的,B 是协变的。这意味着如果 A1A2 的子类型,B1B2 的子类型,那么 (A2) -> B1(A1) -> B2 的子类型。

val stringToIntFunc: (String) -> Int = { it.length }
val anyToStringFunc: (Any) -> String = { it.toString() }

val anyToIntFunc: (Any) -> Int = { (it as String).length }

val funcList1: List<(Any) -> Int> = listOf(anyToIntFunc)
val funcList2: List<(String) -> Int> = listOf(stringToIntFunc)

val combinedList: List<(String) -> Int> = funcList1 + funcList2 // 这是合法的,因为 (Any) -> Int 是 (String) -> Int 的子类型

这种函数类型的变型规则使得我们可以更加灵活地组合和使用函数,在函数式编程中实现更复杂的逻辑。

泛型约束在高阶函数中的应用

在高阶函数中,我们经常需要对传入的函数的参数类型或返回值类型进行约束。

fun <T : Number> processNumbers(func: (T) -> Double) {
    val numbers = listOf(1, 2, 3) as List<T>
    numbers.forEach { number ->
        val result = func(number)
        println(result)
    }
}

processNumbers { it.toDouble() } // 合法,Int 是 Number 的子类型

在上述高阶函数 processNumbers 中,类型参数 T 被约束为 Number 的子类型,这保证了传入的函数 func 可以接受 Number 子类型的参数,并返回 Double 类型的值。这样可以确保在函数内部对 func 的调用是类型安全的,同时也限制了 func 适用的参数类型范围。

泛型约束与变型在实际项目中的常见问题与解决方法

在实际项目开发中,使用泛型约束与变型时可能会遇到一些问题。

类型擦除带来的问题

在Java和Kotlin中,泛型在运行时存在类型擦除。这意味着在运行时,泛型类型信息会被擦除,只保留原始类型。例如,List<String>List<Int> 在运行时的类型都是 List。这可能会导致一些问题,特别是在需要在运行时获取泛型类型信息的场景下。

解决方法之一是使用 reified 关键字(在Kotlin中)来保留类型信息。例如,在 inline 函数中可以使用 reified

inline fun <reified T> getTypeName(): String {
    return T::class.simpleName ?: "Unknown"
}

val intTypeName = getTypeName<Int>()
println(intTypeName) // 输出 "Int"

通过 reified,我们可以在运行时获取泛型类型的信息,从而解决类型擦除带来的部分问题。

变型冲突问题

当我们在一个复杂的类型层次结构中同时使用协变和逆变时,可能会遇到变型冲突的问题。例如,当一个类型既是协变又是逆变的情况,这在Kotlin中是不允许的,因为会破坏类型安全。

解决这种问题的方法是仔细分析类型的使用场景,确定是需要协变还是逆变,或者通过引入中间类型来避免直接的冲突。例如,如果一个类型在某些场景下需要协变,在另一些场景下需要逆变,可以考虑将其功能拆分到两个不同的接口或类中,分别使用协变和逆变。

深入理解泛型约束与变型的字节码层面

为了更深入地理解泛型约束与变型,我们可以从字节码层面来分析。

泛型约束在字节码中的体现

当我们定义一个带有泛型约束的类或函数时,字节码中会体现出这些约束。例如,对于一个带有上界约束的类 Box<T : Number>,在字节码中,类型参数 T 会被替换为其上限类型 Number。方法签名中对 T 的使用也会相应地转换为对 Number 类型的操作。

变型在字节码中的体现

协变和逆变在字节码层面也有相应的体现。协变类型(如 Producer<out T>)在字节码中,对类型参数 T 的读取操作是安全的,因为 T 被替换为其上限类型(如果使用星投影,会被替换为 Any?)。而逆变类型(如 Consumer<in T>)在字节码中,对类型参数 T 的写入操作是安全的,同样 T 会被替换为适当的类型(如果使用星投影,会被替换为 Any)。

通过深入了解字节码层面的实现,我们可以更好地理解泛型约束与变型在运行时的行为和限制,从而在编写代码时更加准确地运用它们。

总之,Kotlin的泛型约束与变型是非常强大且灵活的特性,它们在保证类型安全的前提下,为我们提供了极大的编程灵活性。无论是在集合操作、函数式编程还是复杂的业务逻辑实现中,合理运用泛型约束与变型都可以使代码更加简洁、可读且健壮。在实际开发中,我们需要根据具体的需求和场景,仔细选择和组合这些特性,以达到最佳的编程效果。同时,深入理解它们在字节码层面的实现,也有助于我们更好地掌握和运用这些特性。