In this post we will try to fathom the concepts of covariance and contravariance in bare bones. We won't be discussing the advantages and disadvantages of variance and things like that in this post. You can find better posts online.
This post deliberately doesn't discuss real world metaphors to keep you focussed strictly at the topic in hand.
You can consider this post as an extension to a wonderful post on Variance by Mike James. The link to which is here. Once you get through the current article completely comprehending the post by Mike would be far easier, I believe.
Also I have listed couple of good posts on Variance at the bottom of this article for further exploration.
I hope by the end of this post following statement will make more sense than ever!
Variance defines inheritance relationships of parameterized types.
Let's get started!
The Substitution Principle
In this section we will try to understand the implications of the general principle of substitution on types and functions following which we will find answers to the related questions.
The content following is majorly from the post by Mike I mentioned earlier but needs to be revisited to make the most sense out of it.
Types and the principle of substitution
If you define a function which accepts an object of type A as its input parameter and returns a derived object of type B as its output i.e., f(A)->B where A:>B. Then by the substitution principle you can use an object of type B as the input and object of type A as the result.
In other words, on input A can be replaced by a subclass and on output B can be replaced by a superclass.
Functions and the principle of substitution
Consider two functions, MyFunction1 and MyFunction2 that just differ in the type of their input parameter where type parameters A and B are related as A:>B.
By the substitution principle, MyFunction1 can accept object of type B as its input and so can be used anywhere MyFunction2 can possibly be used. Hence by the reverse substitution principle you have to regard MyFunction1 as derived from MyFunction2 i.e., MyFunction1:>MyFunction2. In other words, by changing the input parameter type from A to B where A:>B results in MyFunction1<:MyFunction2 i.e., functions are contravariant in input parameter.
Now, let's repeat the argument but with two functions that only differ by their output type where return types A and B are related as A:>B.
By the substitution principle, it is clear that MyFunction2 that returns type B can be used anywhere MyFunction1 that returns type A because B can always be treated as A. Hence, function MyFunction2 has to be considered as derived from function MyFunction1. In other words, by changing the return type from B to A where A:>B results in MyFunction1:>MyFunction2 i.e., functions are contravariant in return type.
In general, changing input parameter type to be more derived makes the function less derived i.e., contravariant change and changing the output type to be more derived makes the function more derived i.e., covariant change.
To put it formally, suppose we have two types A and B and we have a modification, or transformation T, that we can make to both of them to give new types T(A) and T(B).
- If T is a covariant transformation (which is possible only if T returns types A and B, as discussed above), where A:>B implies, T(A):>T(B).
- If T is a contravariant transformation (which is possible only if T accepts types A and B as input parameters, as discussed above), where A :> B implies, T(A) <: T(B).
- It is also possible that neither relationship applies i.e., A :> B doesn't imply anything about the relationship between T(A) and T(B). In this case T is referred to as invariant.
Note: The Scala compiler requires function arguments to behave contravariantly and return types to behave covariantly.
How is all this related to parameterized type anyway?
Well when defined within a parameterized type functions have their input type parameters and return type parameterized, which gets bound to a concrete type when and where the parameterized type is used.
In the above piece of code the parameterized type X binds to concrete type A, B and C respectively, as shown below:
Now, as discussed above, we already know that functions are covariant in return type i.e., we can substitute function get that returns type B with function get that returns type A.
What we have done above, in particular, is that we have tried to substitute
def get: B with
def get: C and
def put(r: B) with
def put(r: C). But Scala compiler returns with an error message indicating that class X is invariant in type S i.e., it doesn't allow us to use X[C] in place of X[B]. We will discuss the significance this along the post but in order to keep our discussion focussed at function substitution I will comply to compiler and redefine our parameterized type X to make it variant in a certain way.
Let's redefine our parameterized type X as shown below:
Type X here is defined as covariant in type parameter S. What it means is that X[C] can now substitute X[B].
Note: Representing covariance with symbol '+' in the parameterized type definition is the design choice made by Scala designers.
Here we need to understand that substituting parameterized types covariantly means substituting functions covariantly. If we try to substitute
def get: B with
def get: C, we don't see any error reported by the Scala compiler since it's a legal substitution, as discussed above. However, notice how Scala compiler clearly pin points the illegal substitution
def get: B with
def get: A.
Let's see what happens if we try to define 'put' method within the covariant definition of type X.
It's clearly noticeable that it's illegal to define functions that accept type parameter as method argument within the covariant definition of type X. Such functions are contravariant in nature.
We will next redefine type X to study contravariance:
The class X here is defined as contravariant in type parameter S. What it means is that X[A] can now substitute X[B].
Note: Representing contravariance with symbol '-' in the parameterized type definition is the design choice made by Scala designers.
Here we need to understand that substituting parameterized types contravariantly means substituting functions contravariantly. If we try to substitute
def put(s: B) with
def put(s: A), we don't see any error reported by the Scala compiler since it's a legal substitution, as discussed above. However, notice how Scala compiler clearly pin points the illegal substitution
def put(s: B) with
def put(s: C).
Let's see what happens if we try to define 'get' method within the contravariant definition of type X.
It's clearly noticeable that it's illegal to define functions that return type parameter within the contravariant definition of type X. As already discussed, such functions are covariant in nature.
How to decide what variance to choose?
Well, since now we understand the following points. In general, parameterized types that are covariant in the type parameter are producers of that type parameter and those that are contravariant in the type parameter are consumers of the type parameter.
- Substituting parameterized types covariantly means substituting functions covariantly.
- Substituting parameterized types contravariantly means substituting functions contravariantly.
Based on this knowledge you may decide which variance is most suited in your use case.
With this we will finish our discussion on covariance and contravariance in Scala. Hope you understand these concepts in bare bones, by now. Please feel free to reach me for any clarifications.
To explore further you may refer the posts listed below.