Covariant type A accepted in contravariant position of function argument A => B

Viewed 266

Consider covariant type parameter A

case class Foo[+A](a: A):
  def bar(a: A) = a             // error: covariant type A occurs in contravariant position 
  def zar(f: A => Int) = f(a)   // ok
             |
          This is contravariant position. Why is it ok?

Foo(41).zar(_ + 1) // : Int = 42

Why is it accepted as argument to zar when it occurs in contravariant position in A => Int?

2 Answers

Following the idea by @sarveshseri I will provide an intuitive explanation rather than a formal one. Both because I do not know a the details of a formal one and because I hope this one would be more helpful for readers.

First some disclaimers:

  1. I may have typos or some errors, please edit the answer if you notice it.
  2. As mentioned above the mental model I am to describe is an approximation, what really happens at compile time and at runtime will be different.
  3. During this answer I will be referring to types and classes interchangeable. This is WRONG and I myself have pointed this on other answers. In this case it is for simplicity of this mental model; but I recommend that after variance clicks then you mix the distinction of types and classes to that model: https://typelevel.org/blog/2017/02/13/more-types-than-classes.html
  4. Connected to the previous point, I will be using concrete/simple types on my example. Thankfully, thanks to type erasure and parametricity what I will explain applies to type constructors and other complex types.
  5. I will also be using methods and functions interchangeably, this is also WRONG: https://docs.scala-lang.org/tutorials/FAQ/index.html#whats-the-difference-between-methods-and-functions

Now let's get to it. First let's assume that if a method accepts a Foo then it can only accept values of type Foo and nothing else, period.

Then let's assume that subtyping is actually an "implicit" function that "cast" values. So B <: A is just B => A
And let's imagine that such "cast" is a disguise, the value is actually the same one but seen differently (this is basically the Liskov principle).

So, when you try to pass a B to a method that expects an A then this implicit cast will be inserted by the compiler. That way in runtime, that method receives a value that is seen like one of type A and not one of type B; but the value is still of type B (actually here we would be talking about classes not types, but I hope you get the idea).

With that then let's see what happens to bar and baz methods of your covariant class Foo
Since Foo[Dog] <: Foo[Animal] the I can cast the former as the latter, then given Cat <: Animal I can also cast the former to the latter. Finally, I could pass this Cat disguised as a Animal to the bar method of Foo[Dog] disguised as a Foo[Animal] but then at runtime we would be passing a Cat to a method that expects a Dog kataplum! Unless, of course, such method was always prepared for such situation.

That is why bar has to be defined like [B >: A](b: B). Here we are saying that we can accept any B for which the compiler can produce the implicit cast function A => B (the opposite as before, thanks to Any such type and such function is always possible). And then the implementation of bar should be able to work for that new type B and using the cast function when is required.
Which would mean that the previous example doesn't blow up at runtime, since we could have just passed the Cat directly without going via indirect disguises; this works because the compiler is always able to infer that B should be Animal (the LUB of Cat and Dog) so it would cast the Cat as Animal and would pass that to bar as well as the cast function Dog => Animal

Note, the presence of the A => B cast function implies that the compiler can also create the F[A] => F[B] function, if F is covariant.

Now let's see what happens to baz. Again, we would cast a Foo[Dog] as a Foo[Animal] and then we would try to call baz with a function Animal => Int which should work at runtime because we do not even need the disguise at all, we could have passed such a function directly to Foo[Dog] because (Animal => Int) <: (Dog => Int) this because functions are contravariant on their inputs.

But how would this work at all? Simple, the intuition says that if I am able to process / consume / receive / use any Animal then I should be able to process any Dog since those are Animals, right? Let's see it how that would work with our mental model.

I have baz(Dog => Int) and I have f(Animal => Int) what the compiler can do is create a new function g(Dog => Int) = cast(Dog => Animal) andThen f(Animal => Int) and use g instead.

Hope this helps, feel free to leave any question.

Your class can be viewed as

trait Function1[-Input, +Result]  // Renamed the type parameters for clarity

case class Foo[+A](a: A) {
  val bar: Function1[A, A] = identity
  val zar: Function1[Function1[A, Int], Int] = { f => f(a) }
}

The approach taken by the compiler is to assign positive, negative, and neutral annotations at each type position in the signature; I'll notate the positions by putting + and - after the type in that position

First the top-level vals are positive (i.e. covariant):

case class Foo[+A](a: A) {
  val bar: Function1[A, A]+
  val zar: Function1[Function1[A, Int], Int]+
}

bar can be anything that's a subtype of Function1[A, A] and zar can be anything that's a subtype of Function1[Function1[A, Int], Int], so this makes sense (LSP, etc.).

The compiler then moves into the type arguments.

case class Foo[+A](a: A) {
  val bar: Function1[A-, A+]
  val zar: Function1[Function1[A, Int]-, Int+]
}

Since Input is contravariant, this "flips" the classification (+ -> -, - -> +, neutral unchanged) relative to its surrounding classification. Result being covariant does not flip the classification (if there was an invariant parameter in Function1, that would force the classification to neutral). Applying this a second time

case class Foo[+A](a: A) {
  val bar: Function1[A-, A+]
  val zar: Function1[Function1[A+, Int-], Int+]
}

A type parameter for the class being defined can only be used in + position if it's covariant, - position if contravariant, and anywhere if it's invariant (Int, which is not a type parameter can be considered invariant for the purpose of this analysis: i.e. we could have dispensed with annotating once we got to a type which neither was a type parameter nor had a type parameter). In bar, we have a conflict (the A-), but in zar the A+ means no conflict.

The produce/consume relationship presented in Luis's answer is a good intuitive summary. This is a partial (methods with type parameters complicate this, though I'm not totally sure how my transformation would work there...) exploration of how the compiler actually concludes that the A in zar is in a covariant position; Programming in Scala (Odersky, Spoon, Venners) describes it in more detail (in the 3rd edition, it's in section 19.4).

Related