Kotlin generics

Generics in Kotlin provide a powerful way to create flexible and reusable code that can work with different data types while ensuring compile-time type safety. This guide will explore the basics of Kotlin generics, their advantages, real-world use cases, and advanced concepts like variance and type projections.

1. Introduction to Kotlin Generics

Generics allow us to create parameterized classes, methods, and properties that can operate on a variety of data types. They ensure type safety at compile time, eliminate the need for explicit type casting, and promote code reusability.

1.1 Creating Parameterized Classes

In Kotlin, we use angle brackets < > to specify type parameters. For example, to create a generic class MyClass that holds a value of type T:

class MyClass<T>(text: T) {
    var name = text
}

fun main() {
    val myString = MyClass("Hello, Kotlin!") // Type inference
    val myInt = MyClass(123) // Explicit type argument

    println(myString.name) // Output: Hello, Kotlin!
    println(myInt.name) // Output: 123
}

Here, T is a type parameter that is replaced by actual types (String, Int, etc.) when creating instances of MyClass.

1.2 Advantages of Generics

  • Type Safety: Generics ensure that only compatible types can be used, reducing the risk of runtime errors.
  • Code Reusability: Generic classes and functions can be reused with different data types, promoting modular and scalable code.
  • Eliminates Type Casting: Generics eliminate the need for explicit type casting, making code cleaner and more readable.

2. Real-World Examples

Let’s explore some real-world scenarios where Kotlin generics are beneficial.

2.1 Generic Container for Any Type

Consider a scenario where you need a container to hold elements of any type. Using generics, we can create a flexible container class:

class Container<T>(private val element: T) {
    fun getElement(): T {
        return element
    }
}

fun main() {
    val intContainer = Container(42)
    val stringContainer = Container("Kotlin")

    println(intContainer.getElement()) // Output: 42
    println(stringContainer.getElement()) // Output: Kotlin
}

Here, Container can hold elements of any type (Int, String, etc.) without sacrificing type safety.

2.2 Generic Operations on Collections

Generics are commonly used in collections to perform operations on elements of various types. For instance, a generic function to find the maximum element in a list:

fun <T : Comparable<T>> findMax(list: List<T>): T? {
    return list.maxOrNull()
}

fun main() {
    val intList = listOf(1, 5, 3, 7, 2)
    val stringList = listOf("apple", "banana", "cherry")

    println(findMax(intList)) // Output: 7
    println(findMax(stringList)) // Output: cherry
}

The findMax function works with any list of Comparable elements, providing a generic and reusable solution.

3. Variance in Kotlin Generics

Variance refers to how subtyping relationships are preserved in generic types. Kotlin supports declaration-site variance using in and out keywords.

3.1 Covariance (out Keyword)

Covariance allows substituting subtypes where supertypes are expected. It’s denoted by the out keyword.

class Producer<out T>(private val value: T) {
    fun get(): T {
        return value
    }
}

fun main() {
    val producer: Producer<Number> = Producer(42)
    val result: Producer<Any> = producer // Covariance

    println(result.get()) // Output: 42
}

Here, Producer<Number> is a subtype of Producer<Any> due to covariance, allowing assignment of producer to result.

3.2 Contravariance (in Keyword)

Contravariance allows substituting supertypes where subtypes are expected. It’s denoted by the in keyword.

class Consumer<in T> {
    fun toString(value: T): String {
        return value.toString()
    }
}

fun main() {
    val consumer: Consumer<Number> = Consumer()
    val result: Consumer<Int> = consumer // Contravariance

    println(result.toString(123)) // Output: "123"
}

Here, Consumer<Number> is a supertype of Consumer<Int> due to contravariance, allowing assignment of consumer to result.

4. Type Projections

Type projections allow working with unknown or multiple types in a generic context using star * projections and reified type parameters.

4.1 Star Projections

Star projections (*) are used when the specific type is unknown or irrelevant:

fun printArray(array: Array<*>) {
    array.forEach { println(it) }
}

fun main() {
    val array = arrayOf(1, 2, "Kotlin", true)
    printArray(array) // Output: 1, 2, Kotlin, true
}

The printArray function works with arrays of any type without specifying the exact type.

4.2 Reified Type Parameters

Reified type parameters allow accessing type information at runtime in inline functions:

inline fun <reified T> filterList(list: List<Any>): List<T> {
    return list.filterIsInstance<T>()
}

fun main() {
    val mixedList = listOf(1, "Kotlin", true, 42.0)
    val stringList: List<String> = filterList(mixedList)

    println(stringList) // Output: [Kotlin]
}

The filterList function filters elements of a specific type (T) from a mixed list, leveraging reified type parameters.

Kotlin generics offer a powerful mechanism for writing flexible, type-safe, and reusable code. By understanding generics, variance, type projections, and reified type parameters, developers can build robust and adaptable applications across various domains.