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
- Create a generic cache class with TTL (time-to-live) functionality
- Implement a generic event bus with type-safe event handling
- Build a generic validation framework with composable validators
- Create a generic repository pattern with CRUD operations
- Implement a generic state machine with type-safe transitions
Quick Quiz
- What's the difference between
out T
andin T
? - When can you use reified type parameters?
- What does the star projection (*) represent?
- How do you add multiple constraints to a generic type?
Show answers
out T
makes the type covariant (producer),in T
makes it contravariant (consumer)- Only in inline functions, where type information is preserved at compile time
- Star projection represents an unknown type, equivalent to Java's raw types
- Use the
where
clause:fun <T> example() where T : Type1, T : Type2