The company I work for has a robust Intern program; as a result, I work with a lot of young engineers and computer scientists. To date:
- 100% of their resumes mention they have a tight grasp on Object Oriented Programming
- 100% of them fail to understand the finer points of subtyping and furthermore subclassing
I have given more explanations of variance than I have given explanations of anything else on the job. So, in a effort to practice the DRY principle in all my affairs, I decided to put it into documentation I can point to.
Note: Type Variance has A LOT of math (type theory & category theory) behind it. This post will focus on its usage in the Scala language not on the math.
Sub Classes
class Foo[T] def check[A, B](a:A, b:B)(implicit ev: A <:< B): Unit = {}
Here the class Foo is parameterized by T and the check function simply checks if the type of its first argument is a subclass of the type of its second argument. Don't worry about the check function, its implementation details are beyond the scope of this post but, its a nifty trick!
val str = "" val obj = new Object() check(str, obj)//compiles
So, the check here compiles which tells us String is a subclass of Object.
val fStr = new Foo[String] val fObj = new Foo[Object] check(fStr, fObj)//error: Cannot prove that zzz.simple.Foo[String] <:< zzz.simple.Foo[Object].
This doesn't compile because the type parameter of Foo allows for no variation in its relationship; Foo is invariant in T.
We will begin by briefly describing variance.
Variance
Variance is a huge part of programming with types. It is an intrinsic property of class hierarchies and can be witnessed as such in languages like C++ and Scala who have compiler errors along the lines of:
- covariant whatever in contravariant position
- whatever is in covariant position but is not a subclass of whatever
In short, type variance describes the types that may be substituted in place of another type.
Covariance: We say a substitution is covariant with a type, Foo, if Foo or any other class with a subclass relationship to Foo is valid.
Contravariance: We say a substitution is contravariant with a type, Bar, if Bar or any other class with a superclass relationship to Bar is valid.
Invariance: We say a substitution is in variant with a type, Foo, if only types that are exactly Foo are valid.
Variance in Practice
We already saw invariance above. In Scala covariance and contravariance are denoted by using the + and - symbols respectively.
Covariance
case class Foo[+T]//covariant in T
Redeclaring Foo in this way makes it covariant so our test now validates
val str = "" val obj = new Object() check(str, obj)//compiles val fStr = new Foo[String] val fObj = new Foo[Object] check(fStr, fObj)//compiles
Declaring the type variable with a + (as covariant) tells the compiler that the subclass relationship between type parameters gives rise to a direct subclass relationship in Foo. So any def, val or var requiring a Foo[Object] can take a Foo[String] as an argument in place.
Contravariance
case class Foo[-T]//contravariant in T
This redeclaration makes Foo contravariant and breaks our test again
val str = "" val obj = new Object() check(str, obj)//compiles val fStr = new Foo[String] val fObj = new Foo[Object] check(fStr, fObj)//error: Cannot prove that zzz.simple.Foo[String] <:< zzz.simple.Foo[Object].
This is what we expect! Contravariance implies a superclass relationship not a subclass relationship. We can fix this by reversing our input arguments
check(fObj, fStr)//compiles
This declaration is a hint to the compiler that the subclass relationship between type parameters gives rise to a superclass relationship in Foo. So any def, val or var requiring a Foo[String] can take a Foo[Object].
How to use Variance
Where covariance preserves the subclass relationship from the type parameter into the type, contraveriance reverses this relationship.
Covariance is used a lot in Scala by the collections library. Most of the immutable collections are covariant. This makes working with your data types inside the collection the same as working with them outside the collection when writing interfaces.
Contravariance is less prominent. I use contravariance for typeclasses a lot.
trait Bar[-T]{ def bar(t:T): Unit } implicit val bar = new Bar[Object]{def bar(o:Object): Unit = ()} def procBar[T:Bar](t: T){ implicitly[Bar[T]].bar(t) } procBar(obj)//compiles procBar(str)//pulled the superclass instance in
If a class does not have a typeclass instance in implicit scope for its type it can use a contravariant instance if one is in scope.