Generics in Kotlin allow us to write flexible and reusable code. In this article, we’ll dive into the world of Kotlin Generics and cover the following topics:
- What are Generics and Why Do We Need Them?
- Generic Functions
- Constraints in Generics
- Type Erasure in Generics
- Variance in Generics
What are Generics and Why Do We Need Them?
Generics enable us to write a single class, function, or property that can handle different types. This reduces code duplication and increases flexibility.
Imagine we’re building an app where users answer questions. We start with a simple Question
class:
data class Question(
val questionText: String,
val answer: String
)
fun main() {
val question = Question("Name your favorite programming language", "Kotlin")
}
This works fine for text answers. But what if the answer can also be a number or a boolean? We’d need multiple versions of the Question
class or sacrifice type safety by using Any
. Both approaches are less than ideal.
Solution: Generics
Generics allow us to make the Question
class type-flexible by introducing a type parameter <T>
:
data class Question<T>(
val questionText: String,
val answer: T
)
fun main() {
val questionString = Question("What's your favorite programming language?", "Kotlin")
val questionBoolean = Question("Kotlin is statically typed?", true)
val questionInt = Question("How many days are in a week?", 7)
}
Here, <T>
is a placeholder for the type of answer. The compiler ensures type safety while providing flexibility. If needed, you can explicitly specify the type:
val questionInt = Question<Int>("2 + 2 equals?", 4)
Why Use Generics?
- Avoid code duplication.
- Increase reusability.
- Maintain type safety.
Generic Functions
Generics aren’t just for classes — they work with functions too! To define a generic function, place the type parameter <T>
before the function name.
fun <T> printValue(value: T) {
println(value)
}
fun <T> T.toCustomString(): String { // Generic extension function
return "Value: $this"
}
fun main() {
printValue("Hello, Kotlin!")
printValue(42)
println(3.14.toCustomString())
}
Generics Constraints
Sometimes, we need the type parameter to meet certain requirements. Constraints restrict the types that can be used as arguments for generics.
interface Movable {
fun move()
}
class Car(private val make: String, private val model: String) : Movable {
override fun move() {
println("$make $model is moving.")
}
}
fun <T : Movable> run(vehicle: T) {
vehicle.move()
}
fun main() {
run(Car("BMW", "X3 M"))
}
Here, the <T : Movable>
constraint ensures that only types implementing Movable can be used. The type specified after a colon is the upper bound (Movable).
The default upper bound (if there was none specified) is Any?
Only one upper bound can be specified inside the angle brackets. If the same type parameter needs more than one upper bound, we need a separate where-clause:
interface Flyable {
fun fly()
}
class Plane(private val make: String, private val model: String) : Movable, Flyable {
override fun move() {
println("$make $model is moving.")
}
override fun fly() {
println("$make $model is flying.")
}
}
fun <T> operate(vehicle: T) where T : Movable, T : Flyable {
vehicle.move()
vehicle.fly()
}
fun main() {
operate(Plane("Boeing", "747"))
}
The where
clause ensures that the type T
satisfies multiple constraints.
Type Erasure in Generics
At compile time, the compiler removes the type argument from the function call. This is called type erasure. The reified
keyword retains the type information at runtime, but it can only be used in inline functions. Reified let us use reflection on type parameter. Function must be inline to use reified.
// Generics reified
fun <T> printSomething(value: T) {
println(value.toString())// OK
// println("Doing something with type: ${T::class.simpleName}") // Error
}
inline fun <reified T> doSomething(value: T) {
println("Doing something with type: ${T::class.simpleName}") // OK
}
Variance in Generics
Variance modifiers out
and in
help make generics flexible by controlling how subtypes are treated. Kotlin offers three variance:
- Invariant (T)
- Covariant (out T)
- Contravariant (in T)
Invariant Generics
By default, Kotlin generics are invariant. This means that even if B
is a subclass of A
, Container<B>
is not a subclass of Container<A>
.
class Container<T>(val item: T)
fun main() {
val intContainer = Container(10)
// val numberContainer: Container<Number> = intContainer // Error: Type mismatch
}
Invariant generics ensure that no unsafe operations occur, as Container<T>
guarantees the exact type of T
.
Covariant Generics (out T)
The out
modifier is used when a generic class or function only produces values of type T
. It ensures that subtypes are preserved, meaning if Dog
is a subtype of Animal
, then Producer<Dog>
is also a subtype of Producer<Animal>
.
open class Animal
class Dog : Animal()
class AnimalProducer<out T>(private val instance: T) {
fun produce(): T = instance
// fun consume(value: T) { /* ... */ } // This will show compile time error
}
fun main() {
val dogProducer: AnimalProducer<Animal> = AnimalProducer(Dog())
println("Produced: ${dogProducer.produce()}") // Works because of `out`
}
Contravariant Generics (in T)
The in
modifier is used when a generic class or function only consumes values of type T
. It reverses the subtyping relationship. If Dog
is a subtype of Animal
, then Consumer<Animal>
is a subtype of Consumer<Dog>
.
class AnimalConsumer<in T> {
fun consume(value: T) {
println("Consumed: ${value.toString()}")
}
// fun produce(): T { /* ... */ } //This will show compile time error
}
fun main() {
val animalConsumer: AnimalConsumer<Dog> = AnimalConsumer<Animal>()
animalConsumer.consume(Dog()) // Works because of `in`
}
Source Code: GitHub
Contact Me: LinkedIn | Twitter
Happy coding! ✌️
Top comments (0)