I have read this article on covariance/contravariance: http://julien.richard-foy.fr/blog/2013/02/21/be-friend-with-covariance-and-contravariance/
The examples are very clear. However, I am struggling to understand the conclusions drawn at the end:
If you look at the definitions of Run[+A] and Vet[-A] you may notice that the type Aappears only in the return type of methods of Run[+A] and only in the parameters of methods of Vet[-A]. More generally a type that produces values of type A can be made covariant on A (as you did with Run[+A]), and a type that consumes values of type A can be made contravariant on A (as you did with Vet[-A]).
From the above paragraph you can deduce that types that only have getters can be covariant (in other words, immutable data types can be covariant, and that’s the case for most of the data types of Scala’s standard library), but mutable data types are necessarily invariant (they have getters and setters, so they both produce and consume values).
Producers: If something produces type A, I can imagine some reference variable of type A being set to an object of type A or any subtypes of A, but not supertypes, so it's appropriate that it can be covariant.
Consumers: If something consumes type A, I guess that means type A may be used as parameters in methods. I'm not clear what relationship this has to covariance or contravariance.
It seems from the examples that specifying a type as covariant/contravariant affects how it can be consumed by other functions but not sure how it affects the classes themselves.
It seems from the examples that specifying a type as covariant/contravariant affects how it can be consumed by other functions but not sure how it affects the classes themselves.
It is right that the article focused on the consequences of variance for users of a class, not for implementers of a class.
The article shows that covariant and contravariant types give more freedom to users (because a function that accepts a Run[Mammal]
effectively accepts a Run[Giraffe]
or a Run[Zebra]
). For implementors, the perspective is dual: covariant and contravariant types give them more constraints.
These constraints are that covariant types can not occur in contravariant positions and vice versa.
Consider for instance this Producer
type definition:
trait Producer[+A] {
def produce(): A
}
The type parameter A
is covariant. Therefore we can only use it in covariant positions (such as a method return type), but we can not use it in contravariant position (such as a method parameter):
trait Producer[+A] {
def produce(): A
def consume(a: A): Unit // (does not compile because A is in contravariant position)
}
Why is it illegal to do so? What could go wrong if this code compiled? Well, consider the following scenario. First, get some Producer[Zebra]
:
val zebraProducer: Producer[Zebra] = …
Then upcast it to a Producer[Mammal]
(which is legal, because we claimed the type parameter to be covariant):
val mammalProducer: Producer[Mammal] = zebraProducer
Finally, feed it with a Giraffe
(which is legal too because the consume
method a Producer[Mammal]
accepts a Mammal
, and a Giraffe
is a Mammal
):
mammalProducer.consume(new Giraffe)
However, if you remember well, the mammalProducer
was actually a zebraProducer
, so its consume
implementation actually only accepts a Zebra
, not a Giraffe
! So, in practice, if it was allowed to use covariant types in contravariant positions (like I did with the consume
method), the type system would be unsound. We can construct a similar scenario (leading to an absurdity) if we pretend that a class with a contravariant type parameter can also have a method where it is in covariant position (see at the end for the code).
(Note that several programming languages, e.g. Java or TypeScript, have such unsound type systems.)
In practice, in Scala if we want to use a covariant type parameter in contravariant position, we have to use the following trick:
trait Producer[+A] {
def produce(): A
def consume[B >: A](b: B): Unit
}
In that case, a Producer[Zebra]
would not expect to get an actual Zebra
passed in the consume
method (but any value of a type B
, lower-bounded by Zebra
), so it would be legal to pass a Giraffe
, which is a Mammal
, which is a super-type of Zebra
.
Appendix: similar scenario for contravariance
Consider the following class Consumer[-A]
, which has a contravariant type parameter A
:
trait Consumer[-A] {
def consume(a: A): Unit
}
Suppose that the type system allowed us to define a method where A
is in covariant position:
trait Consumer[-A] {
def consume(a: A): Unit
def produce(): A // (does not actually compile because A is in covariant position)
}
Now we can get an instance of Consumer[Mammal]
, upcast it to Consumer[Zebra]
(because of contravariance) and call the produce
method to get a Zebra
:
val mammalConsumer: Consumer[Mammal] = …
val zebraConsumer: Consumer[Zebra] = mammalConsumer // legal, because we claimed `A` to be contravariant
val zebra: Zebra = zebraConsumer.produce()
However, our zebraConsumer
is actually mammalConsumer
, whose method produce
can return any Mammal
, not just Zebra
s. So, at the end, zebra
might be initialized to some Mammal
that is not a Zebra
! In order to avoid such absurdities, the type system forbids us to define the produce
method in the Consumer
class.