As we learned in Chapter 12, we can substitute a subtype anywhere that a supertype is declared.
When it comes to generics, though, things don’t always work the way we might expect. While MutableList<Cow>
is a subtype of List<Cow>
, it’s not a subtype of MutableList<FarmAnimal>
.
With a little bit of magic, though, we can finagle parameterized types into becoming the subtypes that we need them to be. To do that, we first need to learn about covariance and contravariance, so let’s jump in!
Covariance
Parker works for the local Parks & Recreation Department. Since the playground is often full of parents with hungry children, he thought, “Let’s add a vending machine in the pavilion.” So he spoke with Vinnie, a guy who owns a nearby business that sets up and stocks vending machines.
“I need a vending machine where kids can put in a coin and get back a snack”, said Parker. Vinnie agreed, so they drew up a contract.
Once the paperwork was signed, Vinnie installed the new vending machine at the park, where customers could insert a coin and receive a random snack, like trail mix, a bag of gummy bears, or a candy bar. The next day, when Parker saw a kid insert a nickel and receive a bag of trail mix from the machine, he smiled to himself, knowing all was well.
A few weeks later, Parker saw Vinnie replacing the machine. Parker was a bit nervous about the change, so he spoke to him. “This new machine is still going to accept a coin and return a snack, right?”
“Yes”, began Vinnie, “Trail mix and gummy bears are in short supply, though, so this vending machine is only going to return a candy bar. That’s still okay, right?”
Parker thought about it, and finally replied, “As long as it accepts a coin and returns a snack. A candy bar is a kind of snack, so that should be fine.”
After the new machine was substituted for the old one, a child came up, inserted a coin, and received a candy bar. Parker smiled, glad that the new machine did what it was supposed to.
A few weeks passed. One day, Vinnie was at it again, replacing the machine with yet another one. A few minutes after the installation was complete, an irate father complained to Parker, “What’s wrong with the new vending machine? My hungry kid put in a coin and instead of getting a snack, he got a toy action figure - he can’t eat a toy!”
Parker quickly caught up with Vinnie again. When asked about the new machine, Vinnie replied, “Oh, I thought I’d switch out the old vending machine with a new one that returns either a snack or a toy.”
“No, no, no! The contract said the machine would accept a coin and return a snack. Toys are not snacks!” Realizing that this new machine didn’t do what Parker needed, Vinnie agreed to resolve the issue.
Covariance and Substitution
As we can see from this story, some substitutions work, but others don’t.
- When Vinnie replaced the original vending machine with one that only returns candy bars, everything was fine, because candy bars are still a kind of snack that kids can eat.
- However, when he replaced it with the machine that returns either a snack or a toy, he broke his contract - the machine no longer returned only snacks.
This brings up some important points for us to explore about substitution in Kotlin. To get started, let’s model the first vending machine that Vinnie provided - the one that accepted a coin and returned a snack.
open class VendingMachine { open fun purchase(money: Coin): Snack = randomSnack() }
Although this chapter won’t include definitions for
Snack
and its related types, here’s a class diagram that shows them with their type hierarchies.Now let’s model Vinnie’s second vending machine by extending the one above. This one looks almost identical to it, but instead of a random snack, it only ever returns a candy bar.
class CandyBarMachine : VendingMachine() { override fun purchase(money: Coin): Snack = CandyBar() }
Any code that expects a
VendingMachine
can work with aCandyBarMachine
, because it’s a subtype ofVendingMachine
- it still accepts a coin and returns a snack, just likeVendingMachine
does. Because of this, we can substitute aCandyBarMachine
for aVendingMachine
. In other words, we can assign aCandyBarMachine
to a variable declared as aVendingMachine
.Similarly, we can send it as an argument to a function that excepts a
VendingMachine
.As we saw in the story, a
CandyBar
is a kind ofSnack
, so it’s also safe to declare that theCandyBarMachine
only returnsCandyBar
objects. Let’s take the code from Listing 19.2 and update the return type.class CandyBarMachine : VendingMachine() { override fun purchase(money: Coin): CandyBar = CandyBar() }
As the story revealed, this only works one way. When Vinnie tried to replace the vending machine with one that returned a product that could be either a snack or a toy, he broke his contract. Kids expect to be able to eat anything that the vending machine provides, but toys aren’t edible!
The same principle holds true in Kotlin - a subclass can’t return a more general type. For example, if we create a
ToyOrSnackMachine
that tries to return any kind ofProduct
, we’ll get a compile-time error.class ToyOrSnackMachine : VendingMachine() { override fun purchase(money: Coin): Product = randomToyOrSnack() }
ErrorMost classes and interfaces associate with other types. Associated types can appear in a variety of places - as function parameter types, function return types, property types, and so on. For example, the
VendingMachine
class associates with two other types:
Coin
- the type of the parameter inpurchase()
Snack
- the return type ofpurchase()
The
VendingMachine
class can have supertypes and subtypes. Similarly, the associated typesCoin
andSnack
can also have their own supertypes and subtypes.The nature of the relationship between the type hierarchy of
VendingMachine
and the type hierarchy of its associated types can be described by variance. As we look at the code ofVendingMachine
andCandyBarMachine
side by side, we can see that relationship emerge.Specifically, we can see that as we make a more specific
VendingMachine
, we can return a more specificSnack
from thepurchase()
function. Because they become more specific together, this kind of variance is called covariance, where “co-” is a prefix that means “together”. When talking about variance, developers normally say it one of these ways:
- “A type is covariant with regard to its return types”
- “A type is covariant on its return types”
To sum up this section, just remember that within a subtype (e.g.,
CandyBarMachine
) a function can return a more specific type (e.g.,CandyBar
) than declared in its supertype, but not a more general type.The adventures of Parker and Vinnie continue, though! Brace yourself - Vinnie is about to try substituting a few more vending machines!
Contravariance
A few weeks later, Parker saw Vinnie replacing the machine yet again. “We’re upgrading our machines so that they accept both coins and bills,” he explained.
Parker thought about it. “Well, I guess that’s fine. As long as the machine still accepts a coin and returns a snack.” After the new machine was installed, a kid walked up, inserted a dime, and got a snack from the machine. “Great,” Parker thought to himself, “Everything is still good”.
Things continued well over the next few weeks. Even though this new vending machine had a bill acceptor, none of the kids ever used it, because they only ever had coins.
Wouldn’t you know it - a few weeks later, Parker saw Vinnie replacing the machine one more time. Parker shrugged it off and continued on his way.
Ten minutes later, though, a young girl was crying next to the snack machine. “What’s wrong?” he asked her.
“I wanted a snack. I have a nickel, but this machine only accepts a dime!” Parker looked at the new vending machine, and sure enough, it only had a slot for dimes, but not any other kind of coin.
Parker complained to Vinnie again. “No, no, no! The contract said the machine would accept a coin and return a snack. A nickel is a kind of coin, so the machine still needs to accept it!” Embarrassed, Vinnie put back the previous vending machine.
Contravariance and Substitution
This story shows again that some substitutions work, and some don’t.
- When Vinnie replaced the original vending machine with the one that accepted both coins and bills, everything still worked, because it still accepted coins. The kids only ever had coins on them, so they never actually used the bill acceptor, but there was no harm in the machine having one, as long as it could still accept coins.
- However, when Vinnie replaced it with the machine that accepted only dimes, he broke his contract - the machine no longer accepted nickels, quarters, or any other kind of coin.
This illustrates additional points about substitution. Let’s take a look at the original
VendingMachine
we created back in Listing 19.1.open class VendingMachine { open fun purchase(money: Coin): Snack = randomSnack() }
As with
Snack
, this chapter won’t include any class definitions forCoin
and its type hierarchy, but here’s a UML class diagram showing how they relate.As we saw, it was safe for Vinnie to replace the coin-based vending machine with one that can accept either coins or bills. Likewise, it would be safe for Kotlin to allow a subtype to declare a more general parameter type.
But guess what? If we try changing the parameter type from
Coin
toMoney
, we’ll get a compiler error!class AnyMoneyVendingMachine : VendingMachine() { override fun purchase(money: Money): Snack = randomSnack() }
ErrorAgain, this would be perfectly safe for Kotlin’s type system to allow. So why does this cause a compiler error?
As you might recall, Kotlin allows us to overload a function - a class or interface can have multiple functions that have the same name, as long as their parameter types differ. In order to support this feature, Kotlin won’t allow us to change the type of a function parameter in subtypes, as we’re trying to do above.
One way to fix this is to simply remove the
override
keyword, which means we’ll be overloading the function instead of overriding it.class AnyMoneyVendingMachine : VendingMachine() { fun purchase(money: Money): Snack = randomSnack() }
Just keep in mind that when we do this, we end up with two
purchase()
functions, each with its own body - one inAnyMoneyVendingMachine
and one inVendingMachine
.So Kotlin’s overloading feature is getting in the way. Overloading only applies to functions declared with the
fun
keyword, though, so to work around this, we can changepurchase()
from a function to a property that has a function type, like this.open class VendingMachine { open val purchase: (Coin) -> Snack = { randomSnack() } }
With this change, we can rewrite
AnyMoneyVendingMachine
to override that property.class AnyMoneyVendingMachine : VendingMachine() { override val purchase: (Coin) -> Snack = { randomSnack() } }
And finally, we can replace the
Coin
parameter type withMoney
.class AnyMoneyVendingMachine : VendingMachine() { override val purchase: (Money) -> Snack = { randomSnack() } }
With this, we can assign an instance of
AnyMoneyVendingMachine
to a variable whose type isVendingMachine
. When assigned to aVendingMachine
variable, it can only accept a coin. But when assigned to anAnyMoneyVendingMachine
, it can accept either a coin or a bill.val vendingMachine: VendingMachine = AnyMoneyVendingMachine() val anyMoneyMachine: AnyMoneyVendingMachine = AnyMoneyVendingMachine() val snack1: Snack = vendingMachine.purchase(Dime()) val snack2: Snack = anyMoneyMachine.purchase(Dime()) val snack3: Snack = anyMoneyMachine.purchase(OneDollarBill())
As we did earlier, we can put these two classes side by side and discover the nature of the variance between the class type and the return type of
purchase()
.This time, we can see that as we make a more specific
VendingMachine
, we can accept a more general parameter type inpurchase
. The arrows above are pointing in opposite directions, so this kind of variance is called contravariance.As you recall from the story, when Vinnie tried to replace the vending machine with one that accepted a more specific kind of coin, he broke his contract. Similarly, we can’t create a subclass that accepts a more specific type than its superclass did. For example, if we update
purchase
so that its parameter type isDime
, we’ll get a compiler error.class AnyMoneyVendingMachine : VendingMachine() { override val purchase: (Dime) -> Snack = { randomSnack() } }
ErrorSo, we learned from this story that a subtype can declare that it accepts a more general type, but not a more specific type.
Now that we understand variance - including covariance and contravariance - its time to revisit what we learned, and see how these concepts apply to generics.
What Makes a Subtype a Subtype?
Whenever Vinnie replaced one vending machine with another, everything was fine as long as the new machine did everything that the contract said it would. Specifically, the contract stated that the vending machine must accept a coin and return a snack. If the new machine did those things, then it was a suitable substitute. On the two occasions when it broke the contract, it was not a suitable substitute.
So what makes a subtype a subtype? The ability to substitute it for one of its supertypes. A subtype must fully support the contract of its supertype. Specifically, this means it must obey the following three rules:1
- The subtype must have all of the same public properties and functions as its supertype.
- Its function parameter types must be the same as or more general than the ones in its supertype.
- Its function return types must be the same as or more specific than the ones in its supertype.
We usually think of a subtype as a class that extends another class, an interface that extends another interface, or a class that implements an interface. In all three of these cases, Kotlin’s type system will ensure that the subclass follows the three rules above. However, when it comes to parameterized types - such as
VendingMachine<Snack>
andVendingMachine<CandyBar>
- we can’t explicitly declare that one type is a subtype of another.To help demonstrate this, let’s convert
VendingMachine
to a generic class. As a part of this change, we’ll add asnack
constructor parameter, which is the snack that will get returned when thepurchase()
function is called.2class VendingMachine<T : Snack>(private val snack: T) { fun purchase(money: Coin): T = snack }
As we learned in the last chapter we can create parameterized types from this generic, such as
VendingMachine<Snack>
andVendingMachine<CandyBar>
. Even though we can’t explicitly declare thatVendingMachine<CandyBar>
is a subtype ofVendingMachine<Snack>
, it would be perfectly safe for this to be the case, because it wouldn’t break the contract - all three rules above would be satisfied.We can visualize this by imagining what the effective parameterized type would look like. In other words, let’s take the generic
VendingMachine
above, and everywhere that the type parameter appears, we’ll replace it with the type argument. Are all three rules satisfied?So,
VendingMachine<CandyBar>
fully satisfies the contract ofVendingMachine<Snack>
, which means it’s safe for it to be one of its subtypes. This won’t just happen automatically, though. For example, if we try assigning it to a variable declared asVendingMachine<Snack>
, we’ll get a compiler error.val candyBarMachine: VendingMachine<CandyBar> = VendingMachine(CandyBar()) val vendingMachine: VendingMachine<Snack> = candyBarMachine
ErrorSo,
VendingMachine<CandyBar>
won’t be a subtype ofVendingMachine<Snack>
until we tell Kotlin our intent by doing one more thing.Variance Modifiers
As we saw above,
VendingMachine<CandyBar>
fully satisfies the contract ofVendingMachine<Snack>
, so it should be possible for it to be a subtype of it. However, a type parameter can potentially be used in lots of places throughout the body of a generic type. It could be used as the return type of a function, as the parameter of a function, as the type of a property, and so on. Let’s consider what would happen if we were to add arefund()
function to theVendingMachine
interface.class VendingMachine<T : Snack>(private val snack: T) { fun purchase(money: Coin): T = snack fun refund(snack: T): Coin = Dime() }
In this case, the type parameter
T
appears both as a function result type and as a function parameter type. Would this version ofVendingMachine<CandyBar>
satisfy the contract ofVendingMachine<Snack>
? Let’s compare the effective parameterized types again. First, let’s look at thepurchase()
function.As before, when it comes to the
purchase()
function,VendingMachine<CandyBar>
satisfies the contract ofVendingMachine<Snack>
. So far so good. Now let’s look at therefund()
function.Yikes! Even though the return type of
refund()
satisfies the contract, the parameter type does not, becauseCandyBar
is not the “same as or more general than”Snack
- it’s more specific. So, ifVendingMachine<T>
includes therefund()
function, thenVendingMachine<CandyBar>
cannot be a subtype ofVendingMachine<Snack>
.If Kotlin is going to treat
VendingMachine<CandyBar>
as a subtype ofVendingMachine<Snack>
, we have to promise Kotlin that we won’t use this type parameter as a function parameter type, like we did inrefund()
. Instead, it can only appear in out-positions - in other words, as a function return type, or as the type of a read-only property.To make this promise, we can add a variance modifier to the type parameter. Kotlin has two variance modifiers - let’s check them out.
The
out
modifierThe first variance modifier is named
out
, and it’s our way of telling Kotlin that this type parameter will only appear in an out-position. Let’s add theout
modifier to type parameterT
.class VendingMachine<out T : Snack>(private val snack: T) { fun purchase(money: Coin): T = snack }
This simple change is all that’s needed to get the code from Listing 19.14 to compile without errors -
VendingMachine<CandyBar>
is now a subtype ofVendingMachine<Snack>
!val candyBarMachine: VendingMachine<CandyBar> = VendingMachine(CandyBar()) val vendingMachine: VendingMachine<Snack> = candyBarMachine
As we saw earlier in this chapter, a type is covariant with its associated function result types. So by ensuring that this type parameter is only used as a result type, we know that it’s safe for the
VendingMachine
type to be covariant withT
.Again, the
out
modifier is a promise to Kotlin that we will only use the type parameter in the out-position. If we try to use this same type parameter in an in-position - that is, as a function parameter type - then we’ll get a compiler error. To demonstrate this, we can pop in therefund()
function from Listing 19.15, and watch as the compiler calls us out for breaking our promise.class VendingMachine<out T : Snack>(private val snack: T) { fun purchase(money: Coin): T = snack fun refund(snack: T): Coin = Dime() }
ErrorSo, by adding the
out
modifier to the type parameter, we’ve got the advantage thatVendingMachine<CandyBar>
is now a subtype ofVendingMachine<Snack>
, but we’ve got the disadvantage that we can no longer useT
in an in-position.The
in
modifierAs you probably guessed, Kotlin includes a complement to the
out
modifier, calledin
. But before we look at it, let’s shake things up in theVendingMachine
class. Instead of a type parameter for its snack type, let’s use the type parameter for its money type.class VendingMachine<T : Money> { fun purchase(money: T): Snack = randomSnack() }
Similar to before, we can try to assign an instance of
VendingMachine<Money>
toVendingMachine<Coin>
, but we’ll get an error.val moneyVendingMachine: VendingMachine<Money> = VendingMachine() val coinVendingMachine: VendingMachine<Coin> = moneyVendingMachine
ErrorEven though
T
is only being used in the in-position, we have to declare this to Kotlin with thein
variance modifier.class VendingMachine<in T : Money> { fun purchase(money: T): Snack = randomSnack() }
With this change, the code from Listing 19.20 successfully compiles.
val moneyVendingMachine: VendingMachine<Money> = VendingMachine() val coinVendingMachine: VendingMachine<Coin> = moneyVendingMachine
As with the
out
modifier, we’ve made a trade-off here: when we declare a type parameter with thein
modifier, we’re promising Kotlin that it will only ever appear in the in-position. Again, if we add arefund()
function, we would needT
in the out-position - as that function’s return type - which would cause a compiler error.class VendingMachine<in T : Money>(private val money: T) { fun purchase(money: T): Snack = randomSnack() fun refund(snack: Snack): T = money }
ErrorIn summary:
- The
out
modifier can be used to ensure that the type parameter will only appear publicly in an out-position, which makes it safe for covariance.- Conversely, the
in
modifier can be used to ensure that it will only appear publicly in an in-position, so that it’s safe for contravariance.In order to keep things simple, we’ve only used one type parameter at a time so far in this chapter. It’s entirely possible to have multiple type parameters, though, and when we do, we can use a variance modifier on each one. Let’s see how that looks.
Variance on Multiple Type Parameters
It’s important to note that generic variance doesn’t describe the type as a whole - it describes the relationship of the type to one of its type parameters. So, it can be covariant on one and contravariant on another. Instead of a single type parameter for either the
Money
orProduct
, let’s include one for each.class VendingMachine<in T : Money, out R: Product>(private val product: R) { fun purchase(money: T): R = product }
In this code,
VendingMachine
is contravariant with respect toT
, and covariant with respect toR
. From this generic class, we could instantiate a wide range of parameterized types. Some of them would include:With two type parameters, all those types of money, and all those types of product, it’s easy to get lost in which of the parameterized types are subtypes of the others. Just remember that a subtype must fully support the contract of its supertype. Here’s how the type hierarchy looks for the parameterized types above.
It’s helpful to know that we can apply variance modifiers to multiple type parameters. For the rest of this chapter, though, we’ll go back to a single type parameter in order to keep the examples easy to follow.
We’ve seen how the
in
andout
modifiers create variance, but we’ve also seen the trade-offs - type parameters declared within
can’t be used as a result type, and those declared without
can’t be used as a function parameter type. But sometimes, those trade-offs just won’t cut it. When that’s the case, we can still get some of the benefits of variance by using type projections.Type Projections
So far, we’ve been able to get
VendingMachine<CandyBar>
to be a subtype ofVendingMachine<Snack>
by using variance modifiers on our type parameters. Let’s update the code from Listing 19.16 so that it works with any kind ofProduct
and any kind ofMoney
.class VendingMachine<out T : Product>(private val product: T) { fun purchase(money: Money): T = product }
In this code, we’ve got
out
on the type parameter again. Because we put this variance modifier where the type parameter is declared, this is called declaration-site variance. We can’t always use declaration-site variance, though. For example, what if we really, truly need thatrefund()
function from Listing 19.18?class VendingMachine<T : Product>(private val product: T) { fun purchase(money: Money): T = product fun refund(product: T): Money = Dime() }
We can’t use the
out
variance modifier, becauseT
is used in an in-position inrefund()
. And we can’t use thein
modifier, because it’s used in an out-position inpurchase()
. Are we just out of luck, here? Does this mean thatVendingMachine<CandyBar>
will never be considered a subtype ofVendingMachine<Snack>
?Thankfully, Kotlin provides a second option. Instead of using a variance modifier on a type parameter, we can use it on a type argument. To demonstrate this, let’s start with a function that accepts a
VendingMachine<Snack>
.fun getSnackFrom(machine: VendingMachine<Snack>): Snack { return machine.purchase(Dime()) }
Since there are no variance modifiers on the type parameter in Listing 19.26,
VendingMachine<CandyBar>
is not a subtype ofVendingMachine<Snack>
, so we won’t be able to call the function with an instance ofCandyBarMachine
.val candyBarMachine: VendingMachine<CandyBar> = VendingMachine(CandyBar()) getSnackFrom(candyBarMachine)
ErrorNow let’s add an
out
modifier to the type argument in thegetSnackFrom()
function, like this.fun getSnackFrom(machine: VendingMachine<out Snack>): Snack { return machine.purchase(Dime()) }
With this change, the code from Listing 19.28 compiles successfully!
val candyBarMachine: VendingMachine<CandyBar> = VendingMachine(CandyBar()) getSnackFrom(candyBarMachine)
When we put a variance modifier on a type argument rather than a type parameter, we create variance at the place in our code where we’re using the type (e.g.,
getSnackFrom()
) instead of where we declared it (e.g.,VendingMachine
). For this reason, we call this use-site variance.Just like declaration-site variance, use-site variance comes with trade-offs. As we’ll see in a moment, because the
machine
parameter has theout
modifier, we won’t be able to call therefund()
function inside the body of this function. Thankfully, the body of this function has no need for therefund()
function, so this trade-off is entirely acceptable here!Keep in mind that declaration-site variance applies to the whole project, but use-site variance works only in a specific part of the project where we put the variance modifier on the type argument. In the example code above, it only works for the
getSnackFrom()
function. So, if other functions in our project still need to use therefund()
function, that’s completely fine. In those places,VendingMachine<CandyBar>
simply wouldn’t be able to be a subtype ofVendingMachine<Snack>
.Out-Projections
It’s important to note that, within the body of this function,
machine
does not have the typeVendingMachine<Snack>
. It has the typeVendingMachine<out Snack>
, which is a type projection. Since this type argument has theout
modifier on it, this particular kind of type projection is called an out-projection.What exactly is a projection?
Think of a projection like the shadow of a ball. If you shine a light on it, it casts a shadow onto the wall. The ball itself is a sphere that has three dimensions, but the ball’s shadow on the wall is a circle that has only two dimensions. The shadow still resembles the ball, but it’s missing its depth.3
Similarly, when we create a type projection, it’s kind of like we’re removing some of the “depth” of that object by limiting the types of its function inputs and outputs. For example, in the
getSnackFrom()
function above, we created a type projection fromVendingMachine<Snack>
, calledVendingMachine<out Snack>
, which looks a lot like the original, except that therefund()
function no longer accepts aSnack
. Instead, it accepts a type calledNothing
!What exactly is
Nothing
? As mentioned in Chapter 14, every type in Kotlin is a subtype of a class namedAny
. Similarly, every type in Kotlin is also a supertype of a class calledNothing
.The
Nothing
class can never be instantiated, because it only has aprivate
constructor. Since it’s impossible to create an instance ofNothing
, we’ll never be able to callrefund()
here in this function.So, an out-projection is created by adding the
out
modifier to a type argument. It looks similar to the original parameterized type, but everywhere that the type argument appears in an in-position, it is replaced with theNothing
type.In-Projections
As you can probably guess, we can also use an
in
modifier to create an in-projection, like in this function.fun getRefundFrom(machine: VendingMachine<in CandyBar>): Coin { return machine.refund(CandyBar()) }
As with the out-projection above, inside the body of this function, we end up with a projection of
VendingMachine<CandyBar>
. But this time, instead of affecting the functions’ parameter types, it’s the functions’ result types that have been affected.With an in-projection, everywhere that the type argument appears in an out-position, it is forced to
Any?
. It’s entirely possible to call these functions and get a result. If we want to do anything useful with that result, though, we’ll probably need to cast it back to its more specific type.So, out-projections and in-projections are two kinds of type projections. Out-projections create covariance by forcing the type argument in in-positions to
Nothing
, and in-projections create contravariance by forcing the type argument in out-positions toAny?
.Sometimes we want a function to accept all instances of a generic type, regardless of their type arguments. For these cases, Kotlin includes one more kind of projection.
Star Projections
Remember Vinnie? Well, at the end of the month, he services all of his vending machines, performing basic maintenance in order to keep them finely-tuned and working well. This operation doesn’t involve any
Money
orProduct
- he just needs to get in there and tighten a few bolts.Here’s an updated version of
VendingMachine
that includes a function calledtune()
. As you can see, this new function doesn’t use a type parameter at all - neither as a function parameter nor as a return type.class VendingMachine<T : Snack>(private val snack: T) { fun purchase(money: Coin): T = snack fun refund(snack: T): Coin = Dime() fun tune() = println("All tuned up!") }
All vending machines need to be tuned up. The type of the
Snack
that they return is completely irrelevant.In cases like these, we might want a function to accept literally any kind of type created from
VendingMachine
. Kotlin makes this easy with a special kind of type projection called a star-projection. To create a star-projection, rather than using a variance modifier, simply use an asterisk*
(that is, a “star”) in place of the type argument. For example, here’s a function that will accept any kind ofVendingMachine
, regardless of its type argument.fun service(machine: VendingMachine<*>) { print("Tuning up $machine... ") machine.tune() }
This function can be called with any kind of
VendingMachine
, regardless of its type argument.service(VendingMachine(CandyBar()) // Works with VendingMachine<CandyBar> service(VendingMachine(TrailMix()) // Works with VendingMachine<TrailMix> service(VendingMachine(GummyBears()) // Works with VendingMachine<GummyBears>
A star-projection looks like the original parameterized type, but:
- Anywhere that the type parameter was used in an in-position, it’ll be replaced with the
Nothing
type.- Anywhere that the type parameter was used in an out-position, it’ll be replaced with the type parameter’s upper bound. Remember - if no upper bound was specified, it’ll be
Any?
by default.In summary, star-projections are useful when you want to accept any instance of a generic type, regardless of its type arguments.
Variance in the Standard Library
Now that we’ve learned all about covariance, contravariance, variance modifiers, and type projections, we can better understand why certain types in the standard library work the way that they do. We opened this chapter by presenting the fact that
MutableList<Cow>
is a subtype ofList<Cow>
, but it’s not a subtype ofMutableList<FarmAnimal>
. Why is this?
MutableList<Cow>
is a subtype ofList<Cow>
becauseMutableList
extends theList
interface. That’s just regular interface inheritance.MutableList<Cow>
is not a subtype ofMutableList<FarmAnimal>
because it allows you to both read and modify its elements, which means its type parameter appears in both the in-position and out-position. As a result, it can’t have a variance modifier on it. This is similar to ourVendingMachine
in Listing 19.26.As we’ve seen, the collection types in Kotlin usually come in two flavors - a read-only type (e.g.,
List
) and a mutable type (e.g.,MutableList
). The read-only types will have theout
modifier on their type parameters, soList<Cow>
will be a subtype ofList<FarmAnimal>
. However, the mutable types won’t have any variance modifiers, soMutableList<Cow>
will not be a subtype ofMutableList<FarmAnimal>
. As you now know, though, you can use a type projection to work around this when it makes sense!Summary
As Parker sat on a bench, watching the families running around in the park, he thought about the differences in the vending machines that Vinnie had installed. Now that the two of them understood which vending machines would satisfy the contract, they both felt much more comfortable about any replacements they might need to make in the future. Vinnie walked up to the vending machine and inserted a dime. He turned to Parker, who was still sitting on the bench. “Want a snack?” he asked him.
Congratulations on working your way through this chapter! Here’s a recap of what we learned:
- How covariance describes the relationship between a type and its function return types.
- How contravariance describes the relationship between a type and its function parameter types.
- How we can use variance modifiers on type parameters to create declaration-site variance.
- How we can use variance modifiers on type arguments to create use-site variance.
- How collection types in the standard library use variance modifiers.
Keep playing with these concepts in your Kotlin projects to help solidify your understanding! In the next chapter, we’ll introduce the very exciting topic of coroutines! See you then!
The goal of this chapter is to explain generic variance, so these three rules are focused on the structure of the types. More strictly speaking, though, the behaviors of the classes should be compatible, as well. (See Liskov, B., & Wing, J. M. (1994). A behavioral notion of subtyping. ACM Transactions on Programming Languages and Systems, 16(6), 1811-1841). Also, Meilir Page-Jones observes these three rules more generally through the lens of preconditions, postconditions, and class invariants. (Page-Jones, M. (2000). Fundamentals of Object-Oriented Design in UML. Addison-Wesley Professional. p. 283). ↩︎
Since the return type of
purchase()
varies depending on the type argument, we can’t just userandomSnack()
any more - the snack has to have the same type as the type argument. For example, inVendingMachine<CandyBar>
, thepurchase()
function must return aCandyBar
, not aSnack
. Setting this value in the constructor is a simple way to do this. If you prefer, you could change thesnack
property to a function type that constructs new snacks. e.g.,private val snack: () -> T
. ↩︎The word “projection” in the term “type projection” is technically rooted in mathematics, but the idea is the same. ↩︎