Contravariance question

After reading some articles such as

https://docs.scala-lang.org/tour/variances.html

https://www.freecodecamp.org/news/understand-scala-variances-building-restaurants/

I find I still do not understand Contravariance very well.

I can see (I suppose I do not really understand in fact) individual things like

Cat is a sub type of Animal so contravariance Printer[Animal] is a sub type of Printer[Cat] 

But I fail understanding the reasons (or the link to understand those relationship). So for the code I attempted, it seems to me the last line where the parameter vegetableRecipe (Recipe[Vegetable]) is still the sub type of Recipe[Food]. So the chef can cook with that recipe.

  trait Food extends Product with Serializable {
    def name: String
  }
  case class Vegetable(name: String) extends Food
  trait Meat extends Food
  case class WhiteMeat(name: String) extends Meat
  case class RedMeat(name: String) extends Meat
  trait Recipe[+A] {
    def name: String
    def ingredients: List[A]
  }
  case class GenericRecipe(ingredients: List[Food]) extends Recipe[Food] {
    def name = s"Generic recipe ${ingredients.map(_.name)}"
  }
  case class MeatRecipe(ingredients: List[Meat]) extends Recipe[Meat] {
    def name = s"Meat recipe ${ingredients.map(_.name)}"
  }
  case class WhiteMeatRecipe(ingredients: List[Meat]) extends Recipe[Meat] {
    def name = s"WhiteMeat recipe ${ingredients.map(_.name)}"
  }
  case class VegetableRecipe(ingredients: List[Vegetable]) extends Recipe[Vegetable] {
    def name = s"Vegetable recipe ${ingredients.map(_.name)}"
  }
  trait Chef[-A] {
    def cook(a: Recipe[A]): Unit
  }
  case object GenericChef extends Chef[Food] {
    def cook(a: Recipe[Food]) = println(s"Generic chef can cook ${a.name}")
  }
  case object MeatChef extends Chef[Meat] {
    def cook(a: Recipe[Meat]) = println(s"Generic chef can cook ${a.name}")
  }
  case object VegetableChef extends Chef[Vegetable] {
    def cook(a: Recipe[Vegetable]) = println(s"Generic chef can cook ${a.name}")
  }
  val beef = RedMeat("beef")
  val chicken = WhiteMeat("chicken")
  val turkey = WhiteMeat("turkey")
  val carrot = Vegetable("carrot")
  val tomato = Vegetable("tomato")
  val mixedRecipe = GenericRecipe(List(chicken, carrot, beef, tomato))
  val meatRecipe = MeatRecipe(List(beef, chicken))
  val vegetableRecipe = VegetableRecipe(List(tomato))

val genericChef = GenericChef
genericChef.cook(vegetableRecipe) // <-- here

TL;DR
Contravariance indeed seems a bit counterintuitive and it is, in my opinion. The only reason to include contravariance into your language is that the more intuitive solution over covariance doesn’t fit your needs.

That happens most of the time if you:

  • wan’t mutable subtyping
  • function subtyping

1.) To mutable subtyping:

You have a fun(list:List[ String | Int | Float]) and want to add a String into it: fun(list)=list.add("hello"). Assume that List is covariant, what happens if you pass a List[Int] to it and add “hello” to it, you crash. For this kind of operation you need contravariance which considers List[T] for any type T:> (String | Int | Float) as subtype of List[String | Int | Float].

2.) Function types aren’t considered as mathematical functions in programming languages (PL) because if so, they wouldn’t be subtypeable at all except for some degenerated cases.
Short background:
Passing and Int=>Int function to an Any=>Int functions isn’t save because and Any which is not an Int get not mapped contradicting the totality of functions.
The deeper background:
Functions are existentials in PLs, i.e. Int=>Int contain functions which must a least map each Int to an Int, but are may able to map more elements for other types as well, e.g. String, Any.
Functions of type Any=>Int have to map each Any to an Int and there can’t be any function which only maps Ints to Ints like for Int=>Int, so Int=>Int contains all functions of Any=>Any and more therefore implying Any=>Int <: Int=>Int.

Anyway, contravariance + covariance doesn’t replace any kind of unsound typing as you can’t read and write simultaneously to an collection in a safe way because contravariance is only safe for mutable operations and covariance for readable operations but you need to decide co -or contravariance within then same scope.

I myself, hate contravariance and only use it for functions. Contravariance becomes highly complex if you have nested parameters because contravariance is like a minus, multiplying it with itself by nesting parameters (nested functions) turns the nested parameters to covariant parameters.

The point of contravariance is that this compiles:

val chef: Chef[Vegetable] = genericChef

And that is because Chef[Food] is a subtype of Chef[Vegetable]. Because every chef that can cook any kind of food is a chef that can cook vegetables.
While for a covariant type like Recipe it’s the other way around. Every vegetable recipe is a food recipe, but not every food recipe is a vegetable recipe.

1 Like

Say, Vegetable extends Food and Tomato extends Vegetable. If you have some class Machine[Vegetable], it usually means one of these:

(1) Some method of Machine returns Vegetable, i.e. the Machine is a provider of Vegetable. Return types are promises (“I promise I will return a Vegetable”)

(2) Some method of Machine takes a Vegetable as argument, i.e. Machine is a recipient of Vegetable. Arguments types are requirements (“I require a Vegetable”)

The question is, in a place where a Machine[Vegetable] is required, can we substitute the Machine[Vegetable] by a Machine[Food] or a Machine[Tomato]?

The deciding principle is: require no more, promise no less.

Case 1: If your Machine[Vegetable] is only a provider of Vegetable, but not a recipient, then Vegetable is a promise. It is OK to promise more (i.e. a more specific type). Say, VendingMachine[Vegetable], where you can only get Vegetables out, but you cannot put them in. A VendingMachine[Tomato] is a VendingMachine[Vegetable]. This is covariance.

Case 2: If your Machine[Vegetable] is only a recipient of Vegetable, but not a provider, then Vegetable is a requirement. For example DisposalMachine[Vegetable], where you can only throw in stuff and never get it back. It is OK to require less (i.e. a less specific type), so DisposalMachine[Food] is a DisposalMachine[Vegetable]. This is Contravariance.

Case 3: Your Machine[Vegetable] is both a provider and a recipient of Vegetable. In this case, Vegetable is both a promise and a requirement. For example, Refrigerator[Vegetable], where you can put a Vegetable in and take it back again, so Refrigerator[Vegetable] cannot be substituted by either Refrigerator[Food] or Refrigerator[Tomato]. This is invariance.

To summarize @curoli a bit, I think the easiest way to gain an intuition for variance is to look at a function types, because ultimately this is where variance comes from.

If you ask me for a function of type Monkey => Food I can give you a function of type Animal => Banana and you’ll never know the difference. It will work fine because Monkey is a kind of Animal and Banana is a kind of Food. Put this together and Animal => Banana is a kind of of Monkey => Food, which we encode by declaring Function1[-A, +B].

If you can convince yourself you understand that example I think you will understand variance.


Quick PS. As @curoli notes, if a type parameter appears in both covariant (return) and contravariant (argument) positions then the data type is invariant in that parameter. There is a actually a fourth case, in which a type parameter appears in neither position and is this both covariant and contravariant. This is known as phantom variance and it’s actually useful! cats.data.Const uses this kind of variance.