The different forms of variance are only needed because you have two independent axes of polymorphism going on:
Subtype polymorphism: A function that is defined to operate on values of type Base
can also operate on values of type Derived
, if Derived
is a subtype of Base
. Within the function, only the operations exposed by type Base
may be used (unless reflection or run-time type inspection are used).
Parametric polymorphism: A generic function that is parameterized over a type T
(e.g. void Foo<T>()
) can operate on values of type A
, B
, etc., if those types match the where
clause restrictions (if any) on the type of T
. Within the function, only operations which are valid for the entire bounds of the type parameter can be used. (So if there are no bounds on T
, then only operations that are guaranteed to be valid for all objects may be used.)
Each axis makes sense on its own. But you run into problems when you try to act polymorphically in both axes at once.
For example, given classes Base
, Derived
, and SomethingElse
:
- A method
void Foo(Base b)
can accept a value of type Derived
as a parameter, by subtype polymorphism.
- The method
void Add(T item)
on the type List<T>
can accept values of any of Base
or SomethingElse
, etc, provided the type has been instantiated as List<Base>
, List<SomethingElse>
, etc.
- The method
void Add(T item)
on the type List<T>
, when the type has been instantiated as List<Base>
, is treated as if it were declared as void Add(Base item)
, and thus can accept a value of type Base
.
- Additionally, the method
void Add(T item)
on the type List<T>
, when the type has been instantiated as List<Base>
, can accept a value of type Derived
, due to the combination of both parametric polymorphism (the parameterized type of Base
) and subtype polymorphism (the is-a relationship between Derived
and Base
).
However, given a function void Foo(List<Base> list)
, and a value of type List<Derived>
, the method void Add(T item)
on the type List<Derived>
is treated as if it were declared as void Add(Derived item)
, which is incompatible with the method void Add(T item)
on the type List<Base>
(which is treated as if were declared as void Add(Base item)
).
This is because the value which is required for the formal parameter list
of void Foo(List<Base> list)
must be an object that has an Add()
method that is able to accept values of type Base
, but the value which is being supplied (a value of type List<Derived>
) does not accept values of type Base
in its Add()
method!
Now, if you are only dealing with one axis of polymorphism at a time, then this isn't a problem:
- If all you have is subtype polymorphism, then we're just like the type system of C# 1.0: there are no generics, and thus no way for problems of covariance and contravariance to come up. (Actually, the problems can come up, it's just that they appear at run time, not compile time. For example, see
ArrayTypeMismatchException
.)
Conversely, if all you have is parametric polymorphism (a.k.a. generics), then it is never allowed to pass a type Derived
to a function that expects a value of type Base
, since functions cannot act polymorphically with respect to a subtype relationship.
(While this restriction seems hopelessly restrictive to someone coming from an OO background, it's actually quite common in some functional programming languages. It does change how you go about structuring your code, however.)
Now, the problems of covariance and contravariance can occur just as easily in a dynamically typed language as in a statically typed one. The only difference is that dynamic language code is necessarily more resilient to unexpected types.
For example, in a Python program, you might have a class that represents a list of strings, and an add(item)
method that adds an item to the list. However, the type system does not enforce the invariant of the class -- any caller can pass a value of any type they like to the add()
method. Therefore, the code must either defend against illegal values somehow (e.g. by doing a check in the add()
method to ensure that a non-string doesn't get added), or cope gracefully with finding a non-string in its internal storage.
These kinds of problems are exactly what covariance and contravariance are about: cases where code can be "surprised" by the type of values. In a statically type-checked language, the type checker is responsible for proving that code can never be surprised by the type of a value: if you have a local variable that was declared with type string
, it will never contain a value of type double
, no matter what. If, due to the way you're using different forms of polymorphism together, the type checker can no longer prove that, it will reject your program with a type error.
Basically, this is the trade-off of static versus dynamic type checking: a static type checker can prove that certain kinds of undesirable behavior are not present in your program (in this case, "surprising" types of values), but only at the cost of rejecting some interesting programs. That is, a type checker is by necessity conservative.
In order to make type checkers more flexible (and thus allow more interesting programs to be checked by them), modern languages like C# have introduced new language constructs (like the in
and out
keywords to identify covariant and contravariant type parameters), which allow more fine-grained control over the type checker's proof. Generally, this is considered a good thing: it allows us to have the advantage of a type checker proving useful things about our program, while still allowing the maximum number of interesting programs through.