Why is variance defined based on positions?

I was going through the below code here

class Pets[+A](val pet:A) {
def add(pet2: A): String = "done"
}

And on compiling it we get the error

<console>:9: error: covariant type A occurs in contravariant position in type A of value pet2

The reason given for it is : -
The compiler prevents us from falling into the absurdity of calling pets.add (Dog ()), since it is a set of Cat.

Isn’t the compiler assuming here that I am maintaining a Set (or a collection) of Cat internally? I might intend to do something totally different which is type safe. But the compiler prevents me from doing this.

Could you give an example?

Perhaps it would help to expand on the error message a bit. Assume the compiler allowed this. If it did, then Pets[Cat] would be a subtype of Pets[Animal]. So if we had a method addPet(pets: Pets[Animal]) then we would be able to pass it an instance of Pets[Cat]. However, it is perfectly find to call pets.add(Dog()) on a Pets[Animal], so if this compiled, you could do something that shouldn’t be allowed. Perhaps the following code shows this even more clearly.

val cats = new Pets[Cat]
val pets: Pets[Animal] = cats // works if Pets[Cat] <: Pets[Animal]
pets.add(Dog())

If Pets could be covariant in A then this would be perfectly valid code, even though it clearly shouldn’t be allowed. The “position” of A as an input to a method makes covariance unsafe. Return positions are fine, but being in an input causes problems. The inverse is true for contravariance.

This is why the signature of methods like :: which “add to” Lists look like the following.

def::[B >: A](elem: B): List[B]

Note that the input isn’t of type A but of type B which must be a supertype of A. You might be able to mirror this type of thing in your Pets class, depending on what you are doing with it.

1 Like

Following up on MarkCLewis’s post since I found this confusing when I first learned about it. Changing your code to

class Pets[+A](val pet: A) {
  def add[B >: A](pet2: B): String = "done"
}

will avoid the compiler complaining. What this does is tells tells the compiler to only allow types that A is a supertype of. The variance of Pets says you can also use anything that is a subtype of A. So the compiler works out that ONLY things of type A are allowed (A being the only subtype and supertype of A). It’s basically a technique to make A invariant for def add.

There are two reasons. First, it is too easy for the result to do something entirely different that what seems obvious. It’s why the default polymorphism is strict. One really needs to deeply understand the schematics to predict the outcome of large systems. Second is the halting problem. If one uses both +T and -U, the type calculus is not to halt. It’s rare, but it can happen. Hopefully the compiler will crash rather than the runtime system hang. Positional schematics greatly reduces both problems. At the price of being annoying.