Software Engineering bits: covariance & contravariance (or How a bunch of Cats is not really a bunch of Animals)

Alberto Sanz
adidoescode
Published in
10 min readJun 21, 2024

Variance is one of those concepts that yes, we know it’s there. Yes, we know it’s important. And yes of course! We know about it. But, please, don’t ask any question about it 😅

In this article, we’re going to get our hands dirty with variance and its cousins (covariance and contravariance). We’ll go from the understandable, code-based examples to the theory explaining why they behave as they behave and how it applies to Java language

Cats. Not sure if animals. Photo by Dietmar Ludmann on Unsplash

First things first

Coffee. Trust me, you need it

Take your favorite moka pot and fill the boiler with water until the pressure valve. Put it to boil and grind some coffee beans in the meantime. It’s OK if you have it ground already (freshly ground it’s way better though), but remember! Don’t put it yet! You may burn it!

Once the water is boiling, put the coffee in the funnel and close with the collector leaving the cover open. Soon, your coffee will start to brew, filling your kitchen with a nice smell. It’s important now to turn down a bit the fire (remember! You don’t want to burn it!). You can close the cover now. Check from time to time and, once the coffee already filled half the collector, turn the fire off. Remaining heat will be enough (remember! You don’t want to burn it!). You’ll soon see that the collector is full with delicious coffee 😁

Let it rest for a couple of minutes but, of course, apply some cold water to the boiler so remaining heat is disipated (remember! YOU DON’T WANT TO BURN IT!)

Burning your house is fine. Burning your coffee is not (source: Know your meme)

Enjoy your coffee

Now that we’re awake…

Variance is how subtyping between more complex types relates to subtyping between their components.
Wikipedia

OK, maybe not that awake. As with most concepts coming from math world to our more simple, stupid programmer understanding, main complexity relies in understanding what such a definition actually means

A programmer after hearing that “A monad is just a monoid in the category of endofunctors” (Photo by Rob Schreckhise on Unsplash)

Let’s see a practical, understandable example of what that definition means

We’ll start by defining some easy classes

class Animal {}
class Cat extends Animal {}
class Dog extends Animal {}

Good, we’re fine (sips coffee with animal superiority)

Mostly everyone would say that, indeed, a Cat is an Animal. Same as a Dog is an Animal. And a Cat is not a Dog, and viceversa. Remember, we stablished that in a previous Software Engineering Bits article! But we're science people! We MUST verify it! Don't focus on the actual values, we care only about the types

Animal animal = ...
Cat cat = ...
Dog dog = ...

animal = cat;
animal = dog;

cat = dog; // Compilation error. Required type Cat, provided Dog

Looks good. But… Is an Animal a Cat?

Animal animal = ...
Cat cat = ...
Dog dog = ...

cat = animal; // Compilation error. Required type Cat, provided Animal

cat = (Cat) animal; // works fine, but may fail at runtime!

Cast to the rescue! Sometimes an Animal is indeed a Cat, but some other times is not. We need to prepare for that. You probably used instanceof or, if you have been out of a cave in the last ~3 years, sealed classes for dealing with it

Happy tusken noises

But variance does not focus on “simple” subclassing. We go with “complex” types AKA generics

Is a group of Cats a group of Animals?

I don’t know! 😣

Nervous coffee sip (source: Know your meme)

But I do know how to verify it!

List<Animal> animals = ...
List<Cat> cats = ...
List<Dog> dogs = ...

List<Animal> animalCats = cats; // Compilation error. Required type List<Animal>, provided List<Cat>
List<Animal> animalDogs = dogs; // Compilation error. Required type List<Animal>, provided List<Dog>

List<Cat> catAnimals = animals; // Compilation error. Required type List<Cat>, provided List<Animal>

Wait, so… A List of Cats... Is not a List of Animals???

It makes no sense! Even less when you can read & write animals!

animals.add(new Animal());
animals.add(new Cat()); // a Cat can be added to a group of animals

Cat cat = cats.get(0);
Animal animal = cats.get(0); // an animal can be read from a group of cats

Remember that the JVM per se does not support generics and the compiler erases such information, making everything to work with Object (i.e. List<Animal> is translated to List<Object>) and adding the needed casts and bridge methods to provide polymorphism. We say that, in Java, variance is defined at the use-site, as the type definition will specify which kind of variance it supports.

