Kotlin Star Projections
Introduction
When working with generic types in Kotlin, you might encounter situations where you don't know or don't care about the specific type arguments. For example, you might need to work with a List<T>
but don't care about what type T
is. This is where star projections come into play.
Star projections in Kotlin are a way to use generic types when the specific type arguments are unknown or irrelevant. They're similar to Java's raw types but with additional type safety.
Understanding Star Projections
In Kotlin, star projections are denoted using the asterisk symbol (*
). When you use a star projection, you're essentially telling the compiler: "I know this is a generic type, but I don't know or care about the specific type parameters."
Basic Syntax
// Regular generic usage
val list: List<String> = listOf("Kotlin", "Java", "Python")
// Star projection - type argument is unknown
val unknownList: List<*> = list
How Star Projections Work
When you use a star projection (*
) for a generic type, it's equivalent to:
- For a type with variance declaration (
out T
orin T
), the star projection maps to the appropriate upper or lower bound. - For invariant types, it maps to
out Any?
for reading and restricts writing.
Let's break this down:
For Types with out
Variance
For a class Box<out T>
(where T
is covariant):
Box<*>
is equivalent toBox<out Any?>
- This means you can read values from it, but they're treated as
Any?
.
class Producer<out T>(val item: T) {
fun get(): T = item
}
fun main() {
val stringProducer = Producer("Hello")
val anyProducer: Producer<*> = stringProducer
// Reading is safe - but type is Any?
val item = anyProducer.get()
println(item) // Output: Hello
// The following would be incorrect:
// val string: String = anyProducer.get() // Type mismatch
}
For Types with in
Variance
For a class Box<in T>
(where T
is contravariant):
Box<*>
is equivalent toBox<in Nothing>
- This means you can't safely write values to it.
class Consumer<in T> {
fun consume(item: T) {
println("Consuming: $item")
}
}
fun main() {
val stringConsumer = Consumer<String>()
val anyConsumer: Consumer<*> = stringConsumer
// The following would be incorrect:
// anyConsumer.consume("test") // Cannot call 'consume' on Consumer<*>
}
For Invariant Types
For a class Box<T>
(where T
is invariant):
Box<*>
allows reading values asAny?
Box<*>
prohibits writing values
When to Use Star Projections
Star projections are particularly useful in the following scenarios:
- When you need to work with generic types but don't care about the specific type arguments
- When you only need to read values from a generic container (not write to it)
- When implementing generic functions that handle multiple types
Example 1: Checking Container Size
fun printContainerSize(container: List<*>) {
println("Container size: ${container.size}")
}
fun main() {
val stringList = listOf("a", "b", "c")
val intList = listOf(1, 2, 3, 4)
printContainerSize(stringList) // Output: Container size: 3
printContainerSize(intList) // Output: Container size: 4
}
Example 2: Type Inspection
fun examineCollection(collection: Collection<*>) {
val firstItem = collection.firstOrNull()
when (firstItem) {
is String -> println("Collection of strings, first item: $firstItem")
is Int -> println("Collection of integers, first item: $firstItem")
null -> println("Empty collection")
else -> println("Collection of unknown type, first item: $firstItem")
}
}
fun main() {
examineCollection(listOf("apple", "banana", "cherry"))
// Output: Collection of strings, first item: apple
examineCollection(listOf(1, 2, 3))
// Output: Collection of integers, first item: 1
examineCollection(emptyList<String>())
// Output: Empty collection
}
Practical Applications
Reflection and Generic Classes
Star projections are commonly used when working with reflection, where you might not know the exact type parameters:
fun <T> printClassInfo(clazz: Class<*>) {
println("Class name: ${clazz.simpleName}")
println("Is interface: ${clazz.isInterface}")
println("Superclass: ${clazz.superclass?.simpleName ?: "None"}")
}
fun main() {
printClassInfo(String::class.java)
// Output:
// Class name: String
// Is interface: false
// Superclass: Object
printClassInfo(List::class.java)
// Output:
// Class name: List
// Is interface: true
// Superclass: None
}
Generic Function for Type-Agnostic Processing
fun processAnyCollection(collection: Collection<*>, action: (Any?) -> Unit) {
collection.forEach { action(it) }
}
fun main() {
val numbers = listOf(1, 2, 3, 4, 5)
val words = listOf("apple", "banana", "cherry")
// Process any collection and print elements
processAnyCollection(numbers) { println("Processing: $it") }
// Output:
// Processing: 1
// Processing: 2
// Processing: 3
// Processing: 4
// Processing: 5
// Count characters in strings
processAnyCollection(words) {
if (it is String) println("${it}: ${it.length} characters")
}
// Output:
// apple: 5 characters
// banana: 6 characters
// cherry: 6 characters
}
Star Projections vs. Generic Type Parameters
It's important to understand when to use star projections and when to use proper generic type parameters:
// Using star projection - less type safety
fun printFirstElement(list: List<*>) {
val element = list.firstOrNull()
println("First element: $element")
}
// Using generic type parameter - more type safety
fun <T> printFirstElementTyped(list: List<T>): T? {
val element = list.firstOrNull()
println("First element: $element")
return element
}
fun main() {
val strings = listOf("Hello", "World")
printFirstElement(strings)
// Output: First element: Hello
val firstString: String? = printFirstElementTyped(strings)
// Output: First element: Hello
println("Got back: $firstString")
// Output: Got back: Hello
}
The key difference is that with generic type parameters (<T>
), you maintain the type information throughout the function, while with star projections (<*>
), you lose the specific type information.
Common Pitfalls and Limitations
1. Can't Write to Star-Projected Types
One of the main limitations is that you can't safely write to a star-projected type:
fun main() {
val list: MutableList<*> = mutableListOf("a", "b", "c")
// Reading is fine
println(list[0]) // Output: a
// The following would be incorrect:
// list.add("d") // Compile error: Cannot add element to collection with star projection
}
2. Loss of Type Information
With star projections, you lose specific type information:
fun main() {
val stringList: List<String> = listOf("a", "b", "c")
val starList: List<*> = stringList
val item = starList[0] // Type of 'item' is Any?
// You need explicit casting to get back the original type
val string: String = item as String
println(string.uppercase()) // Output: A
}
Summary
Star projections in Kotlin provide a way to work with generic types when the specific type arguments are unknown or irrelevant. They offer more type safety than Java's raw types by maintaining some type checking at compile time.
Key points to remember about star projections:
- Use
<*>
syntax to represent an unknown type argument - They're primarily useful for reading from generic types, not writing to them
- For covariant types (
out T
),Box<*>
is equivalent toBox<out Any?>
- For contravariant types (
in T
),Box<*>
is equivalent toBox<in Nothing>
- Use star projections when you don't care about the specific type, only the structure
Star projections are a powerful feature in Kotlin's type system, but they should be used carefully. When possible, prefer using proper generic type parameters to maintain type safety.
Exercises
- Create a function that counts the number of elements in a collection matching a specific condition, using star projections.
- Implement a generic cache using a
Map<*, *>
and explain the limitations of this approach. - Write a function that can print the class names of all elements in any collection.
- Create a function that safely converts between different types of collections without knowing the element type.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)