Generics can often seem confusing. How often have you started to solve a problem with generics, only to realize that they don’t quite work like you thought they did?

The good news is that there are some simple, foundational concepts that underpin generic variance. And once you understand those concepts, you won’t have to memorize acronyms or resort to trial-and-error - you’ll simply understand how and why they work!

In this article, I’m going to cover these foundational concepts, and then demonstrate how they play out in Kotlin class and interface inheritance. Then in the next article, I’ll uncover how these same concepts play out for generics and type projections.

Ready? Let’s go!

It’s All About Subtypes

When it comes to generics, what trips us up the most? Subtyping.

Sure, we know intuitively that a Dog is a subtype of an Animal. But it’s easy to get confused about why an Array<Dog> isn’t a subtype of an Array<Animal>.

Instead of using intuition alone to understand subtypes, let’s identify some actual characteristics that a subtype must have, by definition.

In order for a subtype to truly be a subtype, you must be able to swap one in as a replacement for its supertype, and the rest of the code shouldn’t notice anything different. For example, in order for SubGizmo to be a subtype of Gizmo, nothing must break when code that expects a Gizmo actually interacts with a SubGizmo instead.

Now, when we say “shouldn’t notice anything different” and “nothing must break” - we could be talking about a lot of things. In this article, we’re going to focus on function calls, and the types involved in those calls.1

Arguments and Results

As you know, there are two sides to a function:

The arguments that the calling code sends to the function.

The result that the function returns to the calling code.

Let’s visualize a good old-fashioned function call like this:

On the left, we have some code that wants to match a shape to a color. To do that, it interacts with a class on the right, called Gizmo, by invoking its match() function. Here’s what the calling code knows about Gizmo’s match() function:

It will accept a single argument of either a triangle or a circle, and…

It will respond with a color of either green or blue.

Now, this calling code has been using Gizmo successfully for a long, long time. But today, we’re going to secretly switch out Gizmo with our new SubGizmo (which, of course, is a subtype of Gizmo!).

Narrowing Arguments

This new subtype is designed to work specifically with the circle shape only, so it won’t support triangles. Let’s see how it goes:

Aww… that didn’t go very well.

Our calling code has been used to sending triangles to the Gizmo, but this new subtype didn’t have a slot for it, so we ended up with an error. If we’re going to substitute a SubGizmo for a Gizmo, it looks like it’ll need to continue supporting the same kinds of arguments that Gizmo has always supported.

Expanding Returns

Let’s modify our SubGizmo again! Instead of changing the supported argument types, we’ll continue to support both triangle and circle. But, this time, we’ll update the return types – instead of just green or blue, it can also return red! Let’s fire things up and see how they do.

Blast! We’ve got another error.

Because the calling code only expects to receive green or blue, returning red didn’t work.

So we’ve discovered a few things. In order for SubGizmo to truly be a Gizmo, it must continue to accept the same kinds of arguments as Gizmo, and it must not return a result type that Gizmo wouldn’t return.

Let’s write these out as rules. To make them look more official, we’ll put them in a gray box:

A subtype must accept all argument types that its supertype does.

A subtype must NOT return a result type that its supertype wouldn’t return.

But, hang on!

Let’s take another shot at creating a SubGizmo, but this time, instead of narrowing the range of argument types that it accepts, let’s expand it.

Expanding Arguments

In addition to the triangle and circle that Gizmo accepts, SubGizmo will also accept a new shape - a square.

Now we’re getting somewhere!

Even though our calling code doesn’t know anything about the new square type that SubGizmo can accept, nothing will break, because triangle and circle are still supported. And those are the only kinds of shapes that this particular call site cares about. (Other code that interacts with SubGizmo might know that it can accept a square, of course, but we haven’t broken anything for code that uses it as just a Gizmo).

Let’s do one more thing.

Narrowing Returns

Instead of expanding the range of result types that it can return, let’s narrow it. Even though Gizmo can return either green or blue, our latest incarnation of SubGizmo will only ever return green:

Success again!

The calling code can accept either color, and since SubGizmo only ever returns green, nothing will break.

Refining the Rules

So we can refine our rules from above, taking into account that it’s totally fine for the subtype to expand the range of argument types, and to narrow the range of response types:

A subtype must accept at least the same range of types as its supertype declares.

A subtype must return at most the same range of types as its supertype declares.

These two rules form the basis of variance. Once you’ve got these under your belt, you’ll be able to reason your way through just about anything related to variance.

Talking about shapes and colors has been nice for illustration, but let’s make this more concrete. Instead of matching shapes to colors, we’ll modify our Gizmo to match up compatible animals, so that they can make friends with each other and frolic together in the backyard.

Here’s a hierarchy of Animal types:

Let’s see how Gizmo and SubGizmo might work with this tree of types.

Accepting Arguments - Contravariance

Rule #1 above stated:

A subtype must accept at least the same range of types as its supertype declares.

In other words, the range of the argument types can expand, as shown here:

The range of types accepted in the match() function expanded from 3 types in Gizmo to 7 types in SubGizmo.

In the diagram above, the relationship between the containing type (Gizmo/SubGizmo) and its argument type (Cat/Animal) is called contravariance: as the Gizmo becomes more specific (that is, as it becomes more of a subtype), the type of the argument can become more general (that is, more of a supertype). They go in opposite directions.

But don’t worry - contravariance in argument types will make a comeback when we explore how these rules play out for generics in the next article.

Returning Results - Covariance

Here’s Rule #2:

A subtype must return at most the same range of types as its supertype declares.

This means it’s legal for a subtype to return a result that’s a “sub-er” type than the supertype declared. In other words, the range of the return types can narrow:

In this diagram, the match() function of Gizmo returns a Dog, but the subtype SubGizmo returns the subtype Schnauzer – the range of types returned narrowed from 3 types to 1 type. As our containing type becomes more specific, so can the return type! They can hold hands, becoming subtypes together. That’s why this relationship is called covariance. That’s co-, as in “together”.

Kotlin does allow for covariant return types in normal class and interface inheritance:

Summary

Wow, we’ve covered a lot of ground! We’ve seen how subtypes can safely expand the range of argument types and narrow the range of return types in functions. We’ve also seen how, for functions in regular class and interface inheritance, Kotlin supports covariance but not contravariance.

If you’re interested in additional characteristics to consider, check out the classic paper by Barbara Liskov and Jeannette Wing: “Behavioral Subtyping Using Invariants and Constraints” (July 1999).
[return]