Kotlin - Generics

Kotlin Generics

Generics enable type-safe programming by allowing classes, interfaces, and functions to work with different types while maintaining type safety at compile time.

Generic Functions

Key Concept: Generics provide type safety without sacrificing flexibility. They allow you to write code that works with different types while catching type errors at compile time.

Basic Generic Functions

// Generic function with single type parameter
fun  identity(value: T): T {
    return value
}

// Usage - type is inferred
val stringResult = identity("Hello")    // String
val intResult = identity(42)            // Int
val listResult = identity(listOf(1, 2, 3))  // List

// Explicit type specification
val explicitResult = identity("World")

println("String: $stringResult")
println("Int: $intResult")
println("List: $listResult")

// Generic function with multiple type parameters
fun  transform(input: T, transformer: (T) -> R): R {
    return transformer(input)
}

val length = transform("Hello World") { it.length }           // String -> Int
val upper = transform("hello") { it.uppercase() }            // String -> String
val squared = transform(5) { it * it }                       // Int -> Int

println("Length: $length, Upper: $upper, Squared: $squared")

Generic Functions with Constraints

// Generic function with upper bound constraint
fun  addNumbers(a: T, b: T): Double {
    return a.toDouble() + b.toDouble()
}

val intSum = addNumbers(5, 10)              // Works with Int
val doubleSum = addNumbers(3.14, 2.86)     // Works with Double
val floatSum = addNumbers(1.5f, 2.5f)      // Works with Float
// val stringSum = addNumbers("1", "2")     // ❌ Compilation error

println("Int sum: $intSum")
println("Double sum: $doubleSum")
println("Float sum: $floatSum")

// Multiple constraints
fun  processComparable(items: List): T? where T : Comparable, T : Any {
    return items.maxOrNull()
}

val maxString = processComparable(listOf("apple", "banana", "cherry"))  // "cherry"
val maxNumber = processComparable(listOf(1, 5, 3, 9, 2))               // 9
// val maxNullable = processComparable(listOf(null, "test"))   // ❌ T : Any constraint

println("Max string: $maxString")
println("Max number: $maxNumber")

Generic Classes

Basic Generic Classes

// Generic class with single type parameter
class Box(private var content: T) {
    fun get(): T = content
    fun set(value: T) {
        content = value
    }
    
    fun isEmpty(): Boolean = content == null
    
    override fun toString(): String = "Box($content)"
}

// Usage
val stringBox = Box("Hello")
val intBox = Box(42)
val listBox = Box(listOf(1, 2, 3))

println("String box: ${stringBox.get()}")  // Hello
stringBox.set("World")
println("Updated: ${stringBox.get()}")     // World

println("Int box: ${intBox.get()}")        // 42
println("List box: ${listBox.get()}")      // [1, 2, 3]

// Generic class with multiple type parameters
class Pair(val first: T, val second: U) {
    fun swap(): Pair = Pair(second, first)
    
    override fun toString(): String = "($first, $second)"
}

val stringIntPair = Pair("Hello", 42)
val swapped = stringIntPair.swap()

println("Original: $stringIntPair")        // (Hello, 42)
println("Swapped: $swapped")               // (42, Hello)

Generic Classes with Constraints

// Generic class with upper bound
class NumberProcessor(private val numbers: MutableList) {
    fun add(number: T) {
        numbers.add(number)
    }
    
    fun sum(): Double {
        return numbers.sumOf { it.toDouble() }
    }
    
    fun average(): Double {
        return if (numbers.isEmpty()) 0.0 else sum() / numbers.size
    }
    
    fun max(): T? = numbers.maxOrNull()
    
    fun getNumbers(): List = numbers.toList()
}

val intProcessor = NumberProcessor(mutableListOf(1, 2, 3, 4, 5))
intProcessor.add(6)
println("Int sum: ${intProcessor.sum()}")           // 21.0
println("Int average: ${intProcessor.average()}")   // 3.5
println("Int max: ${intProcessor.max()}")           // 6

