Ковариантность и контравариантность (программирование)

Материал из Википедии — свободной энциклопедии
Перейти к навигации Перейти к поиску

Ковариа́нтность и контравариа́нтность[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++ начиная со стандарта 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 начиная с J2SE 5.0. В параметрах методов ковариантности нет: для перекрытия виртуального метода типы его параметров должны совпадать с определением в родительском классе, иначе вместо перекрытия будет определён новый перегруженный метод с этими параметрами.

Массивы в Java ковариантны с самой первой версии, когда в языке ещё не было обобщенных типов. (Если бы этого не было, то для использования, например, библиотечного метода, принимающего массив объектов Object[], для работы с массивом строк String[], требовалось бы его сначала скопировать в новый массив Object[].) Поскольку, как было сказано выше, при записи элемента в такой массив можно обойти контроль типов, в JVM существует дополнительный контроль во время выполнения, генерирующий исключение при записи некорректного элемента.

Обобщённые типы в Java инвариантны, поскольку вместо создания универсального метода, работающего с Object’ами, можно его параметризовать, превратив в обобщённый метод и сохранив контроль типов.

Вместе с тем в Java можно реализовать своего рода ко- и контравариантность обобщенных типов, используя символ-джокер и уточняющие спецификаторы: List<? extends Animal> будет ковариантен подставляемому типу, а List<? super Animal> — контравариантен.

В языке 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>, и т. п.

Примечания

[править | править код]
  1. В прежней редакции документации Microsoft на русском языке Архивная копия от 24 декабря 2015 на Wayback Machine использовались термины ковариация и контравариация.
  2. Здесь и далее слово «производный» не означает «наследник».
  3. Castagna, 1995, Abstract.
  4. On covariance and C++ templates (8 февраля 2013). Дата обращения: 20 июня 2013. Архивировано 28 июня 2013 года.
  5. Eric Lippert. Covariance and Contravariance in C#, Part Two (17 октября 2007). Дата обращения: 22 июня 2013. Архивировано 28 июня 2013 года.

Литература

[править | править код]