With a simple example, the concept of co- and contravariance is understood. Forever and for everybody! With examples for scala and java developers.
Inheritance #
Let’s start slowly. Concepts in our problem domain can be expressed using inheritance. We usually express this graphically as follows:
+-----------+ +----------+
| | | |
| A | | Animal |
| | | |
+-----------+ +----------+
^ ^
| |
+----+------+ +----+-----+
| | | |
| B | | Cat |
| | | |
+-----------+ +----------+
Thus we expressed that B is a special kind of A, or that a Cat is a special kind of Animal. Usually, when working with inheritance we want to adhere to the inheritance contract: that is, we want to be able to use the specialised object anywhere where a more general object is required.
For our own concern - covariance, invariance or contravariance - we just observe that these concepts do not matter yet.
Invariance, Covariance and Contravariance #
In the next step we will put the objects of our classes into some other structure. This structure could be a list, a vector, an option, a future. Lets just call it F
for the time being. E.g. we talk about a F of Animals, a F of Cats, a F of As, a F of Bs.
In scala, in the simplest possible way, we could start to define a structure F, which operates on As, Bs, Animals or Cats like
// this does nothing - it just allows us to create typed F instances
case class F[A]()
And now - with the structure F in place - we can start to talk about the variance of its type parameter A. Syntactically we can differentiate three cases (there are three cases of specifying the type paramter A in our structure F):
- F[A]
- A is invariant
- F[+A]
- A is covariant
- F[-A]
- A is contravariant
Now let us assume we have defined Animal
and Cat
as in the example below:
trait Animal {}
trait Cat extends Animal {}
then we can, respectively cannot, do the following:
// Cat is an animal
scala> val animal: Animal = new Cat {}
val animal: Animal = $anon$1@100bba26
// An animal is not a cat
scala> val cat: Cat = new Animal {}
^
error: type mismatch;
found : Animal
required: Cat
The example above still has nothing do to with variance. We need to use our F
.
Invariance #
In the example above (no +
or -
in the type parameter) F
is invariant in A
. Invariance implies that even if the type parameter is in an inheritance relationship, the structure F
is not.
+----------+ +----------+ +----------+
| | | | | |
| Animal | | F[Animal]| | F[Cat] |
| | | | | |
+----------+ +----------+ +----------+
^
|
+----+-----+
| |
| Cat |
| |
+----------+
So, if Cat
is an Animal
, then F[Cat]
is not an F[Animal]
AND F[Animal]
is not a F[Cat]
. That is the case because we did decide that F
should behave this way, since we made the type paramter A
invariant in F
.
// Pretend that F is covariant.
scala> val f_animal : F[Animal] = F[Cat] ()
^
error: type mismatch;
found : F[Cat]
required: F[Animal]
Note: Cat <: Animal, but class F is invariant in type A.
You may wish to define A as +A instead. (SLS 4.5)
// Pretend that F is contravariant
scala> val f_cat : F[Cat] = F[Animal]()
^
error: type mismatch;
found : F[Animal]
required: F[Cat]
Note: Animal >: Cat, but class F is invariant in type A.
You may wish to define A as -A instead. (SLS 4.5)
Compare this with the error message, when the type is not in an inheritance relationship with Cat at all:
scala> val f_cat : F[Cat] = F[Person]()
^
error: type mismatch;
found : F[Person]
required: F[Cat]
Also have a look at the error messages above: You may wish to define A as +A instead. (SLS 4.5)
and You may wish to define A as -A instead. (SLS 4.5)
. This is the compiler telling us that we can consider to make A covariant (+
) or contravariant (-
).
Covariance (+) #
If we define
case class F[+A]()
then F
is covariant in A
. We say that if Cat
is an Animal, then F[Cat]
is an F[Animal]
as well.
+----------+ +----------+
| | | |
| Animal | | F[Animal]|
| | | |
+----------+ +----------+
^ ^
| |
+----+-----+ +----+-----+
| | | |
| Cat | | F[Cat] |
| | | |
+----------+ +----------+
With this definition of F
, the code which previously had errors in all three cases now works in the case of:
scala> val f_animal : F[Animal] = F[Cat]()
val f_animal: F[Animal] = F()
Contravariance (-) #
If we define
case class F[-A]()
then F
is contravariant in A
. We say that if Cat
is an Animal
, then F[Animal]
is an F[Cat]
as well.
+----------+ +----------+ +----------+
| | | | | |
| Animal | | F[Cat] | | F[Animal]|
| | | | | |
+----------+ +----------+ +----+-----+
^ ^ OR |
| | v
+----+-----+ +----+-----+ +----------+
| | | | | |
| Cat | | F[Animal]| | F[Cat] |
| | | | | |
+----------+ +----------+ +----------+
Please note how the inheritance arrow of F
is strangely inverted. With this definition of F
, the code which previously had errors with invariance in all three cases now works in the case of:
scala> val f_cat : F[Cat] = F[Animal]()
val f_cat: F[Cat] = F()
Does it have any use, whatsoever? #
Contravariance #
Yes, it does. To illustrate contravariance with a practical example, let’s consider animal feeders.
trait Animal {
def eat(): Unit = println("Animal eating")
}
trait Cat extends Animal {
override def eat(): Unit = println("Cat eating fish")
}
// A contravariant feeder class
case class Feeder[-A](feed: A => Unit)
// Create our feeders
val animalFeeder = Feeder[Animal](animal => println("Feeding standard animal food"))
val catFeeder = Feeder[Cat](cat => println("Feeding premium cat food"))
// This works! Someone expecting a cat feeder can use an animal feeder
// since it knows how to feed any animal (including cats)
val myCatFeeder: Feeder[Cat] = animalFeeder // ✅ Type-safe assignment
// This would NOT work - someone expecting to feed any animal
// cannot use a specialized cat feeder
// val myAnimalFeeder: Feeder[Animal] = catFeeder // ❌ Type error!
+----------+ +--------------+
| | | |
| Animal | | Feeder[Cat] |
| | | |
+----------+ +--------------+
^ ^
| |
| |
+----+-----+ +-----+---------+
| | | |
| Cat | |Feeder[Animal]|
| | | |
+----------+ +--------------+
Notice how the inheritance arrow is inverted! While Cat is a subtype of Animal, Feeder[Animal] is a subtype of Feeder[Cat]. This is the essence of contravariance.
The intuition: If you have a feeder that can feed ANY animal, it can definitely feed your cat. But if you have a feeder that only knows how to feed cats, it cannot necessarily feed any arbitrary animal.
This same pattern applies to function inputs, which is why in Scala a function is defined as Function[-Input, +Output]
. The function is contravariant in its input type.
Covariance #
While contravariance is about consuming values (inputs), covariance is about producing values (outputs). Let’s create a complementary example using animal shelters:
// Same hierarchy as before
trait Animal {
def speak(): String = "Some animal sound"
}
trait Cat extends Animal {
override def speak(): String = "Meow"
}
// A covariant shelter that provides animals
case class Shelter[+A](get: () => A)
// Create our shelters
val catShelter = Shelter[Cat](() => new Cat {})
val animalShelter = Shelter[Animal](() => new Animal {})
// This works! Someone expecting any animal can accept a cat
val myAnimalShelter: Shelter[Animal] = catShelter // ✅ Type-safe assignment
// This would NOT work - someone expecting a cat
// cannot accept just any animal
// val myCatShelter: Shelter[Cat] = animalShelter // ❌ Type error!
+----------+ +---------------+
| | | |
| Animal | |Shelter[Animal]|
| | | |
+----------+ +---------------+
^ ^
| |
| |
+----+-----+ +-----+--------+
| | | |
| Cat | | Shelter[Cat] |
| | | |
+----------+ +--------------+
Notice that unlike with contravariance, the inheritance arrows point in the same direction! If Cat is a subtype of Animal, then Shelter[Cat] is a subtype of Shelter[Animal].
The intuition: If you need a shelter that provides animals, a cat shelter works fine because cats are animals. But if you specifically need a cat shelter, a general animal shelter won’t suffice because it might give you a dog or some other non-cat animal.
This same pattern applies to function outputs, which is why in Scala a function is defined as Function[-Input, +Output]
. The function is covariant in its output type.
We can summarize the pattern:
Contravariance (-A): Used when a type consumes values of type A
- If B extends A, then Consumer[A] extends Consumer[B]
- Example: Feeder[-A], Function[-A, B]
Covariance (+A): Used when a type produces values of type A
- If B extends A, then Producer[B] extends Producer[A]
- Examples: Shelter[+A], Function[A, +B], List[+A]
For Java Developers #
If you’re coming from Java, you might find Scala’s variance annotations a bit different. In Java, variance is declared at the usage site rather than at the declaration site (as in Scala).
Here’s how our examples would look in Java:
Java Syntax for Variance #
// Our type hierarchy
interface Animal {
void eat();
}
interface Cat extends Animal {
@Override
void eat();
}
// Invariance - standard generic with no wildcards
class Container<T> { /* ... */ }
// Covariance - using extends wildcard (similar to +T in Scala)
// "? extends Animal" means "Animal or any subtype of Animal"
Container<? extends Animal> covariantContainer;
// Contravariance - using super wildcard (similar to -T in Scala)
// "? super Cat" means "Cat or any supertype of Cat"
Container<? super Cat> contravariantContainer;
Translating Our Examples to Java #
For contravariance (Feeder example):
interface AnimalFeeder<T> {
void feed(T animal);
}
// In Java, we'd use the super wildcard for contravariance:
AnimalFeeder<Animal> generalFeeder = animal -> System.out.println("Feeding generic food");
AnimalFeeder<Cat> catFeeder = cat -> System.out.println("Feeding cat food");
// This works - using an animal feeder where a cat feeder is expected
AnimalFeeder<? super Cat> feederForMyCat = generalFeeder; // ✅ Legal
// This doesn't work - cat feeder can't handle all animals
// AnimalFeeder<? super Animal> feederForAllAnimals = catFeeder; // ❌ Illegal
For covariance (Shelter example):
interface Shelter<T> {
T getAnimal();
}
// In Java, we'd use the extends wildcard for covariance:
Shelter<Animal> animalShelter = () -> new Animal() { /* ... */ };
Shelter<Cat> catShelter = () -> new Cat() { /* ... */ };
// This works - using a cat shelter where an animal shelter is expected
Shelter<? extends Animal> myAnimalShelter = catShelter; // ✅ Legal
// This doesn't work - animal shelter might not give cats
// Shelter<? extends Cat> myCatShelter = animalShelter; // ❌ Illegal
The key difference is that Scala lets you declare variance at the type definition (class Shelter[+A]
), while Java requires wildcards at each usage site (Shelter<? extends Animal>
).
Java’s approach is more flexible but more verbose, while Scala’s approach is more concise but requires carefully designing types with variance in mind from the start.
Contravariance in the Wild #
Contravariance isn’t just a theoretical concept - it’s widely used in real-world libraries and frameworks. Let’s look at some examples from well-known Java APIs to see how contravariance helps create more flexible and powerful interfaces.
Example 1: Java Collections Framework #
The Java Collections Framework makes extensive use of contravariance. One of the clearest examples is the Collections.copy()
method:
public static <T> void copy(List<? super T> dest, List<? extends T> src)
This method copies elements from a source list to a destination list. Notice the contravariant parameter List<? super T>
for the destination list. This allows you to copy elements into a list of any supertype of T
, which is extremely useful in practice.
For example, if you have:
List<Animal> animals = new ArrayList<>();
List<Cat> cats = new ArrayList<>();
cats.add(new Cat("Fluffy"));
// This works! You can copy Cats into an Animal list
Collections.copy(animals, cats);
Without contravariance, you would need exact type matching, which would severely limit the flexibility of the API.
Example 2: Java Functional Interfaces #
The Iterable
interface’s forEach
method demonstrates another practical use of contravariance:
default void forEach(Consumer<? super T> action)
This allows you to pass a Consumer
that can handle a supertype of the elements in the collection. For example:
// A consumer that works with any Animal
Consumer<Animal> animalFeeder = animal -> animal.feed();
// A list of specific Cat objects
List<Cat> cats = getCats();
// Using contravariance, we can feed all cats with our animal feeder
cats.forEach(animalFeeder);
Without contravariance, we would need to create a new Consumer<Cat>
even though our existing Consumer<Animal>
could already handle cats perfectly well.
Example 3: Comparators #
Java’s sorting methods often use contravariant comparators:
<T> void sort(List<T> list, Comparator<? super T> c)
This means you can use a comparator for a supertype to compare more specific types. For instance, you could sort a list of ElectricCar
objects using a Comparator<Vehicle>
that compares vehicles by their manufacturing year.
That’s it - viel Spaß and happy coding!
Kommentare