val doubleProcessor = NumberProcessor(mutableListOf(1.5, 2.5, 3.5))
println("Double sum: ${doubleProcessor.sum()}")     // 7.5
println("Double average: ${doubleProcessor.average()}")  // 2.5

// Generic class with multiple constraints
class SortedContainer(private val items: MutableList = mutableListOf()) 
    where T : Comparable, T : Any {
    
    fun add(item: T) {
        items.add(item)
        items.sort()
    }
    
    fun getAll(): List = items.toList()
    
    fun search(item: T): Boolean = items.binarySearch(item) >= 0
}

val sortedStrings = SortedContainer()
sortedStrings.add("banana")
sortedStrings.add("apple")
sortedStrings.add("cherry")
println("Sorted strings: ${sortedStrings.getAll()}")  // [apple, banana, cherry]
println("Contains 'banana': ${sortedStrings.search("banana")}")  // true

Variance (in/out)

Covariance (out)

// Covariant generic class (producer)
class Producer(private val value: T) {
    fun produce(): T = value
    // fun consume(value: T) { } // ❌ Not allowed - T is in 'out' position
}

// Covariance allows subtype relationships
open class Animal(val name: String)
class Dog(name: String, val breed: String) : Animal(name)
class Cat(name: String, val color: String) : Animal(name)

fun processAnimals(producer: Producer) {
    val animal = producer.produce()
    println("Processing animal: ${animal.name}")
}

val dogProducer = Producer(Dog("Buddy", "Golden Retriever"))
val catProducer = Producer(Cat("Whiskers", "Orange"))

// This works because Producer is covariant
processAnimals(dogProducer)  // Producer can be used as Producer
processAnimals(catProducer)  // Producer can be used as Producer

// Real-world example: List is covariant
val dogs: List = listOf(Dog("Rex", "German Shepherd"))
val animals: List = dogs  // ✅ Works because List

Contravariance (in)

// Contravariant generic class (consumer)
class Consumer {
    private val items = mutableListOf<@UnsafeVariance T>()
    
    fun consume(item: T) {
        items.add(item)
        println("Consumed: $item")
    }
    
    // fun produce(): T { } // ❌ Not allowed - T is in 'in' position
}

fun processDogs(consumer: Consumer) {
    val dog = Dog("Max", "Labrador")
    consumer.consume(dog)
}

val animalConsumer = Consumer()
val dogConsumer = Consumer()

// This works because Consumer is contravariant
processDogs(animalConsumer)  // Consumer can be used as Consumer
processDogs(dogConsumer)     // Direct match

// Real-world example: Comparator is contravariant
val animalComparator = Comparator { a1, a2 -> a1.name.compareTo(a2.name) }
val dogs = listOf(Dog("Alpha", "Husky"), Dog("Beta", "Poodle"))
val sortedDogs = dogs.sortedWith(animalComparator)  // ✅ Works because Comparator

Use-site Variance (Wildcards)

// Use-site variance with 'out' (similar to ? extends in Java)
fun copyFromProducer(source: List, destination: MutableList) {
    for (item in source) {
        destination.add(item)
    }
}

val intList = listOf(1, 2, 3)
val doubleList = listOf(1.5, 2.5)
val numberList = mutableListOf()

copyFromProducer(intList, numberList)     // List -> List
copyFromProducer(doubleList, numberList)  // List -> List
println("Combined: $numberList")          // [1, 2, 3, 1.5, 2.5]

// Use-site variance with 'in' (similar to ? super in Java)
fun copyToConsumer(source: List, destination: MutableList) {
    for (item in source) {
        destination.add(item)
    }
}

val anyList = mutableListOf()
val numberSource = listOf(10, 20, 30)

copyToConsumer(numberSource, numberList)  // MutableList -> MutableList
copyToConsumer(numberSource, anyList)     // MutableList -> MutableList
println("Number list: $numberList")
println("Any list: $anyList")

Reified Type Parameters

Reified Generics with inline Functions

// Reified type parameters allow access to type information at runtime
inline fun  isInstanceOf(value: Any): Boolean {
    return value is T
}

