Kotlin Generics Tutorial: Getting Started
In this tutorial, you’ll become familiar with Kotlin generics so that you can include them in your developments to make your code more concise and flexible. By Pablo L. Sordo Martinez.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Kotlin Generics Tutorial: Getting Started
15 mins
Covariance and Contravariance
Fortunately, Kotlin (among other languages) offers a few alternatives to make your code more flexible, allowing relationships like Cage<Dog>
being a sub-type of Cage<T: Animal>
, for instance.
The main idea is that language limitations on this topic arise when trying to read and modify generic data defined in a type. The solution proposed is constraining this read/write access to only allow one of them. In other words, the only reason why the compiler does not permit the assignation cageAnimal = cageDog
, is to avoid a situation where the developer decides to modify (write) the value cageAnimal.animal
. What if we could forbid this operation so that this generic class would be read-only?
Declaration-Site Variance
Try the following class based on the zoo/exotic-pet shop example in your playground:
class CovariantCage<out T : Animal>(private val t: T?) {
fun getId(): Int? = t?.id
fun getName(): String? = t?.name
fun getContentType(): T? = t?.let { t } ?: run { null }
fun printAnimalInfo(): String = "Animal ${t?.id} is called ${t?.name}"
}
As you can see, there is an unknown term: out
. This Kotlin reserved keyword indicates that T
is only going to be produced by the methods of this class. Thus, it must only appear in out positions. For this reason, T
is the return type of the function getContentType()
, for example. None of the other methods include T
as an input argument either. This makes CovariantCage
covariant in the parameter T
, allowing you to add the following assignation:
val dog: Dog = Dog(id = 1, name = "Stu", furColor = FurColor.PATCHED)
var cage1: CovariantCage<Dog> = CovariantCage(dog)
var cage2: CovariantCage<Animal> = cage1 // thanks to being 'out'
By making CovariantCage<Dog>
extend from CovariantCage<Animal>
, in run-time, any valid type (Animal
, Dog
, Bird
, etc.) will replace T
, so type-safety is guaranteed.
On the other hand, there is contravariance. The Kotlin reserved keyword in
indicates that class methods will only consume a certain parameter T
, not producing it at all. Try this class in your playground:
class ContravariantCage<in T : Bird>(private var t: T?) {
fun getId(): Int? = t?.id
fun getName(): String? = t?.name
fun setContentType(t: T) { this.t = t }
fun printAnimalInfo(): String = "Animal ${t?.id} is called ${t?.name}"
}
Here, setContentType
replaces getContentType
from the previous snippet. Thus, this class always consumes T
. Therefore, the parameter T
takes only in positions in the class methods. This constraint leads to state, for example, that ContravarianceCage<Animal>
is a sub-type of ContravarianceCage<Bird>
.
Further information is available in the Kotlin official documentation.
Type Projection
The other alternative that Kotlin offers is type projection, which is a materialization of use-site variance.
The idea behind this is indicating a variance constraint at the precise moment in which you use a parameterized class, not when you declare it. For instance, add this class and method to try:
class Cage<T : Animal>(val animal: T, val size: Double)
...
fun examine(cageItem: Cage<out Animal>) {
val animal: Animal = cageItem.animal
println(animal)
}
And then, when using it:
val bird: Bird = Eagle(id = 7, name = "Piti", featherColor = FeatherColor.YELLOW, maxSpeed = 75.0f)
val animal: Animal = Dog(id = 1, name = "Stu", furColor = FurColor.PATCHED)
val cage01: Cage<Animal> = Cage(animal = animal, size = 3.1)
val cage02: Cage<Bird> = Cage(animal = bird, size = 0.9)
examine(cage01)
examine(cage02) // 'out' provides type-safety so that this statement is valid
Generics in Real Scenarios
Reaching this point, you may wonder in which scenarios are generics worth using. In other words, when is it convenient to put what you’ve learned here in action?
Let’s try to implement an interface to manage the zoo/exotic-pet shop. Recall:
class Cage<T : Animal>(val animal: T, val size: Double)
Create the generic interface declaration:
interface Repository<S : Cage<Animal>> {
fun registerCage(cage: S): Unit
}
Obviously, the following implementation is definitely valid:
class AnimalRepository : Repository<Cage<Animal>> {
override fun registerCage(cage: Cage<Animal>) {
println("registering cage for: ${cage.animal.name}")
}
}
However, this other is not according to the IDE:
class BirdRepository: Repository<Cage<Bird>> {
override fun registerCage(cage: Cage<Bird>) {
println("registering cage for: ${cage.animal.name}")
}
}
The reason is that Repository
expects an argument S
of type Cage<Animal>
or a child of it. By default, the latter does not apply to Cage<Bird>
. Fortunately, there is an easy solution which consists of using declaration-site variance on Cage
, so that:
class Cage<out T : Animal>(val animal: T, val size: Double)
This new condition also brings a limitation to Cage
, since it will never include a function having T
as an input argument. For example:
fun sampleFun(t: T) {
println("dummy behavior")
}
As a rule of thumb, you should use out T
in classes and methods that will not modify T
, but produce or use it as an output type. Contrary, you should use in T
in classes and methods that will consume T
, i.e. using it as an input type. Following this rule will buy you type-safety when establishing class hierarchy sub-typing.
Bonus Track: Collections
Apart from the above explanation and examples, it is rather common to get snippets about lists and collections when talking about generics. In general, List
is a type easy to implement and understand.
Try out the following example:
var list0: MutableList<Animal>
val list1: MutableList<Dog> = mutableListOf(dog2)
list0 = list1 // the IDE reports an error
The error reported, as you can imagine, relates to MutableList<Dog>
not extending from MutableList<Animal>
. However, this assignation is OK if you ensure modifications won’t happen in this collections, i.e.:
var list1: MutableList<out Animal>
A similar explanation applies to the contravariant case.
Where to Go From Here?
You can download the final version of the playground with all of these examples using the Download Materials button at the top or bottom of the tutorial.
While there are several good references for Kotlin generics, the best source may be the official documentation. It is rather concise and comes with a good number of well-documented examples.
Perhaps the best thing you can do after reading this article is to jump straight into your code and work it out. Have a look at the tips provided and use them to make your applications more flexible and re-usable.
I hope you enjoyed this tutorial about Kotlin generics. If you have any questions or comments, please join the forum discussion below!