The purpose of the variance annotations is to prevent, at compile time, that you don't end up with a value you don't expect.
Consider
sealed trait Animal
class Dog() extends Animal
There are three meaningful types of variance.
- Covariance: a
CovariantOf[Dog]
is a subtype of CovariantOf[Animal]
- Contravariance: a
ContravariantOf[Animal]
is a subtype of ContravariantOf[Dog]
- Invariance: there's no relationship between
InvariantOf[Animal]
and InvariantOf[Dog]
(i.e. neither covariant nor contravariant)
Let's first consider something that we can get things out of:
trait Producer[A] {
def getOne: A
}
Let's imagine that Producer
is contravariant: a Producer[Animal]
is a Producer[Dog]
. Then we could have:
class Wildebeest() extends Animal
val animalProducer = new Producer[Animal] { def getOne: Animal = new Wildebeest() }
val dogProducer: Producer[Dog] = animalProducer
val dog: Dog = dogProducer.getOne
animalProducer
only promises to produce Animal
s and in fact will only produce Wildebeest
s, but the last line is expecting a Dog
. The only hinky thing is treating the Producer[Animal]
as a Producer[Dog]
, since a general Producer[Animal]
isn't promising anything about only producing Dog
s. Since contravariance means that we can do this, declaring Producer
to be contravariant can't be legal.
On the flip-side, if Producer were covariant, we could
val dogProducer = new Producer[Dog] { def getOne: Dog = new Dog() }
val animalProducer: Producer[Animal] = dogProducer
val animal: Animal = animalProducer.getOne
And that's clearly OK, since a Dog
is an Animal
, so it would be OK to declare
trait Producer[+A] {
def getOne: A
}
Meanwhile, let's model something that we can only produce Animal
s to: Animal
s go in but they don't come out:
trait Consumer[A] {
def consume(a: A): Unit
}
Should Consumer
be covariant? No, because imagine that we have a dog-petter:
val dogPetter = new Consumer[Dog] {
def consume(dog: Dog): Unit = println("petting a dog")
}
dogPetter
only knows how to pet dogs. It would be silly to have it pet a slug. But if we made Consumer
covariant, we could make it pet slugs:
class Slug() extends Animal
val animalPetter: Consumer[Animal] = dogPetter // no cast needed, since covariance would mean a Consumer[Dog] is a Consumer[Animal]
val slug = new Slug()
animalPetter.consume(slug) // Uh-oh, the dogPetter is petting a slug!
dogPetter
's definition is perfectly fine. Treating a Slug
as an animal is perfectly fine: a Consumer[Animal]
should definitely be able to consume
a Slug
. So the problem must be with treating a dogPetter
as an Animal
-petter, which means the problem is with covariance.
On the flip-side, imagine that we had an object that could praise an Animal
, any Animal
:
val animalPraiser = new Consumer[Animal] {
def consume(a: Animal): Unit = println("I praise this ${a.getClass} (like I should)")
}
It can clearly praise a Dog
or a Slug
or any other Animal
, so this should be valid:
val dogPraiser: Consumer[Dog] = animalPraiser
dogPraiser.consume(new Dog)
Thus, Consumer
can be contravariant:
class Consumer[-A] { ... }
The intuition (which is absolutely correct) is that a type which is generic in A
can only be covariant in A
if it does not consume A
s; likewise, it can only be contravariant in A
if it does not produce A
s.
What if we have something that we can produce to and consume from? Well, then it must be invariant because it can't be covariant and it can't be invariant. Invariance is a conservative default, since assuming covariance or contravariance could lead to some unsound places.
Java likewise has covariance and contravariance annotations:
Foo<? extends Bar>
for covariance
Foo<? super Bar>
for contravariance
The key difference with Scala is that in Java you would have the annotation at the use-site while in Scala it's at the definition site. The Scala approach, if nothing else, reduces the repetitive variance annotations.