Hackle's blog
between the abstractions we want and the abstractions we get.
We'll continue to explore covariance and contravariance and see what happens if we nest them.
To recap the types we will be using:
class Creature { }
class Animal : Creature { }
class Dog : Animal { }
And a simple covariance example, a sub type can be used where a super type is required:
IEnumerable<Animal> animals = Enumerable.Empty<Dog>();
// or
Func<Animal> getAnimal = new Func<Dog>(() => new Dog());
and contravariant - a super type can be used in place of a sub type:
Action<Dog> actOnDog = new Action<Animal>(ani => { });
Action
sThis is all very well, except when we nest them. For example, Can Action<Action<Dog>>
be assigned to Action<Action<Animal>>
, or is it the other way around? Let's see,
Action<Action<Animal>> actOnActionOnAnimal = actionOnAnimal => ?;
As the type indicates, the parameter will be a Action<Animal>
which it's called actionOnAnimal
above. What can be done with this Action
? One very obvious thing to do, is to apply it to an Animal
.
Action<Action<Animal>> actOnActionOnAnimal = actionOnAnimal => actionOnAnimal(new Animal());
But if it accepts an Animal
, sure it accepts a Dog
? Indeed this works.
Action<Action<Animal>> actOnActionOnAnimal = actionOnAnimal => actionOnAnimal(new Dog());
However, it won't accept a Creature
as below
Action<Action<Animal>> actOnActionOnAnimal = actionOnAnimal => actionOnAnimal(new Creature());
// Error CS1503: Argument 1: cannot convert from 'Contra.Creature' to 'Contra.Animal'
As far as our types are concerned, a Creature
is not necessarily an Animal
.
So while Action<T>
is contravariant in T
, Action<Action<T>>
is covariant.
A potentially helpful analogy would be: suppose my job is to feed something, and my tool happens to be an "animal feeder". I need to use this tool to feed something - I can surely use it to feed animals, as its name indicates. I can also use it to feed dogs, as dogs are animals. But I can't use it to feed any creature. (A bit stretched I admit.)
Func
sHow about Func
?
Func<Func<Animal>, int> nestedFuncAnimal = f => 1;
Func<Func<Dog>, int> nestedFuncDog = f => 1;
nestedFuncDog = nestedFuncAnimal;
So while Func<T>
is covariant in T
, Func<Func<T>
is contravariant.
If we are to define Func<Func<Dog>, int>
,
Func<Func<Dog>, int> withDogGetter = dogGetter => dogToInt(dogGetter());
withDogGetter
needs to somehow turn a Dog
(as is returned from Func<Dog>
) into an int
, or, we could say, it's a Func<Dog, int>
in disguise. Func<Dog, int>
is actually contravariant in Dog
, which means we can use a Func<Animal, int>
.
Note I analysed Action
and Func
separately for ease of explanation. The reasoning would stay the same if we mix them up.
As nesting gets deeper and deeper - why anybody would want to do that I have no idea - it's harder and harder to come up with analogies, and even if it's possible, the analogies would be quite stretched to the point of being more confusing than helpful (try google for Monad analogies!)
At the same time it becomes harder and harder to understand if we resort only to analogies and intuition.
Luckily, some smart people made it easy for us, with the notation of positive and negative positions. Below is how it works taking Func
and Action
as examples.
T
is in a positive position if it's the return type, usually, the last type in the list of type parameters, such as in Func<T>
, or Func<int, T>
and so on.T
is in a negative position if it's not the return type, such as Func<T, int>
or Func<string, T, int>
. Note T
is negative in Action<T>
as Action
always returns void
which does not appear in the parameter list.In Func<int, T>
, T
is positive, as in a positive number, say +1
. In Func<T, int>
or Action<T>
, T
is negative as in -1
.
Now in Func<Func<int, T>, string>
, T
is positive in Func<int, T>
, but Func<int, T>
as a type is negative in Func<Func<int, T>, string>
. Now we multiply the numbers as in Algebra, +1 * -1 = -1
, so T
is negative in the whole type.
Or in Action<Action<T>>
: T
is negative in Action<T>
, which in turn is also negative in Action<Action<T>>
, when multiplied, -1 * -1 = 1
, so T
is positive in Action<Action<T>>
.
Try it out yourself - it's pretty wicked!
If you come this far you'd think this should be a thing for any static-type languages with support for generics (or parametric polymorphism). It's not always the case - for example, in TypeScript.
class Creature {}
class Animal extends Creature {}
class Dog extends Animal {}
type Func<T> = () => T;
let dogGetter : Func<Dog> = () => new Dog();
let animalGetter : Func<Animal> = () => new Animal();
let creatureGetter : Func<Creature> = () => new Creature();
animalGetter = dogGetter;
console.log(animalGetter());
animalGetter = creatureGetter;
console.log(animalGetter());
This would look like an issue for correctness but in practice it's not as simple as black or white - there is a nice explanation here which I find very reasonable and insightful.
CORRECTION
This is only true with default function type checking mode. When --strictFunctionTypes
is set, TypeScript checks function types for contravariance. See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html
Understanding co- and contra-variance can help us design correct types when generics are involved, as well as troubleshoot or avoid variance problems - such problems can be hard to penetrate to the unknowing.
Nesting of variance is not really for the faint of heart! It is one of these things that are nice to know, but might not come up very often, or matter that much in real-life application development for most of us. In fact on the rare occasions that I had to use such concepts, I usually find it's best to steer away from them, and the solution would be cleaner and better off.