I've read a few tutorials including the main Scala documentation regarding method signatures of covariant types. Suppose I have the following abstract class:
abstract class List[+A] {
def head: A
def tail: List[A]
def isEmpty: Boolean
def add[B >: A](element: B): List[B]
protected def printElements: String
override def toString: String = "[" + printElements + "]"
}
My question concerns the signature of the add()
method. Why is it necessary to declare it that way? We are passing in a parameter that is a supertype of A. What problem does this solve? I'm trying to understand this on an intuitive level.
Formal explanation
Given
abstract class List[+A] {
def add(element: A): List[A]
}
"This program does not compile, because the parameter element in add
is of type A
, which we declared covariant. This doesn’t work because functions are contravariant in their parameter types and covariant in their result types. To fix this, we need to flip the variance of the type of the parameter element in add
.
We do this by introducing a new type parameter B
that has A
as a lower type bound".
-- reference.
Intuitive explanation
In this example, if you add
something to a List:
It must be an A
- in this case the List is still a List[A]
.
Or it must be any subtype of A
- in this case the element gets upcasted to A
, and the List remains a List[A]
.
Or if it is another type B
, then it MUST be a supertype of A
- in this case the List gets upcasted to a List[B]
. (Note: Because Any
is just a supertype of everything, in the worst case the List will be upcasted to List[Any]
).
Suppose I want to make a list of integers. And suppose, for the sake of argument, that add
is implemented without generics.
def add(element: A): List[A]
For the sake of this example, let's suppose we have some way of producing an "empty" list.
def emptyList[A]: List[A] = /* some magic */
Now I want to make my list of integers.
(1 to 10).foldRight(emptyList) { (x, acc) => acc.add(x) }
Oops! We have a problem! When I call emptyList
, Scala is going to infer the most general type, and since A
is covariant, it's going to assume Nothing
. That means I just tried to add an integer to a list of nothing. We could fix this problem with an explicit type signature,
(1 to 10).foldRight(emptyList[Int]) { (x, acc) => acc.add(x) }
But, really, that's not solving the problem. It adds nothing to readability and just requires the user to do extra work. Realistically, I should be able to append a number to a list of nothing. It's just that, if I choose to do so, I can't meaningfully call it a list of Nothing
anymore. Hence, if we define
def add[B >: A](element: B): List[B]
Now, I can start with a List[Nothing]
and add an Int
to it. The thing I get out isn't a List[Nothing]
anymore; it's a List[Int]
, but I can do it. If I take that List[Int]
and come along later and add a String
to it, well I can do that too, but now I have a virtually useless List[Any]
.
When you declare +A
, you're saying that, for example, List[String]
extends List[Object]
. Now, imagine this:
val ls: List[Object] = List[String]() // Legal because of covariance
ls.add(1) // Adding an int to a list of String?
This is only legal if the type of the List can be expanded to include arbitrary Objects, which is exactly what your add signature does. Otherwise, the existence of add(a: A)
would imply an inconsistency in the type system.