We have just seen an example of invariance. This happens when the type definition has no open bounds (more about than in a few paragraphs). Or, what is the same, A is a subclass of B but Generic<A> is not a subclass of Generic<B> and viceversa

But… A group of Cats… Is a group of Animals!

Is it? Really?

We’ll need to update our code then!

As we mentioned before, variance in Java is defined at the use-site, so we’ll need to adjust our variable types. In order to fix it, we need to “open the bounds” of our types

List<? extends Animal> animals = ...
List<Cat> cats = ...
List<Dog> dogs = ...

animals = cats; // Works now!
animals = dogs; // Works now!

List<Cat> moreCats = animals; // Compilation error. Required type List<Cat>, provided List<capture of ? extends Animal>

Now we have it! A List of Cats is... A List of something that can be considered an Animal? How useful is that? Even taking into consideration that we can not write neither Animals nor Cats!

animals.add(new Animal());   // Compilation error. Required type List<capture of ? extends Animal>, provided List<Animal>  
animals.add(new Cat()); // Compilation error. Required type List<capture of ? extends Animal>, provided List<Cat>

Cat cat = cats.get(0); // Works fine
Animal animal = cats.get(0); // Works fine

Well, this is an example of covariance. We say that a type is covariant when the subtype relationship is preserved. Or, what is the same, A is a subclass of B and Generic<A> is also a subclass of Generic<B>

Yes, we lost the ability to add elements to the list. But we can still read elements from it! Bear with me til the end, you’ll see the benefits

I’m confused now… Can a group of Animals be a group of Cats?

Well it can! Under certain circumstances (we’ll see examples soon), it’s a totally valid scenario. Or, better phrased, a List of Animals can behave like a List of something that is a super class of Cats

List<Animal> animals = ...
List<? super Cat> cats = ...
List<Dog> dogs = ...

List<Animal> animalCats = cats; // Compilation error. Required type List<capture of ? extends Animal>, provided List<Animal>
List<Animal> animalDogs = dogs; // Compilation error. Required type List<capture of ? extends Animal>, provided List<Animal>

cats = animals; // Works now

Contravariance explains this. We say that a type is contravariant when the subtype relationship is inverted. Or, what is the same, A is a subclass of B and Generic<B> is a subclass of Generic<A>

OK so I know about covariant and contravariant. Anything else?

Yes! But not that useful really. Java type system does not support it properly, but we can hack our way into it

List<?> animals = ...
List<?> cats = ...
List<?> dogs = ...

List<?> animalCats = cats;
List<?> animalDogs = dogs;

List<?> catAnimals = animals;

As mentioned, Java does not support this propery. We’re writing ?, which just translates to plain Object. We can do whatever we want, as we're dealing with just List. With proper language support, we'd be talking about bivariance. We say that a type is bivariant when it's both covariant and contravariant. Or, what is the same, A is a subclass of B and Generic<A> is equivalent to Generic<B>

Enough theory! How useful is this?

If we stick purely to previous examples of just reassigning variables to other types… Not much, really. Full power of variance comes when you need a bit more flexibility in your type system and you’re dealing with “unexpected” compiler errors

Let’s continue the example of our beloved animals. Imagine we want to feed them, but our cats are a bit chubby…

public void feedAnimals(List<Animal> animals) {
animals.forEach(animal -> {
if (animal instanceof Cat cat) {
// I only know fat cats, OK?
cat.eat();
cat.eat();
} else {
animal.eat();
}
});
}

Let’s now have some animals to feed

List<Animal> animals = List.of(new Cat(), new Dog());
feedAnimals(animals);

List<Cat> cats = List.of(new Cat(), new Cat());
feedAnimals(cats); // Compilation error. Required type List<Animal>, provided List<Cat>

Compilation errors again! A few minutes ago you may have struggled a bit to understand why you can’t pass a List<Cat> to a method that expects a List<Animal>, but now you're a variance master!

You, understanding variance

Can you make the code compile? Yes, of course you can! It’s just a matter of adding extends or super until you get one that works 🙃. Wouldn't be easier if there were something that helps you know which one you need?

Making things easier

PECS to the rescue. Any time you have issues with variance and how to fix your typing, remember PECS. PECS stands for Producer Extends, Consumer Super. It’s an easy rule to know how you have to fix your types. As a summary:

  • If you have a generic class that Produces values (i.e. you had nothing, and get something from it) and you do something with them, it should use Extends
  • If you have a generic class that Consumes values (i.e. you had something, and push into it), it should use Super

