До сих пор не можете спать, пытаясь осмыслить понятия ковариантности и контравариантности? Чувствуете, как они дышат вам в спину, но когда оборачиваетесь ничего не находите? Есть решение!
Меня зовут Никита, и сегодня мы попытаемся заставить механизм в голове работать корректно. Вас ожидает максимально доступное рассмотрение темы вариантности в примерах. Добро пожаловать под кат.
Брифинг
Вариантность в данном посте разбирается безотносительно к какому-либо языку программирования. Примеры в разделе практики написаны на псевдоязыке (он чудом оказался похож на C#) и поэтому не обязаны компилироваться вашим любимым компилятором. Приступим.
Хитрости терминологии
В документации, технической литературе и других источниках вы могли встречаться с различными названиями для явлений вариантности. Больше не стоит пугаться и путаться.
Термины ковариантность и ковариация эквивалентны (по крайней мере в программировании). Более того, термины контравариантность и контравариация также эквивалентны. Так, например, термины ковариантность и контравариантность используется в Википедии и у Троелсена (в переводе). А термины ковариация и контравариация встречаются, например, на MSDN и у Скита (в переводе).
В английском языке всё проще — covariance и contravariance.
Теория
Вариантность — перенос наследования исходных типов на производные от них типы. Под производными типами понимаются контейнеры, делегаты, обобщения, а не типы, связанные отношениями "предок-потомок". Различными видами вариантности являются ковариантность, контравариантность и инвариантность.
Ковариантность — перенос наследования исходных типов на производные от них типы в прямом порядке.
Контравариантность — перенос наследования исходных типов на производные от них типы в обратном порядке.
Инвариантность — ситуация, когда наследование исходных типов не переносится на производные.
Если у производных типов наблюдается ковариантность, говорят, что они ковариантны исходному типу. Если у производных типов наблюдается контравариантность, говорят, что они контравариантны исходному типу. Если у производных типов не наблюдается ни того, ни другого, говорят, что они инвариантны.
Вот и всё, что нужно знать. Конечно, тем кто первый раз сталкивается с вариантностью, трудно вникнуть. Поэтому рассмотрим конкретные примеры.
Практика
Для чего всё это?
Вся суть вариантности состоит в использовании в производных типах преимуществ наследования. Известно, что если два типа связаны отношением "предок-потомок", то объект потомка может храниться в переменной типа предка. На практике это значит, что мы можем использовать для каких-либо операций объекты потомка вместо объектов предка. Тем самым, можно писать более гибкий и короткий код для выполнения действий поддерживаемых разными потомками с общим предком.
Исходная иерархия и производные типы
Для начала опишем иерархию типов, которой будем оперировать. Вверху иерархии у нас находится Device (устройство), потомками которого являются Mouse (мышь), Keyboard (клавиатура). У Mouse в свою очередь тоже есть потомки — WiredMouse (проводная мышь), WirelessMouse (беспроводная мышь).

Все любят контейнеры. На их примере наиболее просто объяснить, что подразумевается под производными типами. Если говорить о списках как производных типах, то для типа Device производным будет
List<Device> (список устройств). Аналогично, для типа Keyboard производным будет List<Keyboard> (список клавиатур). Думаю, если и были сомнения, то теперь их нет.
Классическая ковариантность
Ковариантность также легче изучать на примере контейнеров. Для этого выделим часть иерархии (ветвь) — Keyboard : Device (клавиатура является устройством, клавиатура частный случай устройства). Опять возьмём списки и построим ковариантную производную ветвь — List<Keyboard> : List<Device> (список клавиатур является частным случаем списка устройств). Как видим, наследование передалось в прямом порядке.

Рассмотрим пример кода. Есть функция, которая принимает список устройств List<Device> и совершает над ними какие-то манипуляции. Как вы уже догадались, в эту функцию можно передать список клавиатур List<Keyboard>:
void DoSmthWithDevices(List<Device> devices) { /* действия с элементами списка */ }
...
List<Keyboard> keyboards = new List<Keyboard> { /* заполнение списка */ };
DoSmthWithDevices(keyboards);Классическая контравариантность
Каноническим для изучения контравариантности является рассмотрение её на основе делегатов. Допустим, у нас есть обобщённый делегат:
delegate void Action<T>(T something);Для исходного типа Device производным будет Action<Device>, а для Keyboard — Action<Keyboard>. Полученные делегаты могут представлять функции, которые выполняют какие-то действия над устройством или мышью соответственно. Для ветви Keyboard : Device построим производную контравариантную ветвь — Action<Device> : Action<Keyboard> (действие над устройством является частным случаем действия над клавиатурой — звучит странно, но так и есть). Если можно нажать клавишу на клавиатуре, то это не значит, что и на устройстве можно нажать её (оно может не иметь понятия о том, что такое клавиша). Но если можно подключить устройство, то можно этим же способом (методом, функцией) подключить и клавиатуру. Как видим, наследование передалось в обратном порядке.

Из выше сказанного логично, что если функция может выполнить, что-то над устройством, то она может выполнить это и над клавиатурой. Это значит, мы можем передать объект делегата Action<Device> в функцию, принимающую объект делегата Action<Keyboard>. Рассмотрим в коде:
void DoSmthWithKeyboard(Action<Keyboard> actionWithKeyboard) { /* выполнение actionWithKeyboard над клавиатурой */ }
...
Action<Device> actionWithDevice = device => device.PlugIn();
DoSmthWithKeyboard(actionWithDevice);Немного инвариантности
Если производные типы инвариантны к исходным типам, то для ветви Keyboard : Device не образуется ни ковариантной (List<Keyboard> : List<Device>), ни контравариантной (Action<Device> : Action<Keyboard>) ветви. Это значит, что нет никакой связи между производными типами. Как видим, наследование не переносится.

А что если?
Неочевидная ковариантность
Делегаты типа Action<T> могут быть ковариантны. Это значит, что для ветви Keyboard : Device образуется ковариантная ветвь — Action<Keyboard> : Action<Device>. Таким образом, в функцию, принимающую объект делегата Action<Device>, можно передавать объект делегата Action<Keyboard>.
void DoSmthWithDevice(Action<Device> actionWithDevice) { /* выполнение actionWithDevice над устройством */ }
...
Action<Keyboard> actionWithKeyboard = keyboard => ((Device)keyboard).PlugIn();
DoSmthWithDevice(actionWithKeyboard);Неочевидная контравариантность
Контейнеры могут быть контравариантны. Это значит, что для ветви Keyboard : Device образуется контравариантная ветвь — List<Device> : List<Keyboard>. Таким образом, в функцию, принимающую List<Keyboard>, можно передавать List<Device>:
void FillListWithKeyboards(List<Keyboard> keyboards) { /* заполнение списка клавиатур */ }
...
List<Devices> devices = new List<Devices>();
FillListWithKeyboards(devices);Сакральный смысл
Рассмотренные выше экзотические виды вариантности имеют, разве что, академическую ценность. Сложно придумать реальную задачу, которая легче решается при наличии такого рода возможностей. Стоит запомнить, что ковариантность и контравариантность могут вызывать ошибки времени выполнения. Для их устранения требуется вводить определённые ограничения. Ко��пиляторы, как правило, такие ограничения не вводят.
Безопасность для контейнеров
Если производный тип ковариантен, то для обеспечения безопасности контейнер должен быть read only. В противном случае, остаётся возможность записать в List<Keyboard> объект неверного типа (Device, Mouse и другие) через приведение к List<Device>:
List<Device> devices = new List<Keyboard>();
devices.Add(new Device()); // ошибка времени выполненияЕсли производный тип контравариантен, то для обеспечения безопасности контейнер должен быть write only. В противном случае, остаётся возможность считывания из List<Device> объекта неверного типа (Keyboard, Mouse и других) через приведение к соответствующему списку (List<Keyboard>, List<Mouse> и другим):
List<Keyboard> keyboards = new List<Device>();
keyboards.Add(new Keyboard());
keyboards[0].PressSpace(); // ошибка времени выполненияДвойные стандарты для делегатов
Разумным для делегатов является ковариантность для выходного значения и контравариантность для входных параметров (исключая передачу по ссылке). В случае соблюдения данных условий ошибок времени выполнения не возникает.
Дебрифинг
Представленных примеров достаточно для понимания принципов работы вариантности. Данные о её поддержке разными типами вашего любимого языка ищите в соответствующей спецификации. Если что-то пошло не так — закройте глаза, выдохните и выпейте чай. После этого попытайтесь снова. Спасибо за внимание.
UDP
Возможно более правильным определением вариантности является предложенное Эриком Липпертом. Спасибо Alex_sik за ссылку на статью.
Совместимость присваивания, assignment compatibility — это возможность присвоить значение более частного типа совместимой переменной более общего типа.
Вариантность — это сохранение совместимости присваивания исходных типов у производных типов.
Ковариантность — это сохранение совместимости присваивания исходных типов у производных в прямом порядке.
Контравариантность — это сохранение совместимости присваивания исходных типов у производных в обратном порядке.
