Ковариантность и контравариантность (программирование)
Ковариа́нтность и контравариа́нтность[1] в программировании — способы переноса наследования типов на производные[2] от них типы — контейнеры, обобщённые типы, делегаты и т. п. Термины произошли от аналогичных понятий теории категорий «ковариантный» и «контравариантный функтор».
Определения
Ковариантностью называется сохранение иерархии наследования исходных типов в производных типах в том же порядке. Так, если класс Cat
наследуется от класса Animal
, то естественно полагать, что перечисление IEnumerable<Cat>
будет потомком перечисления IEnumerable<Animal>
. Действительно, «список из пяти кошек» — это частный случай «списка из пяти животных». В таком случае говорят, что тип (в данном случае обобщённый интерфейс) IEnumerable<T>
ковариантен своему параметру-типу T.
Контравариантностью называется обращение иерархии исходных типов на противоположную в производных типах. Так, если класс String
наследуется от класса Object
, а делегат Action<T>
определён как метод, принимающий объект типа T, то Action<Object>
наследуется от делегата Action<String>
, а не наоборот. Действительно, если «все строки — объекты», то «всякий метод, оперирующий произвольными объектами, может выполнить операцию над строкой», но не наоборот. В таком случае говорят, что тип (в данном случае обобщённый делегат) Action<T>
контравариантен своему параметру-типу T.
Отсутствие наследования между производными типами называется инвариантностью.
Контравариантность позволяет корректно устанавливать тип при создании подтипов (subtyping), то есть, установить множество функций, позволяющее заменить другое множество функций в любом контексте. В свою очередь, ковариантность характеризует специализацию кода, то есть замену старого кода новым в определённых случаях. Таким образом, ковариантность и контравариантность являются независимыми механизмами типобезопасности, не исключающими друг друга, и могут и должны применяться в объектно-ориентированных языках программирования[3].
Использование
Массивы и другие контейнеры
В контейнерах, допускающих запись объектов, ковариантность считается нежелательной, поскольку она позволяет обходить контроль типов. В самом деле, рассмотрим ковариантные массивы. Пусть классы Cat
и Dog
наследуют от класса Animal
(в частности, переменной типа Animal
можно присвоить переменную типа Cat
или Dog
). Создадим массив Cat[]
. Благодаря контролю типов в этот массив можно записывать лишь объекты типа Cat
и его потомков. Затем присвоим ссылку на этот массив переменной типа Animal[]
(ковариантность массивов это позволяет). Теперь в этот массив, известный уже как Animal[]
, запишем переменную типа Dog
. Таким образом, в массив Cat[]
мы записали Dog
, обойдя контроль типов. Поэтому контейнеры, разрешающие запись, желательно делать инвариантными.
Также, контейнеры, разрешающие запись, могут реализовывать два независимых интерфейса, ковариантный Producer<T> и контравариантный Consumer<T>, в этом случае вышеописанный обход контроля типов сделать не удастся.
Поскольку контроль типов может нарушаться лишь при записи элемента в контейнер, то для неизменяемых коллекций и итераторов ковариантность безопасна и даже полезна. Например, с её помощью в языке C# любому методу, принимающему аргумент типа IEnumerable<Object>
, можно передавать любую коллекцию любого типа, например IEnumerable<String>
или даже List<String>
.
Если же в данном контексте контейнер используется, наоборот, только для записи в него, а чтение отсутствует, то он может быть контравариантным. Так, если есть гипотетический тип WriteOnlyList<T>
, наследующий от List<T>
и запрещающий в нём операции чтения, и функция с параметром WriteOnlyList<Cat>
, куда она записывает объекты типа Cat
, то передавать ей List<Animal>
или List<Object>
безопасно — туда она ничего, кроме объектов класса-наследника, не запишет, а пытаться читать другие объекты не будет.
Функциональные типы
В языках с функциями первого класса существуют обобщённые функциональные типы и переменные-делегаты. Для обобщённых функциональных типов полезна ковариантность по возвращаемым типам и контравариантность по аргументам. Так, если делегат задан как «функция, принимающая String и возвращающая Object», то в него можно записать и функцию, принимающую Object и возвращающую String: если функция способна принимать любой объект, она может принимать и строку; а из того, что результатом функции является строка, следует, что функция возвращает объект.
Реализация в языках
C++
C++ начиная со стандарта 1998 года поддерживает ковариантные типы возврата в перекрытых виртуальных функциях:
class X {};
class A
{
public:
virtual X* f() { return new X; }
};
class Y : public X {};
class B : public A
{
public:
virtual Y* f() { return new Y; } // ковариантность позволяет задать в перекрытом методе уточнённый тип возврата
};
Указатели в C++ ковариантны: например, указателю на базовый класс можно присвоить указатель на дочерний класс.
Шаблоны C++, вообще говоря, инвариантны, отношения наследования классов-параметров на шаблоны не переносится. Например, ковариантный контейнер vector<T>
позволял бы нарушать контроль типов. Однако при помощи параметризованных конструкторов копирования и операторов присваивания можно создать умный указатель, ковариантный своему параметру-типу[4].
Java
Ковариантность типов возврата методов реализована в Java начиная с J2SE 5.0. В параметрах методов ковариантности нет: для перекрытия виртуального метода типы его параметров должны совпадать с определением в родительском классе, иначе вместо перекрытия будет определён новый перегруженный метод с этими параметрами.
Массивы в Java ковариантны с самой первой версии, когда в языке ещё не было обобщенных типов. (Если бы этого не было, то для использования, например, библиотечного метода, принимающего массив объектов Object[]
, для работы с массивом строк String[]
, требовалось бы его сначала скопировать в новый массив Object[]
.) Поскольку, как было сказано выше, при записи элемента в такой массив можно обойти контроль типов, в JVM существует дополнительный контроль во время выполнения, генерирующий исключение при записи некорректного элемента.
Обобщённые типы в Java инвариантны, поскольку вместо создания универсального метода, работающего с Object’ами, можно его параметризовать, превратив в обобщённый метод и сохранив контроль типов.
Вместе с тем в Java можно реализовать своего рода ко- и контравариантность обобщенных типов, используя символ-джокер и уточняющие спецификаторы: List<? extends Animal>
будет ковариантен подставляемому типу, а List<? super Animal>
— контравариантен.
C#
В языке C#, начиная с первой его версии, массивы ковариантны. Это было сделано для совместимости с языком Java[5]. При попытке записать в массив элемент неверного типа выбрасывается исключение во время выполнения.
Обобщённые классы и интерфейсы, появившиеся в C# 2.0, стали, как и в Java, инвариантными по типу-параметру.
С введением обобщённых делегатов (параметризированных по типам аргументов и возвращаемым типам), язык позволил автоматическое преобразование обычных методов к обобщённым делегатам с ковариантностью по возвращаемым типам и контравариантностью по типам аргументов. Поэтому в C# 2.0 стал возможен код следующего вида:
void ProcessString(String s) { /* ... */}
void ProcessAnyObject(Object o) { /* ... */ }
String GetString() { /* ... */ }
Object GetAnyObject() { /* ... */ }
//...
Action<String> process = ProcessAnyObject;
process(myString); // легальное действие
Func<Object> getter = GetString;
Object obj = getter(); // легальное действие
однако код Action<Object> process = ProcessString;
некорректен и даёт ошибку компиляции, иначе этот делегат можно было бы потом вызвать как process(5)
, передавая Int32 в ProcessString.
В C# 2.0 и 3.0 этот механизм позволял лишь записывать простые методы в обобщённые делегаты и не мог делать автоматическое преобразование одних обобщённых делегатов в другие. Иначе говоря, код
Func<String> f1 = GetString;
Func<Object> f2 = f1;
в этих версиях языка не компилировался. Таким образом, обобщённые делегаты в C# 2.0 и 3.0 всё ещё были инвариантными.
В C# 4.0 это ограничение было снято, и начиная с этой версии код f2 = f1
в примере выше стал работать.
Кроме того, в 4.0 стало возможным задавать вариантность параметров обобщённых интерфейсов и делегатов явным образом. Для этого используются ключевые слова out
и in
соответственно. Поскольку в обобщённом типе реальное использование типа-параметра известно лишь его автору, к тому же оно может меняться в процессе разработки, это решение обеспечивает наибольшую гибкость без ущерба для надёжности контроля типов.
Некоторые библиотечные интерфейсы и делегаты были переопределены в C# 4.0 с использованием этих возможностей. Например, интерфейс IEnumerable<T>
отныне стал определяться как IEnumerable<out T>
, интерфейс IComparable<T>
— как IComparable<in T>
, делегат Action<T>
— как Action<in T>
, и т. п.
Примечания
- В документации Microsoft на русском языке используются термины ковариация и контравариация.
- Здесь и далее слово «производный» не означает «наследник».
- Castagna, 1995, Abstract.
- On covariance and C++ templates (8 февраля 2013). Дата обращения: 20 июня 2013. Архивировано 28 июня 2013 года.
- Eric Lippert. Covariance and Contravariance in C#, Part Two (17 октября 2007). Дата обращения: 22 июня 2013. Архивировано 28 июня 2013 года.
Литература
- Castagna, Giuseppe. Covariance and Contravariance: Conflict Without a Cause (англ.) // ACM Trans. Program. Lang. Syst.. — ACM, 1995. — Vol. 17, no. 3. — P. 431-447. — ISSN 0164-0925. — doi:10.1145/203095.203096.