Following PECS, we can see that the list is producing Animals, so we go with extends without thinking

List<Animal> animals = List.of(new Cat(), new Dog());
feedAnimals(animals); // Still works

List<Cat> cats = List.of(new Cat(), new Cat());
feedAnimals(cats); // Working now!

public void feedAnimals(List<? extends Animal> animals) {
// Remains the same
...
}

List<Cat> is not a subclass of List<Animal>, but it is of List<? extends Animal>

source: Know your meme

On the other side, an example of a consumer would be

public void addingKitties(List<Cat> cats) {
cats.add(new Cat());
cats.add(new Cat());
}

We receive a List and we add a coupe of Cats. Because we're adding Cats, we need a List<Cat>

Note: for the sake of example, let’s just ignore the fact that a kitty dies every time you modify an input value. No code like that was executed during the writing of this article, nor any animal harmed

Invoking now our method…

List<Cat> cats = new ArrayList<>();
addingKitties(cats);

List<Animal> animals = new ArrayList<>();
addingKitties(animals); // Compilation error. Required type List<Cat>, provided List<Animal>

We’re adding Cats to the List, shouldn't it be safe to pass a List<Animal>? Not for Java!

How do we fix it? Remember, we need a List that consumes Animals, therefore...

public void addingKitties(List<? super Cat> cats) {
cats.add(new Cat());
cats.add(new Cat());
}


List<Cat> cats = new ArrayList<>();
addingKitties(cats); // Still works

List<Animal> animals = new ArrayList<>();
addingKitties(animals); // Works now!

List<Animal> is not a subclass of List<Cat>, but it is of List<? super Cat>

source: Know your meme

Last, better example

While contravariance is useful, covariance allows one nice feature. Let’s see, for example, how we deal with pet owners

interface PetOwner {
List<Animal> getPets();
}

Seems fine at first glance. Now let’s go with an specialisation

class CatOwner implements PetOwner {
@Override
public List<Animal> getAnimals() {
return List.of(new Cat(), new Cat());
}
}

Technically… It works, yes. But wouldn’t it be nicer to be able to return a List<Cat>? If we already know that we're dealing with a CatOwner, knowing the specific type simplifies things. Of course, we can go with generics

interface GenericPetOwner<T extends Animal> {
List<T> getAnimals();
}

class SpecificCatOwner implements GenericPetOwner<Cat> {
@Override
public List<Cat> getAnimals() {
return List.of(new Cat(), new Cat());
}
}

Again, technically, works. But we’re introducing a generic type for just solving a particular signature issue. Leveraging covariance simplifies the code for us

interface CovariantPetOwner {
List<T extends Animal> getAnimals();
}

class CovariantCatOwner implements CovariantPetOwner {
@Override
public List<Cat> getAnimals() {
return List.of(new Cat(), new Cat());
}
}

// Works great with Animals as well
class MultiplePetOwner implements CovariantPetOwner {
@Override
public List<Animal> getAnimals() {
return List.of(new Cat(), new Dog());
}
}

Find a similar example for contravariance is… A bit more complex. Subclassing is easy, but “super-classing” is not as supported

Then… Should I change the way I code?

Always! 😁

There’s always room for improvement. However, don’t change your coding practices “just because of the yes”. Now you know the concepts of covariance and contravariance, and how they can simplify some of your type related problems

Should you apply PECS? Well, it’s not gonna hurt, that’s for sure

Should you always apply PECS? That’s a way longer response. As mentioned before, applying variance related concepts does add more flexibility to your code at the expense of readability. Seeing that a method receives a List<Animal> is way for readable than seeing a List<? extends Animal>. Yes, it brings more semantics to its signature as you're adding information about its role in the method (producing values vs consuming values), but untrained eyes will struggle to understand it at first

Remember that 90% of your coding time is spent reading code, not writing it. Your code should behave like a NoSQL database: prioritise readings as those will happen way more often than writings

(source: The Ten Commandments poster)

Happy coding!

The views, thoughts, and opinions expressed in the text belong solely to the author, and do not represent the opinion, strategy or goals of the author’s employer, organization, committee or any other group or individual.

--

--