Hi @SliverHotDog, not sure why you deleted your original post but I hope this explanation that I was just posting when you deleted the topic would be helpful; not only for you but for future readers.
Variance is probably one of the most complex yet most useful topics in Scala. I hope that after this explanation the concept becomes clearer.
First let’s try to understand what the current signature means:
Intutive explanation
sealed trait List[+A] {
def add[B >: A](elem: B): List[B]
}
In this example, if you add
something to a List
then elem
should be:
- Of type
A
- In this case, it returns aList[A]
- Of any subtype of
A
- In this case, the element gets upcasted toA
, and the return is still aList[A]
(Actually, the previous case and this one are the same) - Or any other type
C
- In this case, the compiler will find a new typeB
which is the LUB (least upper bound) betweenA
&C
, then you can think that the whole original list gets upcasted to aList[B]
and the new element is also upcasted toB
and finally the return will be anotherList[B]
.
(BecauseAny
is just a supertype of everything, in the worst case the result will be aList[Any]
).
Formal explanation
If we tried to define add
like this:
sealed trait List[+A] {
def add(elem: A): List[A]
}
The code will not compile because:
The parameter
elem
inprepend
is of typeA
, which we declared covariant. This doesn’t work because functions are contravariant in their parameter types.
– Reference
In other words, since A
is covariant with respect to List
then it can’t be used as an argument of a method because those are contravariant.
Thus, in order to fix it, we must add a new type parameter B
But, to be able to successfully implement the method we end up adding the constraint that B >: A
But why?
The reason why this is the case is actually quite simple.
Since we declared List[+A]
so A
is covariant with respect to List
it means that I can always pass a List[Dog]
where a List[Pet]
is expected; because Dog <: Pet
then List[Dog] <: List[Pet]
Now, if we think about it, it means that I can always add a Pet
to a List[Dog]
right?
Because I can always do the upcast route:
val dogs: List[Dog] =???
val pet: Pet = ???
// Why would this not compile?
dogs.add(pet)
// When this should compile?
val pets: List[Pet] = dogs
pets.add(pet)
Similarly, I can always add a Cat
to a List[Dog]
because Cat <: Pet
and the Liskov principle should be held!
val dogs: List[Dog] =???
val cat: Cat = ???
// Why would this not compile?
dogs.add(cat)
// When this should compile?
val pets: List[Pet] = dogs
val pet: Pet = cat
pets.add(pet)
And, if you see the expected return type of the longer routes is List[Pet]
that is why if you add a Cat
to a List[Dog]
you get back a List[Pet]
; because the compiler automatically does for us what I just did manually, finding the LUB and doing the proper upcasting.
In conclusion, add
must work for any supertype of A
because I can always upcast the List
and it should keep working to guarantee the Liskov principle and its own covariance.
Note, if List
would have been invariant then add
would only accept elements of type A
, just like Array.update