Kotlin Generics Simplified
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 : data class Question( 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, 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("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 before the function name. fun printValue(value: T) { println(value) } fun 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 run(vehicle: T) { vehicle.move() } fun main() { run(Car("BMW", "X3 M")) } Here, the 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 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 printSomething(value: T) { println(value.toString())// OK // println("Doing something with type: ${T::class.simpleName}") // Error } inline fun 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 is not a subclass of Container. class Container(val item: T) fun main() { val intContainer = Container(10) // val numberContainer: Container = intContainer // Error: Type mismatch } Invariant generics ensure that no unsafe operations occur, as Container 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
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
:
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,
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
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
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
is not a subclass of Container
.
class Container<T>(val item: T)
fun main() {
val intContainer = Container(10)
// val numberContainer: Container = intContainer // Error: Type mismatch
}
Invariant generics ensure that no unsafe operations occur, as Container
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
is also a subtype of Producer
.
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
is a subtype of Consumer
.
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! ✌️