Covariance and Contravariance in .NET C#
Have hard time understanding it? Let me simplify it for you.
If it is so hard on you to understand what Covariance and Contravariance in .NET C# means, don’t feel ashamed of it, you are not alone.
It happened to me and many other developers. I even know experienced developers who either don’t know about them and are using them but still can’t understand them well enough.
From where I see it, this is happening because every time I come across an article talking about Covariance and Contravariance, I find it focused on some technical terminologies rather than being concerned about the reason why we have them in the first place and what we would have missed if they didn’t exist.
Photo by Tadas Sar on Unsplash
Microsoft’s Definition
If you check Microsoft’s documentation for the Covariance and Contravariance in .NET C#, you would find this definition:
In C#, covariance and contravariance enable implicit reference conversion for array types, delegate types, and generic type arguments. Covariance preserves assignment compatibility and contravariance reverses it.
Do you get it? do you like it?
You can search the internet and you will find tons of resources about this topic. You will come across definitions, history, when introduced, code samples,… and many others and this is not what you would find in this story. I promise you that what you would see here is different….
Photo by Rhys Kentish on Unsplash
What are they actually?
Basically, what Microsoft did is that they added a small addition to the way you define your generic template type place holder, the famous .
What you used to do when defining a generic interface is to follow the pattern public interface IMyInterface<T> {…}
. After having Covariance and Contravariance introduced, you can now follow the pattern public interface IMyInterface<out T> {…}
or public interface IMyInterface<in T> {…}
.
Do you recognize the extra out
and in
?
Have you seen them somewhere else?
May be on the famous .NET public interface IEnumerable<out T>
?
or the famous .NET public interface IComparable<in T>
?
Microsoft introduced a new concept so that the compiler -at design time- would make sure that the types of objects you use and pass around generic members would not throw runtime exceptions caused by wrong type expectations.
Still not clear, right? Just bear with me... Let’s assume that the compiler doesn’t apply any design time restrictions and see what would happen.
Photo by Rick Monteiro on Unsplash
What if the compiler doesn’t apply any design time restrictions?
To be able to work on an appropriate example, let’s define the following:
Looking into the code above, you will notice that:
Class A has
F1()
defined.Class B has
F1()
andF2()
defined.Class C has
F1()
,F2()
, andF3()
defined.The interface
IReaderWriter
hasRead()
which returns an object of typeTEntity
andWrite(TEntity entity)
which expects a parameter of typeTEntity
.
Then let’s define a TestReadWriter()
method as follows:
Calling TestReadWriter()
when passing in an instance of IReaderWriter<B>
This should work fine as we are not violating any rules. TestReadWriter()
is already expecting a parameter of type IReaderWriter<B>
.
Calling TestReadWriter()
when passing in an instance of IReaderWriter<A>
Keeping in mind the assumption that the compiler doesn’t apply any design time restrictions, this means that:
param.Read()
would return an instance of class A, not B
\=> So, thevar b
would actually be of type A, not B
\=> This would lead to theb.F2()
line to fail as thevar b
-which is actually of type A- does not haveF2()
definedparam.Write()
line in the code above would be expecting to receive a parameter of type A, not B
=> So, callingparam.Write()
while passing in a parameter of type B would both work fine
Therefore, since in the point #1 we are expecting a runtime failure, then we can’t call TestReadWriter()
with passing in an instance of IReaderWriter<A>
.
Calling TestReadWriter()
when passing in an instance of IReaderWriter<C>
Keeping in mind the assumption that the compiler doesn’t apply any design time restrictions, this means that:
param.Read()
would return an instance of class C, not B
\=> So, thevar b
would actually be of type C, not B
\=> This would lead to theb.F2()
line to work fine as thevar b
would haveF2()
param.Write()
line in the code above would be expecting to receive a parameter of type C, not B
=> So, callingparam.Write()
while passing in a parameter of type B would fail because simply you can’t replace C with its parent B
Therefore, since in the point #2 we are expecting a runtime failure, then we can’t call TestReadWriter()
with passing in an instance of IReaderWriter<C>
.
Photo by Markus Winkler on Unsplash
Now, let’s analyze what we have discovered up to this moment:
Calling
TestReadWriter(IReaderWriter<B> param)
when passing in an instance ofIReaderWriter<B>
is always fine.Calling
TestReadWriter(IReaderWriter<B> param)
when passing in an instance ofIReaderWriter<A>
would be fine if we don’t have theparam.Read()
call.Calling
TestReadWriter(IReaderWriter<B> param)
when passing in an instance ofIReaderWriter<C>
would be fine if we don’t have theparam.Write()
call.However, since we always have a mix between
param.Read()
andparam.Write()
, we would always have to stick to callingTestReadWriter(IReaderWriter<B> param)
with passing in an instance ofIReaderWriter<B>
, nothing else.Unless…….
Photo by Hal Gatewood on Unsplash
The Alternative
What if we make sure that the IReaderWriter<TEntity>
interface defines either TEntity Read()
or void Write(TEntity entity)
, not both of them at the same time.
Therefore, if we drop the TEntity Read()
, we would be able to call TestReadWriter(IReaderWriter<B> param)
with passing in an instance of IReaderWriter<A>
or IReaderWriter<B>
.
Similarly, if we drop the void Write(TEntity entity)
, we would be able to call TestReadWriter(IReaderWriter<B> param)
with passing in an instance of IReaderWriter<B>
or IReaderWriter<C>
.
This would be better for us as it would be less restrictive, right?
Photo by Agence Olloweb on Unsplash
Time for some Facts
In the real world, the compiler -in design time- would never allow calling
TestReadWriter(IReaderWriter<B> param)
with passing in an instance ofIReaderWriter<A>
. You would get a compilation error.Also, the compiler -in design time- would not allow calling
TestReadWriter(IReaderWriter<B> param)
with passing in an instance ofIReaderWriter<C>
. You would get a compilation error.From point #1 and #2, this is called Invariance.
Even if you drop the
TEntity Read()
from theIReaderWriter<TEntity>
interface, the compiler -in design time- would not allow you to callTestReadWriter(IReaderWriter<B> param)
with passing in an instance ofIReaderWriter<A>
. You would get a compilation error. This is because the compiler would not -implicitly by itself- look into the members defined into the interface and see if it is going to always work at runtime or not. You will need to do this by yourself through<in TEntity>
. This acts as a promise from you to the compiler that all members in the interface would either don’t depend onTEntity
or deal with it as an input, not an output. This is called Contravariance.Similarly, even if you drop the
void Write(TEntity entity)
from theIReaderWriter<TEntity>
interface, the compiler -in design time- would not allow you to callTestReadWriter(IReaderWriter<B> param)
with passing in an instance ofIReaderWriter<C>
. You would get a compilation error. This is because the compiler would not -implicitly by itself- look into the members defined into the interface and see if it is going to always work at runtime or not. You will need to do this by yourself through<out TEntity>
. This acts as a promise from you to the compiler that all members in the interface would either don’t depend onTEntity
or deal with it as an output, not an input. This is called Covariance.Therefore, adding
<out >
or<in >
makes the compiler less restrictive to serve our needs, not more restrictive as some developers would think.
Image by Harish Sharma from Pixabay
Summary
At this point, you should already understand the full story of Invariance, Covariance and Contravariance. However, as a quick recap, you can deal with the following as a cheat sheet:
Mix between input and output generic type => Invariance => the most restrictive => can’t replace with parents or children.
Added
<in >
=> only input => Contravariance => itself or replace with parents.Added
<out >
=> only output => Covariance => itself or replace with children.
Image by Ahmed Tarek
Also, in a later story, I noticed that understanding Invariance, Covariance and Contravariance would help you understand other topics and making the right design decisions. You can know more about this on my story A Best Practice for Designing Interfaces in .NET C#; Is it enough to define IMyInterface? do I need IMyInterface as well?
Finally, I will drop here some code for you to check. It would help you practice more.
That’s it, hope you found reading this story as interesting as I found writing it.
This article is originally published on Development Simply Put.