3

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?

Mario Galic
  • 47,285
  • 6
  • 56
  • 98
  • 1
    If you want to get the intution for it, think of convert `zar` to function, its "type signature" will be something like `[-[-A, +Int], Int]`. Where as `bar` will be like `[-A, +A]`. Although this is not techniclly correct, this should convey the gist of the logic. – sarveshseri Jul 03 '21 at 09:35
  • @sarveshseri So does `-[-A, +Int]` mean `[+A, -Int]`? – Mario Galic Jul 03 '21 at 09:41
  • 1
    Intutively but not exactly. I have noticed that this "imperfect logic" works most of the time. Think of it more from the producer/consumer and type-casting perspective and it will be clear why it works. Your `zar` takes `f` which `consumes A to produce Int` to produce something which`consumes Unit and produces Int` (or is it `consumes Nothing and produces Int` ? Not sure. what is the type of `=> Int` ?). – sarveshseri Jul 03 '21 at 09:58
  • It is in a contravariant position in an argument which is itself in a contravariant position. Variance works like parity: twice contravariant is covariant, and any number of times covariant is also covariant. – n. m. could be an AI Jul 03 '21 at 10:03
  • @n.1.8e9-where's-my-sharem. With `Foo[+A, +B]` then `B` is covariant, but `def zar(f: A => B)` errors with `covariant type B occurs in contravariant position in type A => B of parameter f`, so twice covariant does not seem to be covariant here. – Mario Galic Jul 03 '21 at 10:09
  • B is in a covariant position in f which is in a contravariant position. – n. m. could be an AI Jul 03 '21 at 10:16
  • @sarveshseri When you say "not exactly", what is an example where logic `-[-A, +Int] mean [+A, -Int]` does not work? – Mario Galic Jul 03 '21 at 10:33
  • I have not seen (or might have seen but never notcied) the cases where it does not work. What I mean to say is that the whole `+/-` thing for variance is just representation and so the basic arithmatic rules don't necessarily apply here. I am not sure if a proof for this exists or not. Niether am I sure that this will work for all cases. The real logic is the combination of consumer/producer semantics and related type constraints. – sarveshseri Jul 03 '21 at 11:45

2 Answers2

6

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.

Mario Galic
  • 47,285
  • 6
  • 56
  • 98
  • I am unable to get the paragraph starting with "That is why bar has to be defined like [B >: A](b: B). Here we...." Why do we need to establish the relationship between A and B ? B is stand alone. I see that this paragraph is explaining how to overcome the problem in the previous paragraph. But not able to get how it is solving... – user3103957 Jul 15 '21 at 06:20
  • @user3103957 sure you could just use `B` alone and in terms of variance it will be correct at definition site as well as at usage site the compiler would have inferred `Animal` for the disguised case. However, if you try to implement your own **List** and you try to implement the `::` method and you use just `def ::[B](b: B): List[B]` without the `B >: A` you will notice two things, first that it is impossible to implement, second that doing `cat :: listOfDogs` would return a `List[Cat]` _(assuming you force the implementation someway)_ which doesn't make sense at all. – Luis Miguel Mejía Suárez Jul 15 '21 at 12:45
  • @user3103957 so those are the 2 reasons why we use `B >: A` First to get the _"implicit"_ cast function `A => B` which also give us another one `F[A] => F[B]` _(where `F` is the covariant type being define, like `List[+A]`)_ that is used in the implementation. And also, because that way we force the compiler to find the LUB between the current type inside the covariant type and the type of the passed element. Another intuitive explanation here: https://stackoverflow.com/questions/54163830/implementing-a-method-inside-a-scala-parameterized-class-with-a-covariant-type/54164135#54164135 – Luis Miguel Mejía Suárez Jul 15 '21 at 12:46
  • Thanks Luis! Sorry for the delayed reply.. I spent considerable amount of time thinking about this. Basically the usage of [B >:A], allows any sort of type to be passed to the function. The type 'Any' satisfies this. So why not we use 'Any' in place of B? Hence [B >: A] can be avoided completely. I really appreciate your valuable time responding to my questions!! This co/contra variance and their usage is the hardest thing I have ever come across in any languages. – user3103957 Jul 16 '21 at 15:47
  • @user3103957 again let's consider this with the concrete example of prepend on `List` if you do `def ::(elem: Any): List[Any]` then if you do `val animals = myCat :: myDogs` you would get back a `List[Any]` which means we lose all the type safety of the language, even worse something as simple as `1 :: Nil` would be a `List[Any]` even worse, all lists would be `List[Any]` sine there is not a typesafe way to construct a well-typed one. – Luis Miguel Mejía Suárez Jul 16 '21 at 16:01
4

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).

Levi Ramsey
  • 18,884
  • 1
  • 16
  • 30