Kotlin’s type system provides robust features, including generics, which allow you to write flexible and reusable code. Diving deeper into Kotlin generics unveils advanced concepts such as type variance, constraints, and reified types. Mastering these aspects can lead to more type-safe, efficient, and expressive code. In this blog post, we’ll explore these advanced features of Kotlin generics with detailed examples.
Understanding Generics in Kotlin
Before diving into the advanced concepts, let’s briefly recap the basics of Kotlin generics. Generics allow you to parameterize classes, interfaces, and functions with type parameters. This enables you to write code that works with various types while maintaining type safety.
Basic Generic Example
class Box(val value: T)
fun main() {
val intBox: Box = Box(10)
val stringBox: Box = Box(\"Hello\")
println(intBox.value) // Output: 10
println(stringBox.value) // Output: Hello
}
Type Variance
Type variance deals with how generic types behave concerning their subtypes. Kotlin supports three types of variance:
- Covariance: Preserves the subtype relationship. If
A
is a subtype ofB
, thenGenericType
is a subtype ofGenericType
. - Contravariance: Reverses the subtype relationship. If
A
is a subtype ofB
, thenGenericType
is a supertype ofGenericType
. - Invariance: No subtype relationship is preserved.
GenericType
andGenericType
are unrelated, even ifA
is a subtype ofB
.
Covariance with out
To declare a generic type parameter as covariant, use the out
keyword. This means the type can only be used as an output (return type) and not as an input (parameter type).
interface Source {
fun next(): T
}
class StringSource(val value: String) : Source {
override fun next(): String = value
}
fun demo(stringSource: Source) {
val objectSource: Source = stringSource // OK: String is a subtype of Any
println(objectSource.next())
}
fun main() {
val stringSource = StringSource("Hello")
demo(stringSource) // Output: Hello
}
In this example, Source
is covariant in T
. This means a Source
can be safely treated as a Source
because String
is a subtype of Any
.
Contravariance with in
To declare a generic type parameter as contravariant, use the in
keyword. This means the type can only be used as an input (parameter type) and not as an output (return type).
interface Comparable {
fun compareTo(other: T): Int
}
class Box(val value: Double) : Comparable {
override fun compareTo(other: Number): Int {
return value.compareTo(other.toDouble())
}
}
fun demo(box: Comparable) {
val doubleBox: Comparable = box // OK: Number is a supertype of Double
println(doubleBox.compareTo(3.14))
}
fun main() {
val box = Box(2.71)
demo(box) // Output: -1
}
In this example, Comparable
is contravariant in T
. This means a Comparable
can be safely treated as a Comparable
because Number
is a supertype of Double
.
Invariance (No Variance)
By default, generic types in Kotlin are invariant. This means there is no subtype relationship between instances of generic types, even if their type parameters have a subtype relationship.
class InvariantBox(var value: T)
fun main() {
val intBox: InvariantBox = InvariantBox(10)
// val numberBox: InvariantBox = intBox // Error: Type mismatch
val numberBox: InvariantBox = InvariantBox(10)
// val intBox2: InvariantBox = numberBox // Error: Type mismatch
}
In this case, even though Int
is a subtype of Number
, InvariantBox
is not a subtype or supertype of InvariantBox
.
Type Constraints
Type constraints allow you to restrict the types that can be used as type parameters. Kotlin supports both upper bounds and multiple constraints.
Upper Bounds
An upper bound restricts the type parameter to be a subtype of a specific type.
fun half(value: T): Double {
return value.toDouble() / 2
}
fun main() {
println(half(10)) // Output: 5.0
println(half(3.14)) // Output: 1.57
// println(half("Hello")) // Error: Type argument is not within its bounds: should be a subtype of 'Number'
}
Here, T
is constrained to be a subtype of Number
, ensuring that only numbers can be passed to the half
function.
Multiple Constraints
You can specify multiple constraints using the where
keyword.
interface Printable {
fun print(): String
}
interface Sized {
fun size(): Int
}
fun process(item: T) where T : Printable, T : Sized {
println("Item: ${item.print()}, Size: ${item.size()}")
}
class Data(val name: String, val count: Int) : Printable, Sized {
override fun print(): String = "Name: $name"
override fun size(): Int = count
}
fun main() {
val data = Data("Example", 42)
process(data) // Output: Item: Name: Example, Size: 42
}
In this example, T
must implement both Printable
and Sized
interfaces, allowing the process
function to call methods from both interfaces.
Reified Types
Normally, type erasure in Java and Kotlin means that type parameters are not available at runtime. However, Kotlin provides reified
types, allowing you to access type information at runtime.
Using reified
with Inline Functions
To use reified
types, you must declare the function as inline
.
inline fun isA(value: Any): Boolean {
return value is T
}
fun main() {
println(isA("Hello")) // Output: true
println(isA(10)) // Output: true
println(isA(10)) // Output: false
}
Here, the isA
function can check the type of the value
at runtime, which is not normally possible with generics due to type erasure.
Example: Filtering a List
Reified types are particularly useful when filtering lists based on type at runtime.
inline fun List<*>.filterByType(): List {
return filterIsInstance()
}
fun main() {
val mixedList: List = listOf("Hello", 10, 3.14, "World", 20)
val stringList: List = mixedList.filterByType()
println(stringList) // Output: [Hello, World]
val intList: List = mixedList.filterByType()
println(intList) // Output: [10, 20]
}
In this example, filterByType
uses reified T
to filter the list and return only elements of the specified type.
Conclusion
Advanced Kotlin generics, including type variance, constraints, and reified types, enable you to write more expressive and type-safe code. Understanding covariance and contravariance allows you to create flexible type hierarchies. Type constraints ensure that your generic code operates on specific types, enhancing safety. Reified types provide runtime type information, enabling powerful features like type-based filtering. Mastering these concepts will significantly enhance your ability to leverage Kotlin’s generics system effectively.