Pull to refresh

Вариантность в программировании

Reading time 6 min
Views 109K

До сих пор не можете спать, пытаясь осмыслить понятия ковариантности и контравариантности? Чувствуете, как они дышат вам в спину, но когда оборачиваетесь ничего не находите? Есть решение!


Меня зовут Никита, и сегодня мы попытаемся заставить механизм в голове работать корректно. Вас ожидает максимально доступное рассмотрение темы вариантности в примерах. Добро пожаловать под кат.


Брифинг


Вариантность в данном посте разбирается безотносительно к какому-либо языку программирования. Примеры в разделе практики написаны на псевдоязыке (он чудом оказался похож на 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>, а для KeyboardAction<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 — это возможность присвоить значение более частного типа совместимой переменной более общего типа.
Вариантность — это сохранение совместимости присваивания исходных типов у производных типов.
Ковариантность — это сохранение совместимости присваивания исходных типов у производных в прямом порядке.
Контравариантность — это сохранение совместимости присваивания исходных типов у производных в обратном порядке.

Tags:
Hubs:
+42
Comments 22
Comments Comments 22

Articles