Skip to main content

Working with Kotlin Generics

1. Creating Generic Classes

Generic classes in Kotlin allow you to define classes that work with different types while maintaining type safety at compile time.

Feature Description Example
Generic Class Declaration Use type parameters in angle brackets after the class name. class Box<T>(value: T) { ... }
Type Parameter Constraints Use : T to constrain type parameters to a specific supertype. class Container<T : CharSequence>(value: T) { ... }
Reified Type Parameters Use reified to access type information at runtime (only in inline functions). inline fun <reified T> getType(): Class<T> = T::class.java

Example: Basic Generic Class

class Box<T>(private val value: T) {
    fun getValue(): T = value
}

val intBox = Box(42)
val stringBox = Box("Hello")

Example: Generic Class with Constraints

class Container<T : CharSequence>(private val value: T) {
    fun getValue(): T = value
    fun length(): Int = value.length
}

val container = Container("Kotlin")

2. Implementing Generic Functions

Generic functions allow you to write flexible, reusable code that works with different types. They are declared using a type parameter in angle brackets.

Function Description Example
fun <T> identity(value: T): T Generic function that returns the same type as input
fun <T> identity(value: T): T = value
val str = identity("Hello")
val num = identity(42)
fun <T> processList(list: List<T>, processor: (T) -> T): List<T> Generic function that processes a list of any type
fun <T> processList(list: List<T>, processor: (T) -> T): List<T> {
    return list.map(processor)
}
val numbers = listOf(1, 2, 3)
val doubled = processList(numbers) { it * 2 }
Note: Type parameters are inferred when calling generic functions. You can also explicitly specify the type like <String>.
Warning: Avoid using Any as a type parameter unless necessary, as it reduces type safety.

3. Using Variance

Concept Description Example
Covariance Allows a generic type to be used with a more derived type than originally specified.
interface Producer<out T> {
    fun produce(): T
}

class StringProducer : Producer<String> {
    override fun produce(): String = "Hello"
}

fun useProducer(p: Producer<Any>) {
    val value: Any = p.produce()
}

val stringProducer: Producer<String> = StringProducer()
useProducer(stringProducer) // Works due to covariance
Contravariance Allows a generic type to be used with a less derived type than originally specified.
interface Consumer<in T> {
    fun consume(item: T)
}

class StringConsumer : Consumer<String> {
    override fun consume(item: String) {
        println(item)
    }
}

fun useConsumer(c: Consumer<Any>) {
    c.consume("Hello")
}

val stringConsumer: Consumer<String> = StringConsumer()
useConsumer(stringConsumer) // Works due to contravariance
Invariance Generic type is invariant, meaning it must match exactly.
interface Invariant<T> {
    fun set(value: T)
    fun get(): T
}

val list: Invariant<String> = object : Invariant<String> {
    override fun set(value: String) {}
    override fun get(): String = "Hello"
}

// This won't compile:
// val invar: Invariant<Any> = list

Example: Covariance in Collections

val strings = listOf("a", "b", "c")
val anyList: List<Any> = strings // Covariance allows this

Example: Contravariance in Function Types

fun processString(s: String) { }
fun processAny(a: Any) { }

val stringProcessor: (String) -> Unit = ::processString
val anyProcessor: (Any) -> Unit = ::processAny

// This works because function types are contravariant in their parameter
val func: (Any) -> Unit = stringProcessor // Valid due to contravariance

4. Implementing Covariance

Covariance in Kotlin allows a generic type to be used with a more derived type than originally specified. It's particularly useful when dealing with collections and functions.

Feature Description Example
out Specifies that a type parameter is covariant. It can only be used in output positions (e.g., return types).
interface Producer<out T> {
    fun produce(): T
}
in Specifies that a type parameter is contravariant. It can only be used in input positions (e.g., function parameters).
interface Consumer<in T> {
    fun consume(item: T)
}
invariant Default behavior where the type parameter is neither covariant nor contravariant. It must match exactly.
interface Container<T> {
    fun set(item: T)
    fun get(): T
}

Example: Covariant Producer

open class Animal {
    open fun makeSound() = "Some sound"
}

class Dog : Animal() {
    override fun makeSound() = "Bark"
}

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

class AnimalProducer : Producer<Animal> {
    override fun produce(): Animal = Animal()
}

class DogProducer : Producer<Dog> {
    override fun produce(): Dog = Dog()
}

fun useAnimalProducer(p: Producer<Animal>) {
    val animal = p.produce()
    println(animal.makeSound())
}

fun main() {
    val dogProducer: Producer<Dog> = DogProducer()
    // Covariance allows this assignment
    val animalProducer: Producer<Animal> = dogProducer
    useAnimalProducer(animalProducer)
}

Example: Contravariant Consumer

open class Animal {
    open fun makeSound() = "Some sound"
}

class Dog : Animal() {
    override fun makeSound() = "Bark"
}

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

class AnimalConsumer : Consumer<Animal> {
    override fun consume(item: Animal) {
        println("Consuming animal: ${item.makeSound()}")
    }
}

class DogConsumer : Consumer<Dog> {
    override fun consume(item: Dog) {
        println("Consuming dog: ${item.makeSound()}")
    }
}

fun useAnimalConsumer(c: Consumer<Animal>) {
    c.consume(Animal())
}

fun main() {
    val dogConsumer: Consumer<Dog> = DogConsumer()
    // Contravariance allows this assignment
    val animalConsumer: Consumer<Animal> = dogConsumer
    useAnimalConsumer(animalConsumer)
}

5. Implementing Contravariance