// Usage
println("'Hello' is String: ${isInstanceOf("Hello")}")     // true
println("42 is String: ${isInstanceOf(42)}")               // false
println("42 is Int: ${isInstanceOf(42)}")                     // true

// Reified generics for JSON-like parsing
inline fun  parseJson(json: String): T? {
    // In real implementation, this would use actual JSON parsing
    return when (T::class) {
        String::class -> json as? T
        Int::class -> json.toIntOrNull() as? T
        Boolean::class -> json.toBooleanStrictOrNull() as? T
        else -> null
    }
}

val stringValue = parseJson("Hello")        // String?
val intValue = parseJson("42")                 // Int?
val boolValue = parseJson("true")          // Boolean?

println("Parsed string: $stringValue")
println("Parsed int: $intValue")
println("Parsed boolean: $boolValue")

// Reified generics for filtering
inline fun  List<*>.filterIsInstance(): List {
    return this.filterIsInstance()
}

val mixedList: List = listOf("hello", 42, "world", 3.14, true)
val strings = mixedList.filterIsInstance()
val numbers = mixedList.filterIsInstance()

println("Strings: $strings")  // [hello, world]
println("Numbers: $numbers")  // [42, 3.14]

Advanced Reified Examples

// Generic function with reified type for creation
inline fun  createInstance(): T? where T : Any {
    return try {
        T::class.java.getDeclaredConstructor().newInstance()
    } catch (e: Exception) {
        null
    }
}

// Classes that can be instantiated with default constructor
class Person {
    val name: String = "Unknown"
    override fun toString() = "Person(name='$name')"
}

class Counter {
    var count: Int = 0
    override fun toString() = "Counter(count=$count)"
}

// Usage
val person = createInstance()
val counter = createInstance()

println("Created person: $person")
println("Created counter: $counter")

// Reified generics for type-safe casting
inline fun  Any?.safeCast(): T? {
    return this as? T
}

val value: Any = "Hello World"
val asString = value.safeCast()
val asInt = value.safeCast()

println("As String: $asString")  // Hello World
println("As Int: $asInt")        // null

// Advanced example: Generic repository pattern
interface Repository {
    fun save(item: T)
    fun findAll(): List
    fun findById(id: String): T?
}

inline fun  createInMemoryRepository(): Repository {
    return object : Repository {
        private val items = mutableMapOf()
        
        override fun save(item: T) {
            val id = item.hashCode().toString()
            items[id] = item
            println("Saved ${T::class.simpleName}: $item")
        }
        
        override fun findAll(): List = items.values.toList()
        
        override fun findById(id: String): T? = items[id]
    }
}

val personRepo = createInMemoryRepository()
val counterRepo = createInMemoryRepository()

personRepo.save(Person())
counterRepo.save(Counter())

println("All persons: ${personRepo.findAll()}")
println("All counters: ${counterRepo.findAll()}")

Real-World Generic Examples

Result Wrapper with Generics

// Generic Result class for error handling
sealed class Result {
    data class Success(val data: T) : Result()
    data class Error(val exception: Throwable) : Result()
    
    // Extension functions for Result
    fun  map(transform: (T) -> R): Result {
        return when (this) {
            is Success -> Success(transform(data))
            is Error -> this
        }
    }
    
    fun  flatMap(transform: (T) -> Result): Result {
        return when (this) {
            is Success -> transform(data)
            is Error -> this
        }
    }
    
    fun getOrNull(): T? {
        return when (this) {
            is Success -> data
            is Error -> null
        }
    }
    
    fun getOrDefault(default: T): T {
        return when (this) {
            is Success -> data
            is Error -> default
        }
    }
}

// Generic functions that return Result
fun  safeOperation(operation: () -> T): Result {
    return try {
        Result.Success(operation())
    } catch (e: Exception) {
        Result.Error(e)
    }
}

// Usage examples
val stringResult = safeOperation { "Hello World" }
val intResult = safeOperation { 42 / 2 }
val errorResult = safeOperation { 42 / 0 }  // Division by zero

