All the way back in Chapter 8, when we introduced collections, we saw our first generic type: List<String>
. Then we saw more generics in Chapter 9 when we looked at the Pair
and Map
classes. In order to stay focused on learning about collection types, we glossed over the details about these classes. We’ve put off learning about them for long enough, though! It’s finally time for us to gain a full understanding of generics, so buckle up!
Mugs and Beverages
Jennifer’s bakery café offers delightful, sweet pastries and hot beverages, which you can enjoy at a dainty table or in a cozy chair.
As Jennifer’s business has been picking up lately, she reached out to her brother Eric, who has been learning Kotlin, to model her operations for her. Eric decided to start with the beverage menu, which includes light, medium, and dark roast coffees, which customers receive in a ceramic mug. He decided to use enum class to represent the coffee options, and a simple class to represent the mug. He also added a drink()
function, which prints a line whenever a customer drinks the coffee.
enum class Coffee { LIGHT_ROAST, MEDIUM_ROAST, DARK_ROAST } class Mug(val beverage: Coffee) fun drink(coffee: Coffee) = println("Drinking coffee: $coffee")
Now, whenever a customer orders a coffee, Eric can just instantiate a
Mug
with the right kind of coffee.val mug = Mug(Coffee.LIGHT_ROAST)
And when he takes a sip of his coffee, Eric can call the
drink()
function with the beverage in the mug.drink(mug.beverage) // Drinking coffee: LIGHT_ROAST
One day, Jennifer decided that it was time to expand her beverage menu to include hot tea. As with the coffee, Eric chose to model these new tea options as an enum class. He also created an overload of the
drink()
function that accepts tea.enum class Tea { GREEN_TEA, BLACK_TEA, RED_TEA } fun drink(tea: Tea) = println("Drinking tea: $tea")
The
Mug
class also needed some work. After all, you can’t pass an instance ofTea
to aMug
that accepts onlyCoffee
. Eric thought about it. “Well, I guess I can just create another mug class, just for tea.” So he renamedMug
toCoffeeMug
, and added another class, namedTeaMug
.class CoffeeMug(val coffee: Coffee) class TeaMug(val tea: Tea)
As he finished typing, Jennifer walked up to him and said, “Have I mentioned that I’m going to be expanding my beverage menu next week? I’ll be adding hot chocolate and apple cider!”
Eric grimaced. “The more beverages my sister adds to the menu, the more mug classes I’m going to have to create. Things are going to get out of hand quickly. How can I make a single
Mug
class that can hold any kind of beverage?”So he thought about how a subtype can be used anywhere that the compiler expects a supertype. “That’s it!” he exclaimed, “I’ll create an interface for the general concept of a beverage, and update the tea and coffee classes so that they’re subtypes!” Having recently learned about sealed types, he decided to make the
Beverage
interface sealed. He also updated theMug
class so that its property’s type isBeverage
. Here’s what he ended up with.sealed interface Beverage enum class Tea : Beverage { GREEN_TEA, BLACK_TEA, RED_TEA } enum class Coffee : Beverage { LIGHT_ROAST, MEDIUM_ROAST, DARK_ROAST } class Mug(val beverage: Beverage)
After making this change, he was able to create instances of
Mug
with eitherCoffee
orTea
.val mugOfCoffee = Mug(Coffee.LIGHT_ROAST) val mugOfTea = Mug(Tea.BLACK_TEA)
“Huzzah! I’ve got a single mug class that can hold any kind of beverage!” When he ran his Kotlin code, though, he was alarmed to see some compiler errors - the call to the
drink()
functions no longer worked!fun drink(coffee: Coffee) = println("Drinking coffee: $coffee") fun drink(tea: Tea) = println("Drinking tea: $tea") drink(mugOfCoffee.beverage) drink(mugOfTea.beverage)
ErrorWhy isn’t this working? Let’s take a closer look.
Declared Types, Actual Types, and Assignment Compatibility
As we learned back in Chapter 12, objects have more than one type at a time. For example, a
Coffee
object has a type ofCoffee
, but it also has a type ofBeverage
andAny
. This means it can be assigned to a variable that is declared with any of those types.val coffee: Coffee = Coffee.MEDIUM_ROAST val beverage: Beverage = Coffee.MEDIUM_ROAST val anything: Any = Coffee.MEDIUM_ROAST
As a result, there can be a difference between the type of the variable and the type of the object inside that variable. This brings up a few distinguishing terms.
- The type that a variable has been declared with is known as its declared type.
- The most specific type of the object inside a variable is known as its actual type or its runtime type.
When we assign an object to a variable, property, or parameter, the actual or runtime type is irrelevant. Only the declared type matters. For example, in the following code listing, the second line fails.
val beverage: Beverage = Coffee.MEDIUM_ROAST val coffee: Coffee = beverage
ErrorEven though, as you and I read this code, we know that the actual type of the object inside the
beverage
variable at runtime will definitely beCoffee
, it’s declared type isBeverage
. And since a variable whose type isBeverage
could possibly hold objects other thanCoffee
- it could hold aTea
object, for example - Kotlin won’t let us perform this assignment.In order for an assignment to succeed, the type of an expression on the right-hand side of the equal sign must be the same as the declared type on the left-hand side, or one of its subtypes.
Assignment compatibility is the term we use to describe whether the object on the right-hand side can be assigned to the variable’s type on the left-hand side. When it can be assigned, we say that the object is assignment-compatible with that type.
Even though we’ve been talking specifically about literal assignments involving an equal sign, it’s important to note that when we call a function with an argument, we’re effectively assigning an object to the function’s parameter, so all of the same rules apply.
Let’s look at the relevant parts of Eric’s code again.
fun drink(coffee: Coffee) = println("Drinking coffee: $coffee") fun drink(tea: Tea) = println("Drinking tea: $tea") class Mug(val beverage: Beverage) val mugOfCoffee = Mug(Coffee.LIGHT_ROAST) drink(mugOfCoffee.beverage)
ErrorRegardless of the
beverage
property’s actual type at runtime (e.g.,Coffee
in this example), it can be assigned neither to a parameter of typeCoffee
, nor to a parameter of typeTea
, because its declared type isBeverage
. In other words,beverage
is not assignment-compatible with either of thedrink()
overloads. This is why Eric’s code is failing.In order for this to compile without errors, he would need to cast the
beverage
property back to theCoffee
type, in order to make it assignment-compatible with one of thedrink()
overloads.val mugOfCoffee = Mug(Coffee.LIGHT_ROAST) drink(mugOfCoffee.beverage as Coffee)
Although this works, this means that every time Eric gets the
beverage
property off of aMug
, if he needs to assign it to the more specificCoffee
orTea
type, he would need to cast it. Every time!It’d be great if he could have a single
Mug
class that could work with any kind ofBeverage
, and avoid the need to cast thebeverage
property all the time.Thankfully, this can be solved with generic types!
Introduction to Generic Types
Declaring a Generic Type
For a long time now, we’ve utilized functions to reuse expressions. By calling them with different arguments, we get different results. For example, we created this function to figure out the circumference of a circle back in Listing 2.3.
fun circumference(radius: Double) = 2 * pi * radius
A function with a parameter like this is kind of like a fill-in-the-blank expression.
Whenever you call it, you give it an argument to put into that blank.
So a function’s parameter is basically a blank that you can fill in with a value.
What if there were something similar for _types~? For example, what if the type of the
beverage
property were a blank that we could fill in with different types?Imagine all the different types we could put there!
Well, just like functions can have parameters, types can have type parameters.
To add a type parameter to a class, we can put the name of the type parameter inside angle brackets
<
and>
, to the right of the name of the class, like this:Here, we named the type parameter
BEVERAGE_TYPE
, but it’s more typical to give them names that are just one letter long. such asT
.class Mug<T>(val beverage: T)
With this, the
Mug
class can now be used with different types! Let’s see how to do that next.Using a Generic Type
When we call a function that has a parameter, we have to supply an argument for that parameter. Likewise, when creating an instance of a
Mug
, we’ll have to supply a type argument for the type parameter namedT
. To do this, we can put the type argument in angle brackets next to the name of the class, like this:Much like calling a function with an argument is effectively the same as replacing the parameter with that value, creating a type with a type argument is a lot like filling in the type parameter with that type.1
Even though a type argument must be supplied when constructing a generic class, we usually don’t have to write it out ourselves, because Kotlin can use its type inference to figure it out. For example, since the constructor of
Mug
takes an argument whose type isT
, Kotlin knows that if you create an instance withMug
and pass it aCoffee
object, then the type argument for thisMug
instance should beCoffee
.Note that the type of this
mug
variable is notMug<T>
. It’sMug<Coffee>
, and we can explicitly specify its type like this:val mug: Mug<Coffee> = Mug(Coffee.LIGHT_ROAST)
It’s important to be able to distinguish between
Mug<T>
andMug<Coffee>
. A class that has a type parameter, such asMug<T>
, is known as a generic type. A generic whose type parameter has been filled with a type argument is known as a parameterized type.The great thing about making the
Mug
class generic is that thebeverage
property will retain its specific type.
- When getting the
beverage
property from aMug<Coffee>
, its type will beCoffee
.- When getting the
beverage
property from aMug<Tea>
, its type will beTea
.Because of this, there’s no need to cast it when using it with code that expects a specific type! For example, in the following code, we don’t need to cast the
beverage
property toTea
, because that’s what its type is already. So, the call todrink(tea: Tea)
works just fine.val mug: Mug<Tea> = Mug(Tea.GREEN_TEA) drink(mug.beverage)
After Eric made all of these changes, the customers were able to enjoy their tea and coffee. He was able to use a single
Mug
class, and never needed to cast thebeverage
property.He was about to discover some surprises with his code, though! Let’s find out what happened next.
Type Parameter Constraints
One day, Jennifer decided to replace all of the café’s ceramic mugs with fancy new temperature-controlled mugs that will keep the customers’ tea and coffee at just the right drinking temperature.
She looked at Eric and said, “The mug will need to set its temperature based on the beverage inside it. If there’s tea in the mug, then it should set the temperature to 140 degrees. If there’s coffee in there, then it should set it to only 135 degrees.”
Eric rolled up his sleeves and got to work. He updated all of the
Beverage
types, so that coffee and tea can each have its own ideal temperature.sealed interface Beverage { val idealTemperature: Int } enum class Tea : Beverage { GREEN_TEA, BLACK_TEA, RED_TEA; override val idealTemperature = 140 } enum class Coffee : Beverage { LIGHT_ROAST, MEDIUM_ROAST, DARK_ROAST; override val idealTemperature = 135 }
Next, in order to represent the temperature-controlled mug, he added a new
temperature
property to hisMug
class, and assigned it to the beverage’s ideal temperature. To his surprise, he ended up with a compile-time error!class Mug<T>(val beverage: T) { val temperature = beverage.idealTemperature }
ErrorIt seems that the
Mug
class is unable to see the newidealTemperature
property that he added. As Eric wondered about this, a customer walked up to Jennifer and asked why his mug had a string in it!“A string? It’s supposed to be a beverage!” Jennifer cried. She shot a glance to her brother, who looked at the customer. It was true, there was a string in his mug! Eric looked back down at his computer screen and hammered out some more code. Sure enough, it was possible to put a
String
in aMug
. In fact, as theMug
code is currently written, literally anything can be stuffed inside it!val mugOfString: Mug<String> = Mug("How did this get in the mug?") val mugOfInt: Mug<Int> = Mug(5) val mugOfBoolean: Mug<Boolean> = Mug(true) val mugOfEmptiness: Mug<Any?> = Mug(null)
So Eric now had two problems:
- The
Mug
class can’t see theidealTemperature
property of itsBeverage
. (Listing 18.18)- The
Mug
class can hold an object of any type, but it should only hold aBeverage
. (Listing 18.19)Thankfully, the solution to both of these problems is the same - Eric needs to constrain the type parameter, so that only
Beverage
types can go inside it.To do this, he can add a type parameter constraint, which will ensure that the type argument is of a particular type. To do this, after the name of the type parameter, add a colon and the name of the type that will serve as the upper bound for that parameter. For example, to add an upper bound constraint of
Beverage
, we can write this.With this change, T can only ever be
Beverage
or one of its subtypes -Tea
orCoffee
.Using any other type as a type argument will cause a compiler error.
val mugOfString: Mug<String> = Mug("This won't work any more!")
ErrorAlso, now that Kotlin knows that
beverage
will be some kind ofBeverage
, it’s possible to access any properties or functions that theBeverage
type includes! Because theBeverage
type includes theidealTemperature
property, the following code now works.class Mug<T : Beverage>(val beverage: T) { val temperature = beverage.idealTemperature }
If we don’t specify the upper bound, Kotlin will assume a default of
Any?
, which means it’ll accept any type, as we saw in Listing 18.19. If we know for sure that only certain types should be used for a type argument, it’s usually a good idea to give it a type parameter constraint so that the compiler will enforce those rules.Well, we’ve learned a lot about generics from Jennifer’s bakery café. There are more things we can do with generics, though! Let’s take a look at more ways they can be used.
Generics in Practice
Using Type Parameters
So far, we’ve only used a type parameter for a property parameter, but we can use a type parameter almost anywhere that we might normally write a type within the class body. Frequently, they’re used for function parameters and return types, as shown here.
class Dish<T>(private var food: T) { fun replaceFood(newFood: T) { println("Replacing $food with $newFood") food = newFood } fun getFood(): T = food }
Generics with Multiple Type Parameters
Our examples so far have only included one type parameter, but it’s possible for a class to have more than one. For example, a combination order could include one type parameters for food and one type parameter for a beverage. When declaring them, just use a different name for each type parameter, and separate them with commas, like this.
class ComboOrder<T : Food, U : Beverage>(val food: T, val beverage: U) val combo: ComboOrder<Pastry, Tea> = ComboOrder(Pastry.MUFFIN, Tea.GREEN_TEA)
Don’t go crazy with it, though! It’s rare to use more than two or three type parameters in a generic type.2
Generic Interfaces and Super Classes
In addition to classes, interfaces can also be generic. For example, here’s a generic interface with one property.
interface Dish<T> { val food: T }
When implementing this class, we can substitute actual types for the type parameter. For example, we can create a
BowlOfSoup
class and instance like this.class BowlOfSoup(override val food: Soup) : Dish<Soup> val bowlOfSoup: BowlOfSoup = BowlOfSoup(Soup.TOMATO)
Alternatively, the implementing class can itself declare a type parameter, and relay that to the interface, as shown here.
class Bowl<F>(override val food: F) : Dish<F> val bowlOfSoup: Bowl<Soup> = Bowl(Soup.TOMATO)
Similarly, abstract and open classes can also be generic, and extending them works like you would expect.
abstract class Dish<T>(val food: T) class BowlOfSoup(food: Soup) : Dish<Soup>(food) class Bowl<F>(food: F) : Dish<F>(food)
Just be sure to provide a type argument when calling the superclass’ constructor. And again, that type argument can be a type parameter on the subclass, as is the case on the third line in this code listing.
Generic Functions
We’ve seen how both classes and interfaces can be generic, and how their functions can utilize those type parameters. However, functions can also declare their own type parameters. This is especially common for top-level functions - those which are declared outside of a class.
In this case, the type parameter is declared between the
fun
keyword and the name of the function. For example, we can make a top-level function that wraps the constructor ofMug
.fun <T : Beverage> serve(beverage: T): Mug<T> = Mug(beverage)
When calling this function, we can specify the type argument explicitly by putting it in angle brackets to the right of the function name.
val mug = serve<Coffee>(Coffee.DARK_ROAST)
But again, Kotlin can usually infer the type, in which case you can omit it.
val mug = serve(Coffee.DARK_ROAST)
We can even create generic extension functions where the receiver type is a type parameter. For example, we can change the
serve()
function into an extension function like this:fun <T : Beverage> T.pourIntoMug() = Mug(this) val mug = Coffee.DARK_ROAST.pourIntoMug()
We’ve now got a good understanding of the range of possibilities with generics. Kotlin’s standard library also includes lots of generic types and functions, and we’ve already seen some of them throughout this book. Now that we understand more about what generics are and how they work, let’s review a few of them!
Generics in the Standard Library
List
andSet
Back in Chapter 8, we created
List
andSet
collections using functions likelistOf()
andmutableSetOf()
, which are generic functions. As a refresher, here’s how we can use thelistOf()
function to create a list of menu items.val menu = listOf("Bagel", "Croissant", "Muffin", "Crumpet")
Much like the
serve()
function above, the code in thelistOf()
function ends up calling the constructor of a generic class calledArrayList
. TheArrayList
class implements theList
interface, which is also generic.Most of the time, we just allow Kotlin’s type inference to fill in the type arguments for us, but we could explicitly specify them like this:
val menu: List<String> = listOf<String>("Bagel", "Croissant", "Muffin", "Crumpet")
The type argument to the
listOf()
function isString
(regardless of whether we explicitly specify it or allow Kotlin to infer the type based on the function’s arguments). The return type of this function depends on the type argument it was called with. Since it was called with a type argument ofString
, the return type isList<String>
.
Pair
Pair
is a generic data class that we came across in Chapter 9. It has two type parameters, which determine the type of the two elements that it contains. As you might recall, we can create an instance ofPair
by calling its constructor:val pair = Pair("Crumpet", "Tea")
Again, we use type inference most of the time, but we could explicitly specify the two type arguments like this.
val pair: Pair<String, String> = Pair<String, String>("Crumpet", "Tea")
This listing looks much more intimidating than the previous listing, so it’s usually a good idea to just allow Kotlin’s type inference to do its thing.
The second way to create a
Pair
is to use theto()
infix function, like this:val pair = "Crumpet" to "Tea"
The
to()
function is a generic extension function, similar to thepourIntoMug()
function we created above, except that it has two type arguments. Theto()
function exists to make our code read more naturally, so you should never explicitly specify the type arguments when calling it. Just to help demystify the magic, though, this is how you could do that.val pair = "Crumpet".to<String, String>("Tea")
We’ve seen why generics are helpful, how to create them, how to use them, and a few examples of how they’re used in the standard library. Before we wrap up this chapter, let’s look at some of the trade-offs involved when we use them.
Trade-Offs of Generics
As we’ve seen, generics are a great way to reuse a class or interface, without needing to cast its properties or function results. They come with some trade-offs, though! Here are a few to consider.
Assignment Compatibility of Generic Types
Back in Listing 18.6 we had a version of the
Mug
class that was not generic. This is what it looked like:class Mug(val beverage: Beverage)
With this code, any
Mug
object can be assigned to anyMug
variable, regardless of what kind of beverage it holds. For example, we can declare aMug
variable, and assign it a mug of coffee or a mug of tea.val mugOfCoffee: Mug = Mug(Coffee.DARK_ROAST) val mugOfTea: Mug = Mug(Tea.RED_TEA) var mug: Mug = mugOfCoffee mug = mugOfTea
Now let’s consider a generic version of this class.
class Mug<T : Beverage>(val beverage: T)
When using this class, we’ll end up with parameterized types such as
Mug<Coffee>
andMug<Tea>
. By default, these parameterized types are not assignment-compatible. For example, it’s not possible to assign an instance ofMug<Tea>
to a variable declared withMug<Coffee>
.val mugOfCoffee: Mug<Coffee> = Mug(Coffee.DARK_ROAST) val mugOfTea: Mug<Tea> = Mug(Tea.RED_TEA) var mug: Mug<Coffee> = mugOfCoffee mug = mugOfTea
ErrorThis isn’t surprising. What can catch some developers by surprise, though, is that it’s also not possible to assign
mugOfCoffee
ormugOfTea
to a variable that has typeMug<Beverage>
.val mugOfCoffee: Mug<Coffee> = Mug(Coffee.DARK_ROAST) val mugOfTea: Mug<Tea> = Mug(Tea.RED_TEA) var mug: Mug<Beverage> = mugOfCoffee
ErrorThere are some ways that we can work around this, as we’ll see throughout the next chapter! Note that this assignment does actually work when assigning directly from a constructor call.
val mug: Mug<Beverage> = Mug(Coffee.DARK_ROAST)
Type Erasure
Probably the most significant trade-off is called type erasure. Although an object’s type arguments are known at compile-time, they are erased before your code runs. In other words, an object’s type arguments are not known at runtime.
Let’s look at a few ways that type erasure can affect your code.
Checking the Type of Type Arguments
One consequence of type erasure is that it’s not possible to use
is
to check the type of a parameterized type’s type argument at runtime. For example, the following code tries to determine whether themug
instance has a type ofMug<Tea>
orMug<Coffee>
. This results in a compiler error.val mug: Mug<Beverage> = Mug(Coffee.MEDIUM_ROAST) when (mug) { is Mug<Tea> -> println("Sipping on tea: ${mug.beverage}!") is Mug<Coffee> -> println("Sipping on coffee: ${mug.beverage}!") }
ErrorHowever, it is still possible to check the type of a property that was declared with a type parameter (such as
beverage
), so this works just fine:val mug: Mug<Beverage> = Mug(Coffee.MEDIUM_ROAST) when (mug.beverage) { is Tea -> println("Sipping on tea: ${mug.beverage}!") is Coffee -> println("Sipping on coffee: ${mug.beverage}!") }
Function Overloads on JVM
Kotlin code can target different kinds of computer systems and environments. Most often, a Kotlin project targets the Java Virtual Machine (JVM), and if you’ve been following along with the code in this book, that’s probably what you’ve been doing. However, you can also use Kotlin to create programs that run natively on different platforms, like Windows, Linux, Mac, and so on. In fact, you can even create Kotlin code that compiles down to JavaScript!
Once in a while, there’s a limitation that affects some of these platforms, but not others. When it comes to type erasure, Kotlin code that targets the JVM has a limitation that doesn’t affect native or JavaScript targets: it’s not possible to use function overloads where the functions’ parameters differ only on their type arguments. For example, in the following code, the only difference between the signatures of these two functions is the type argument of their parameters. Compiling this code on the JVM produces an error.
Even with these trade-offs, generics are incredibly helpful, so it’s important to know about them!
Summary
As the seasons changed, so did the menu at Jennifer’s bakery café, but thanks to Eric’s newfound understanding of Kotlin generics, adapting to these changes became a piece of cake! And now that you know all about generics, you’ll be able to adapt to changes, too! Here’s a quick review of what we learned:
- The problem that generics solve.
- How to declare and use a generic type.
- How type parameter constraints can ensure that only the right kinds of type arguments are used.
- How to use generic interfaces and superclasses.
- How to declare and use generic functions.
- How generics are used in the standard library.
- Trade-offs of using generics.
As we saw in this chapter, the subtyping of generics doesn’t always work like we expect -
Mug<Coffee>
is not naturally a subtype ofMug<Beverage>
. However, with a few small changes, we can make that happen. Stay tuned for the next chapter where we cover the fascinating topic of generic variance!
Note that the right side of the illustration shows what the “effective” class would be. In other words, by creating a
Mug<Coffee>
type, it’s as if we had declared another class calledMug<Coffee>
whosebeverage
type isCoffee
. However, we don’t actually write that code - by creating the generic class in Listing 18.14, Kotlin has all it needs to create the type for us! ↩︎Once in a while you might find a library with lots of type parameters in a single generic type. For example, the Arrow functional programming library includes a class called
Tuple10
that includes ten type parameters. Most of the time, you shouldn’t need that many in your own code, though. ↩︎