From time to time I hear a certain question floating around through the office, along the lines of “Why won’t .NET let me pass my List<Thing> as a List<IThing>?”
The tone of the question usually suggests that someone is annoyed that .NET isn’t smart enough to make such a simple inference. The reality though is that the compiler is smart enough to know that it can’t.
You’re kidding, right? If Thing is an IThing, then all the Thing in a List<Thing> must also be IThing. Thus List<IThing>!
If we’re talking about sets, then yes. In our case, we’re talking about computers though, so no. The problem with this line of logic is that we’ve ignored the changes this would cause to our contract. How so?
A bit of make-believe
Suppose we have the following three classes.
Now at some point a bit later in our codebase, we decide to make a list of internet famous cats. Afterward, we rather innocently treat that list of cats as a list of mammals.
Okay, so far, so good. We know the compiler won’t actually let us do this, but so far there doesn’t appear to be an actual reason why. So, what would the problem here be? Well, it wouldn’t. At least not yet. The problem appears though, when we add one more line at the end. One innocent little line.
By now you likely see the problem, but if not, a quick hint for you: what is the actual type our list was initialized as? Yup. List<Cat>. Though this one place sees it as a List<Mammal>, everywhere that is accessing our list via ‘cats’ is expecting it to be a List<Cats>. Oops.
Couldn’t It Still Work Though?
Certainly there’s a good way around this other than just forbidding it though!
So what would be the correct behaviour? Hmm.
Compilation Fail on Mutation?
Living in a type safe world, that would seem like the most reasonable choice. Unfortunately, it’s easier said than done. Take the case of passing our List<T> into a method from another assembly.
Without knowing the implementation details for the class, there isn’t a way for us to guarantee that it’s safe. Even if we could guarantee that a given instance of an assembly didn’t mutate our object a new version of our dependency might.
Either way, at this point we’re making compilation dependent on behaviour, not on contract. We’ve brought .NET to flirtation with an odd variant of duck-typing.
Implicit Type Conversion?
While this might seem alluring, it too has problems. We’d need to make a copy of the list when passing it, depending on method signatures so that the list could actually be mutated. This would mean that sometimes passing a List<T> would appear to act like passing by value and other times like passing by reference. For the same exact calling syntax. It just goes down hill from there.
Runtime Fail on Mutation?
Maybe we would just accept that our Add should fail, since the underlying list couldn’t support it. This option comes at extremely great cost: we greatly complicate the ability to reason about the system.
In order to reason about flow, we’d have to keep in mind that any operation that can mutate state on our generics could fail.
List<T>.Add(new DerivedT()) could fail at runtime because DerivedT might not be valid in a list of T.
Do It Anyway?
Since we’re accessing our list through a variable of type List<Mammal>, maybe we should just let
mammals.Add(new Ferret()) work. Going this route, we’d create quite the type-unsafe surprise for everywhere accessing our list via our List<Cat>. Effectively List<Cat> would give us no more assurances than List<object>. The type safety system would be in utter shambles.
Don’t Allow It.
And then there is the option that .NET uses. The path of least surprise. Don’t. Just don’t. Saving one explicit call to
.Cast<Mammal>() isn’t worth the headache automatically doing it would bring.
I know we talked about List<T> most of the time, but the moral is really focused on generics as a whole. When you have a generic, such as a List<T>, you don’t just have a promise about
T list[int]. You have promises about everything, including
void list.Add(T). Once we’ve made those promises in our contract, .NET knows better than to actually let us break them.
So yes, the compiler actually is smart enough to recognize the relation between List<Thing> and List<IThing>. It’s also smart enough to recognize that they don’t share the same contract.