Type Parameter Description Usage Example
in T Contravariant type parameter; can only be used as input (parameter) fun processFile(writer: Writer<in String>) { ... }
out T Covariant type parameter; can only be used as output (return value) fun readData(): Reader<out String> { ... }

Example: Using Contravariance in Function Parameters

interface Consumer<in T> {
    fun accept(t: T)
}

class StringConsumer : Consumer<String> {
    override fun accept(t: String) {
        println("Received: $t")
    }
}

class AnyConsumer : Consumer<Any> {
    override fun accept(t: Any) {
        println("Received: $t")
    }
}

fun useConsumer(c: Consumer<Any>) {
    c.accept("Hello")
}

// This works because String is a subtype of Any
val stringConsumer = StringConsumer()
useConsumer(stringConsumer)

Example: Contravariance in Collections

open class Animal {
    fun feed() { println("Feeding animal") }
}

class Dog : Animal() {
    override fun feed() {
        println("Feeding dog")
    }
}

class AnimalShelter<in T : Animal> {
    fun addAnimal(animal: T) {
        println("Adding animal to shelter")
    }
}

val dogShelter = AnimalShelter<Dog>()
val animalShelter = AnimalShelter<Animal>()

// This works because Dog is a subtype of Animal
dogShelter.addAnimal(Dog())
animalShelter.addAnimal(Dog()) // Valid due to contravariance
Note: Contravariance is denoted by in keyword and ensures that a type parameter can only be used in input positions, which allows for more flexible subtyping relationships.

6. Using Type Projections (star projection *)

Type projections in Kotlin allow you to use a generic type with a wildcard-like syntax. The star projection is denoted by * and is used when you want to express that a generic type can be of any type.

Usage Description Example
out T Produces values of type T List<out T>
in T Consumes values of type T Consumer<in T>
T Unbounded type parameter List<*>

Example: Star Projection in Lists

fun printListItems(list: List<*>) {
    for (item in list) {
        println(item)
    }
}

val stringList = listOf("Hello", "World")
printListItems(stringList) // Works with any list type

Example: Star Projection in Function Parameters

fun processItems(items: MutableList<out String>) {
    // This function can only read from the list
    for (item in items) {
        println(item)
    }
}

val mutableList = mutableListOf("A", "B", "C")
processItems(mutableList)

7. Applying Generic Constraints

Constraint Type Description Example
Upper Bound Restricts type to be a subtype of a specified class
fun <T : CharSequence> processText(text: T): String { 
    return text.toString() 
}
Lower Bound Restricts type to be a supertype of a specified class
fun processItems(items: List<out CharSequence>) { 
    items.forEach { println(it) } 
}
Multiple Bounds Combines multiple constraints using & operator
fun <T : Comparable<T> & CharSequence> compareAndPrint(a: T, b: T) { 
    println(a.compareTo(b)) 
}
Reified Types with Constraints Allows runtime type checks using reified generics
inline fun <reified T : Any> createInstance(): T { 
    return T::class.java.getDeclaredConstructor().newInstance() as T 
}

Example: Using Upper Bound Constraint

fun <T : Number> calculateAverage(numbers: List<T>): Double {
    return numbers.map { it.toDouble() }.average()
}

val intList = listOf(1, 2, 3, 4, 5)
val doubleList = listOf(1.1, 2.2, 3.3)

println(calculateAverage(intList))     // Output: 3.0
println(calculateAverage(doubleList))  // Output: 2.2
Note: Upper bounds are more commonly used than lower bounds in Kotlin generics.
Warning: Reified generics can only be used in inline functions.

8. Understanding Type Erasure

Concept Description Implication
Type Erasure Generic type information is removed at runtime in Java and Kotlin Reflection and runtime type checks are limited

Example: Runtime Type Check Limitation

fun<T> printType(obj: T) {
    // This will not work as expected due to type erasure
    if (obj is List<String>) {
        println("List of strings")
    }
}
Note: Type erasure means that generic type parameters are not available at runtime. This is a fundamental limitation in JVM-based languages like Kotlin and Java.

Key Points

  • Type erasure removes generic type information at runtime
  • Runtime checks using is operator cannot distinguish between generic types
  • Workarounds include using KClass or reified functions

9. Using Reified Type Parameters

In Kotlin, reified type parameters allow you to access the runtime type information of a generic type within a function. This is particularly useful when working with inline functions and reflection, where you need to know the actual type at runtime.

Feature Description Usage
reified Marks a type parameter as reifiable, allowing runtime access to its type. inline fun <reified T> foo() { ... }
T::class Accesses the runtime KClass of the reified type parameter. val clazz = T::class

Example: Using Reified Type Parameters

inline fun <reified T> printType() {
    println("Type: ${T::class.simpleName}")
}

fun main() {
    printType<String>()
    printType<Int>()
}
Note: The reified keyword is only allowed in inline functions because it requires the compiler to inline the function and replace the type parameter with its actual type at compile time.

10. Working with Generic Extensions

Extension Description Example
Generic Extension Functions Allow adding functions to existing classes without inheritance or composition.
fun <T> List<T>.safeGet(index: Int): T? {
    return if (index in 0 until size) this[index] else null
}
Generic Extension Properties Enable adding properties to existing classes using generics.
val <T> List<T>.isEmpty: Boolean
    get() = this.size == 0

Example: Safe List Access

val numbers = listOf(1, 2, 3)
val result = numbers.safeGet(5) // Returns null
val validResult = numbers.safeGet(1) // Returns 2
Note: Extensions do not actually modify the classes they extend; they are resolved statically at compile time.

Was this article helpful?