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.