Generic types provide a way to reuse code, allowing data types to be variables. This allows us to create classes, interfaces, and methods that can operate with objects of different types as long as these objects are compatible with the type parameters.
In Kotlin, generic types are defined with the keyword <T>. However, the identifier for the generic type can be any letter or word.
Throughout this topic, we will delve into programming with generic types and consider the concepts of type, subtype, and variance. These concepts are important in statically typed languages like Kotlin, where the type relationships are checked at compile time. They help to ensure type safety while providing flexibility in how types are used.
Types and subtypes
A type in programming defines a set of valid values within a domain and a set of appropriate and consistent operations for the established value domain.
A subtype in programming is a type that is related to another type (the supertype) in a hierarchical relationship. The subtype inherits all the characteristics (valid values and operations) of the supertype, but it may also have additional values or operations or restrict the values in some way. In other words, every value of the subtype is also a value of the supertype, but not vice versa. This relationship is often established through mechanisms like inheritance in object-oriented programming.
For example, you have the type Number in Kotlin and the subtypes Int or Long, or, if you have a class Animal, you might define a Dog class that inherits from Animal. In the latter case, Dog is a subtype of Animal. However, the reverse is not possible.
val integer: Int = 1
val number: Number = integer // Int is a subtype of Number
val integer: Int = 1
val nullableInteger: Int? = integer; // Int is a subtype of Int?open class Animal
class Dog : Animal()
class Spider : Animal()
fun main() {
val dog: Dog = Dog()
val spider: Spider = Spider()
var animal: Animal = dog // Dog is a subtype of Animal
animal = spider // Spider is a subtype of Animal
}Variance
However, when we work with generics (and with collections that are based on those), the rules are not always the same and we must know when and how to use them.
For example, we know that Int is a subtype of Number. Imagine we have Box<T>: now, is Box<Int> a subtype of Box<Number>, or is Box<Dog> a subtype of Box<Animal>? Let's see:
class Box<T>
open class Animal
class Dog : Animal()
class Spider : Animal()
val d: Animal = Dog() // Dog is a subtype of Animal
val bd: Box<Animal> = Box<Dog>() // Error: Type mismatch
val bp: Box<Dog> = Box<Animal>() // Error: Type mismatch
val bn: Box<Number> = Box<Int>() // Error: Type mismatch
val bi: Box<Int> = Box<Number>() // Error: Type mismatchWhat is the problem here? All these problems are related to variance.
In the context of generic types in programming, variance refers to the ability to use a more derived (or less derived) type than originally specified. Variance applies when you have a type relationship between two types and you want to maintain that relationship when you use those classes as generic parameters. There are three kinds of variance:
Invariant: a generic class is called invariant on the type parameter when for two different types A and B,
Class<A>is neither a subtype nor a supertype ofClass<B>.Covariance: a generic class is called covariant on the type parameter when the following holds:
Class<A>is a subtype ofClass<B>if A is a subtype of B (preserved subtyping relation).Contravariance: a generic class is called contravariant on the type parameter when the following holds:
Class<A>is a subtype ofClass<B>if B is a subtype of A (reversed subtyping relations).
Invariance
In the above Kotlin code, generic types are by default invariant, meaning that they do not preserve the subtype relationship between types. This is why you're seeing errors in the code. Even though Dog is a subtype of Animal, Box<Dog> is not a subtype of Box<Animal>, and vice versa. Similarly, even though Int is a subtype of Number, Box<Int> is not a subtype of Box<Number>, and vice versa. This is a safety feature in Kotlin (and in many other statically typed languages, like Java and C#). It prevents us from making mistakes like putting a Cat into a Box<Dog> just because Cat and Dog are both subtypes of Animal. This is the default mode, in which you can both produce and consume values. For example MutableList<Dog> is not a subtype of MutableList<Animal> or vice versa because you can produce (get()) and consume (add()) values and it is invariant.
open class Animal
class Dog : Animal()
class Cat : Animal()
// Box class defined as invariant
class Box<T>
// Invariance with MutableList
// MutableList is defined as MutableList<T> in Kotlin, it's invariant.
// You can produce and consume values (get() and add()).
// Meaning you can't use a MutableList<Dog> where a MutableList<Animal> is expected and vice versa.
val mutableAnimalsFromDogs: MutableList<Animal> = mutableListOf<Dog>() // This will give a compiler error
val mutableDogsFromAnimals: MutableList<Dog> = mutableListOf<Animal>() // This will give a compiler error
// Invariance with Box
// Box is defined as Box<T> in Kotlin, it's invariant.
// Meaning you can't use a Box<Dog> where a Box<Animal> is expected and vice versa.
val boxOfAnimalsFromDogs: Box<Animal> = Box<Dog>() // This will give a compiler error
val boxOfDogsFromAnimals: Box<Dog> = Box<Animal>() // This will give a compiler errorCovariance (out)
The relationship is preserved in the same direction when the classes are used as generic parameters. If Dog is a subtype of Animal, Box<Dog> is a subtype of Box<Animal>. This is usually allowed when the generic parameter is only used in "output" positions (like return values), but not in "input" positions (like method parameters).
For example, Kotlin’s List interface represents a read-only collection, which means that if Dog is a subtype of Animal, then List<Dog> is a subtype of List<Animal>. Such classes or interfaces are called covariant. You can only read an element at a certain position in the list (using the method get()) and consume the value. List is defined with the out modifier. The out variance modifier is used to declare a class as covariant on a certain type parameter.
open class Animal
class Dog : Animal()
class Cat : Animal()
// Box class defined as invariant
class Box<T>
// Let's assume we have some animals – dogs and cats
val animals: List<Animal> = listOf(Dog(), Cat())
val dogs: List<Dog> = listOf(Dog(), Dog())
val cats: List<Cat> = listOf(Cat(), Cat())
// Covariance with List
// Because List is defined as List<out T> in Kotlin, it's covariant.
// Meaning you can use List<Dog> wherever List<Animal> is expected.
// val animalsFromDogs: List<Animal> = dogs // This is okay, read-onlyNote, we can change the Box definition to use covariance with out.
open class Animal
class Dog : Animal()
class Cat : Animal()
// Box class defined as covariant
class Box<out T>
// Now, you can use Box<Dog> wherever Box<Animal> is expected.
val dog: Dog = Dog()
val dogBox: Box<Dog> = Box<Dog>()
val animalBox: Box<Animal> = dogBox // This is okayNote that when you define a class with covariance using the out keyword, it restricts the usage of the type T inside the class. You can only use T as a return type, not as a parameter type. This is because it could lead to class invariant violations. For example, if you could add a Cat to a Box<Dog>, it would no longer be a Box<Dog>. The out keyword is telling the compiler that the generic class (or interface) produces values of type T and never consumes them, ensuring type safety.
Contravariance (in)
The relationship is preserved in the opposite direction when the classes are used as generic parameters. If Dog is a subtype of Animal, Box<Animal> is a subtype of Box<Dog>. This is usually allowed when the generic parameter is only used in "input" positions, but not in "output" positions, for example, in the parameters of methods.
open class Animal
class Dog : Animal()
class Cat : Animal()
class Box<in T>
fun main() {
val dogBox: Box<Dog> = Box<Animal>()
val catBox: Box<Cat> = Box<Animal>()
}Note that when you define a class with contravariance using the in keyword, it restricts the usage of the type T inside the class. We declare the Box class with the in modifier on the type parameter T. This allows us to assign Box<Animal> to variables of type Box<Dog> and Box<Cat>, since Animal is a superclass of both Dog and Cat. This is possible because we're using contravariance, which allows us to assign a more general type (Box<Animal>) to a more specific type (Box<Dog> or Box<Cat>). The in keyword is telling the compiler that the generic class (or interface) consumes values of type T and never produces them, ensuring type safety. Note that with contravariance, you can only use the type parameter T as an input (in function parameters) and not as an output (in return types).
Let's consider an example of a Comparator interface. A comparator is a good example of contravariance because it consumes objects (to compare them) but does not produce them. Here's how you might define a contravariant Comparator in Kotlin:
interface Comparator<in T> {
fun compare(e1: T, e2: T): Int
}
open class Animal {
open fun feed() = println("Feeding an animal")
}
class Dog : Animal() {
override fun feed() = println("Feeding a dog")
}
fun main() {
val animalComparator: Comparator<Animal> = object : Comparator<Animal> {
override fun compare(e1: Animal, e2: Animal): Int {
// Comparison logic goes here
return 0
}
}
val dogComparator: Comparator<Dog> = animalComparator
// This is okay because Comparator is contravariant in its type parameter.
}Type projections: use-site variance
Use-site variance refers to the ability to specify variance modifiers at the point of use, rather than in the class or interface definition. In Kotlin, type projections are used to achieve use-site variance.
Imagine you need to copy a MutableList of Animals, Dogs and Cats. Remember that a MutableList is invariant. You can use use-site variance to declare restrictions.
open class Animal
class Dog : Animal()
class Cat : Animal()
fun copyAnimals(source: MutableList<out Animal>, destination: MutableList<in Animal>) {
destination.addAll(source)
}
fun main() {
val dogs: MutableList<Dog> = mutableListOf(Dog(), Dog())
val cats: MutableList<Cat> = mutableListOf(Cat(), Cat())
val animals: MutableList<Animal> = mutableListOf()
copyAnimals(dogs, animals)
copyAnimals(cats, animals)
println(animals)
}In this example, we have the Animal, Dog, and Cat classes, similar to the previous example. We also have a function called copyAnimals that takes two parameters: source of type MutableList<out Animal> (covariant) and destination of type MutableList<in Animal> (contravariant). The copyAnimals function copies elements from the source list to the destination list using the addAll function. Since source is declared with use-site variance (out Animal), it allows reading elements of type Animal from the list. And since destination is declared with use-site variance (in Animal), it allows writing elements of type Animal to the list. We then call the copyAnimals function twice: first, with dogs as the source and animals as the destination, and then, with cats as the source and animals as the destination. This allows us to copy both Dog and Cat objects to the animals list.
Type projections: star projection
Star projection is a feature in Kotlin that allows working with generic types when the exact type argument is unknown or irrelevant. It is denoted by the * symbol and can be used in place of a specific type argument in certain scenarios. Star projection is useful when you want to work with a generic type in a way that accommodates any type argument that satisfies certain constraints. It allows for more flexibility and generality in code that doesn't rely on the specific type argument.
open class Animal
class Dog : Animal()
class Cat : Animal()
class Box<T>(val item: T)
fun printItems(boxes: List<Box<*>>) {
for (box in boxes) {
println(box.item)
}
}
fun main() {
val dogBox = Box(Dog())
val catBox = Box(Cat())
val boxes: List<Box<*>> = listOf(dogBox, catBox)
printItems(boxes)
}The printItems function takes a list of Box<*> (star-projected) as a parameter. Inside the function, we iterate over the boxes and print the item contained in each box. Since the exact type argument is unknown, we can only perform read operations on the items. Since the type argument is unknown or irrelevant in this context, we use star projection (Box<*>)) to accommodate both Box<Dog> and Box<Cat> instances. The code is able to handle different subtypes of Animal without relying on the specific type argument. The star projection can be used with the Box<T> class, allowing for flexibility and generality when working with generic types where the exact type argument is unknown or irrelevant.
Things to remember
When you are working with generics you need to remember these tips:
Invariant is the default mode.
Type parameters that are only used for public out-positions (function results and read-only property types) should be covariant so they have an
outmodifier.Type parameters that are only used for public in-positions (function parameter types) should be contravariant so they have an
inmodifier.
Let's see one more example:
open class Animal {
fun feed() = println("The animal is fed")
}
class Dog : Animal() {
fun pet() = println("The dog is petted")
}
class Cat : Animal() {
fun ignore() = println("The cat ignores you")
}
class Box<in T, out R>(private var t: T, private val r: R) {
fun put(t: T) {
this.t = t
}
fun take(): R {
return r
}
}
fun main() {
val dogBox: Box<Animal, Dog> = Box(Dog(), Dog())
dogBox.put(Cat()) // OK: Cat is a subtype of Animal
val dog: Dog = dogBox.take() // OK: take() returns Dog
val catBox: Box<Dog, Animal> = Box(Dog(), Cat())
// Error: Can't put Cat in Box<Dog, Animal>
// catBox.put(Cat())
val animal: Animal = catBox.take() // OK: take() returns Animal
}In this example, the put method accepts a value of type T, so T is annotated with in, meaning it's contravariant. The take method returns a value of type R, so R is annotated with out, meaning it's covariant. dogBox is Box<Animal, Dog>, so you can put any Animal in it (because Animal is a supertype of Dog), and when you take an object out, it will be a Dog. catBox is Box<Dog, Animal>, so you can only put Dog objects in it (because Dog is a subtype of Animal), and when you take an object out, it will be an Animal.
Please note that in and out should be used carefully, as they can lead to runtime errors if used incorrectly. In a more complex program, this could lead to bugs.
Conclusion
In this topic, we've discussed three types of generic variance: invariance, covariance, and contravariance. We've also talked about type projection in the context of use-site variance. Now you can understand how to improve the use of generics in your code. It's time to put your knowledge to the test with a few tasks. Are you ready?