In his book Effective Java, Joshua Bloch coined the pneumonic device PECS: Producer Extends, Consumer Super. This device can help us remember which Java keyword (extends
or super
) to use when specifying the bounds on generic types.
Unfortunately, we don't have a similar pneumonic for remembering the +
notation for covariance or the -
notation for contravariance in Scala. You need to remember that producers are +
covariant, and consumers are -
contravariant. The following example shows why producers are covariant and consumers are contravariant in terms of substitutability
Let's say that we have some generic boxes that can hold whatever we want.
case class Box[+T](value: T)
We'll assume three classes of objects we might put in our boxes.
Fuel |
> | Plants |
> | Bamboo |
abstract class Fuel case object KiloOfFuel extends Fuel abstract class Plant extends Fuel case object KiloOfPlants extends Plant abstract class Bamboo extends Plant case object KiloOfBamboo extends Bamboo
Above, the > sign designates a substitutability relationship. Since Fuel > Plants, we can use Plants anywhere that simply requires a Fuel. This is because plant matter is a type of fuel. Similarly, since Bamboo is a type of plant, we can use Bamboo anywhere that requires Plants or Fuel; therefore, Plants > Bamboo and Fuel > Bamboo. This is the subtype relationship that we're so familiar with.
Since a box of some thing is just a collection of that thing,
it makes sense that the subtype/substitutability relationship for Box[T]
looks the same as for the fuel types that we put in the boxes:
Box of Fuel |
> | Box of Plants |
> | Box of Bamboo |
case object BoxOfFuel extends Box[Fuel](KiloOfFuel) case object BoxOfPlant extends Box[Plant](KiloOfPlant) case object BoxOfBamboo extends Box[Bamboo](KiloOfBamboo)
We'll also assume three classes of objects that might want to do something with our boxes (or, rather, the contents of the boxes).
Consumer of anything that burns |
< | Consumers of plants |
< | Consumers of bamboo |
abstract class Consumer[-U] { def apply(box: Box[U]): Unit } case object Panda extends Consumer[Bamboo] { def apply(box: Box[Bamboo]) = println("Munch munch...") } case object Herbivore extends Consumer[Plant] { def apply(box: Box[Plant]) = println("Om nom nom...") } case object Fire extends Consumer[Fuel] { def apply(box: Box[Fuel]) = println("Crackle crackle...") }
Interestingly, the substitutability relationship goes in the opposite direction here. Although a panda is a type of herbivore, we see that Herbivore < Panda. This is because, although pandas are a subset of the herbivores, the panda's dietary restrictions prevent us from substituting it for a general consumer of all plants. However, since the class herbivores should be able to eat any kind of plant, we could substitute a consumer of plants anywhere that we need a consumer of bamboo.
Boxes are producers because they just provide read-only data for some other object to read (or "consume").
A Box[Bamboo]
can be substituted for a Box[Plant]
since a Box[Bamboo]
is a Box[Plant]
.
Similarly, a Box[Bamboo]
is a Box[Fuel]
,
and a Box[Plants]
is a Box[Fuel]
.
Notice that the "is a" relationship for Box[A]
and Box[B]
is the same relationship as for type A
and type B
.
Therefore, read-only types like Boxes should use the +
covariant notation:
Box[+T]
.
if you need to feed a... |
you can give it a... | ||
---|---|---|---|
Our Consumer types are obviously consumers. They consume the data provide by a Box.
Note that our Consumer types are write-only; i.e., the data goes in, but none comes out.
That's the opposite of our read-only Box types.
Similarly, the substitutability relationship for Consumers is the opposite as for Boxes;
e.g., a Consumer[Plant]
is a Consumer[Bamboo]
.
I can substitute a Fire
anywhere I can use a Panda
,
but I can't substitute a Panda
anywhere I can use a Herbivore
.
Notice that the "is a" relationship for Consumer[A]
and Consumer[B]
is the opposite relationship as for type A
and type B
.
Therefore, write-only types like Consumers should use the -
contravariant notation:
Consumer[-U]
.
if you have a... |
you can give it to... | ||
---|---|---|---|
Some types support both reading and writing.
A common example is the Array[T]
type:
val myArray: Array[Int] = new Array(1, 2, 3) val x = myArray(0) + 5 // Reading from myArray at index 0 myArray(0) = x // Writing to myArray at index 0
For types that are both readable and writable, we need to take the intersection
of both the "is a" substitutability rules for covariance and contravariance.
The intersection of the supertypes of a type T
and the subtypes of a type T
is only the exact type T
.
That is why Array[T]
is invariant in Scala, and has no +
/-
annotation on the type parameter.
An Array[Plant]
is neither an Array[Fuel]
nor an Array[Bamboo]
.
An Array[Plant]
is only an Array[Plant]
.
The images in this document are licensed under the Creative Commons Share-Alike License. The producer/consumer figures were adapted from a wonderful diagram by Andrey Tyukin.