// Chain operations
val chainedResult = stringResult
    .map { it.length }                    // String -> Int
    .map { it * 2 }                      // Int -> Int
    .map { "Length doubled: $it" }       // Int -> String

println("String result: ${stringResult.getOrNull()}")
println("Int result: ${intResult.getOrNull()}")
println("Error result: ${errorResult.getOrNull()}")
println("Chained result: ${chainedResult.getOrNull()}")

Generic Data Structures

// Generic Stack implementation
class Stack {
    private val items = mutableListOf()
    
    fun push(item: T) {
        items.add(item)
    }
    
    fun pop(): T? {
        return if (items.isEmpty()) null else items.removeLastOrNull()
    }
    
    fun peek(): T? {
        return items.lastOrNull()
    }
    
    fun isEmpty(): Boolean = items.isEmpty()
    fun size(): Int = items.size
    
    override fun toString(): String = "Stack(${items.reversed()})"
}

// Generic Binary Tree Node
class TreeNode>(
    val value: T,
    var left: TreeNode? = null,
    var right: TreeNode? = null
) {
    fun insert(newValue: T) {
        when {
            newValue < value -> {
                if (left == null) left = TreeNode(newValue)
                else left?.insert(newValue)
            }
            newValue > value -> {
                if (right == null) right = TreeNode(newValue)
                else right?.insert(newValue)
            }
        }
    }
    
    fun search(searchValue: T): Boolean {
        return when {
            searchValue == value -> true
            searchValue < value -> left?.search(searchValue) ?: false
            else -> right?.search(searchValue) ?: false
        }
    }
    
    fun inorderTraversal(): List {
        val result = mutableListOf()
        left?.inorderTraversal()?.let { result.addAll(it) }
        result.add(value)
        right?.inorderTraversal()?.let { result.addAll(it) }
        return result
    }
}

// Usage
val stringStack = Stack()
stringStack.push("first")
stringStack.push("second")
stringStack.push("third")
println("Stack: $stringStack")
println("Popped: ${stringStack.pop()}")
println("Peek: ${stringStack.peek()}")

val intTree = TreeNode(50)
listOf(30, 70, 20, 40, 60, 80).forEach { intTree.insert(it) }
println("Tree contains 40: ${intTree.search(40)}")
println("Tree contains 25: ${intTree.search(25)}")
println("Inorder traversal: ${intTree.inorderTraversal()}")

Best Practices

✅ Good Practices

  • Use meaningful type parameter names (T for Type, K for Key, V for Value)
  • Apply appropriate variance (out for producers, in for consumers)
  • Use type bounds to constrain generic types when needed
  • Prefer reified generics for runtime type information
  • Use star projection (*) when type doesn't matter
  • Keep generic type hierarchies simple

❌ Avoid

  • Overusing generics where simple types would suffice
  • Creating complex generic hierarchies
  • Using raw types (prefer explicit wildcards)
  • Ignoring variance when designing APIs
  • Using reified generics in non-inline functions
Architecture Note: Generics enable type-safe, reusable code. Kotlin's variance system (in/out) provides more flexibility than Java's wildcards while maintaining type safety. Reified generics solve the type erasure problem for inline functions.

Practice Exercises

  1. Create a generic cache class with TTL (time-to-live) functionality
  2. Implement a generic event bus with type-safe event handling
  3. Build a generic validation framework with composable validators
  4. Create a generic repository pattern with CRUD operations
  5. Implement a generic state machine with type-safe transitions

Quick Quiz

  1. What's the difference between out T and in T?
  2. When can you use reified type parameters?
  3. What does the star projection (*) represent?
  4. How do you add multiple constraints to a generic type?
Show answers
  1. out T makes the type covariant (producer), in T makes it contravariant (consumer)
  2. Only in inline functions, where type information is preserved at compile time
  3. Star projection represents an unknown type, equivalent to Java's raw types
  4. Use the where clause: fun <T> example() where T : Type1, T : Type2