Лекция
Привет, Вы узнаете о том , что такое вариантность, Разберем основные их виды и особенности использования. Еще будет много подробных примеров и описаний. Для того чтобы лучше понимать что такое вариантность, ковариантность, контравариантность, инвариантность , настоятельно рекомендую прочитать все из категории Объектно-ориентированное программирование ООП.
вариантность разбирается безотносительно к какому-либо языку программирования. Примеры в разделе практики написаны на псевдоязыке и поэтому не обязаны компилироваться компилятором.
В документации, технической литературе и других источниках вы могли встречаться с различными названиями для явлений вариантности.
Термины ковариантность и контравариантность были введены Сильвестром в 1853 году для исследований по алгебраической теории инвариантов.
Термины ковариантность и ковариация эквивалентны (по крайней мере в программировании). Более того, термины контравариантность и контравариация также эквивалентны. Так, например, термины ковариантность и контравариантность используется в Википедии и у Троелсена (в переводе).
А термины ковариация и контравариация встречаются, например, на MSDN и у Скита (в переводе).
Вариантность — перенос наследования исходных типов на производные от них типы. Под производными типами понимаются контейнеры, делегаты, обобщения, а не типы, связанные отношениями "предок-потомок". Различными видами вариантности являются ковариантность, контравариантность и инвариантность .
Ковариантность (covariance)— перенос наследования исходных типов на производные от них типы в прямом порядке.
Контравариантность (contravariance)— перенос наследования исходных типов на производные от них типы в обратном порядке.
Инвариантность — ситуация, когда наследование исходных типов не переносится на производные.
Возможно более правильным определением вариантности является предложенное Эриком Липпертом.
Совместимость присваивания, assignment compatibility — это возможность присвоить значение более частного типа совместимой переменной более общего типа.
Вариантность — это сохранение совместимости присваивания исходных типов у производных типов.
Ковариантность — это сохранение совместимости присваивания исходных типов у производных в прямом порядке.
Контравариантность — это сохранение совместимости присваивания исходных типов у производных в обратном порядке.
Ковариа́нтность и контравариа́нтность — используемые в математике (линейной алгебре, дифференциальной геометрии, тензорном анализе) и в физике понятия, характеризующие то, как тензоры (скаляры, векторы, операторы, билинейные формы и т. д.) изменяются при преобразованиях базисов в соответствующих пространствах или многообразиях. Контравариантными называют «обычные» компоненты, которые при смене базиса пространства изменяются с помощью преобразования, обратного преобразованию базиса. Ковариантными — те, которые изменяются так же, как и базис.
Связь между ковариантными и контравариантными координатами тензора возможна только в пространствах, где задан метрический тензор (не следует путать с метрическим пространством).
Термины ковариантность и контравариантность были введены Сильвестром в 1853 году для исследований по алгебраической теории инвариантов.
Ковариантностью называется сохранение иерархии наследования исходных типов в производных типах в том же порядке. Так, если класс Cat
наследуется от класса Animal
, то естественно полагать, что перечисление IEnumerable<Cat>
будет потомком перечисления IEnumerable<Animal>
. Действительно, «список из пяти кошек» — это частный случай «списка из пяти животных». В таком случае говорят, что тип (в данном случае обобщенный интерфейс) IEnumerable<T>
ковариантен своему параметру-типу T.
Контравариантностью называется обращение иерархии исходных типов на противоположную в производных типах. Так, если класс String
наследуется от класса Object
, а делегат Action<T>
определен как метод, принимающий объект типа T, то Action<Object>
наследуется от делегата Action<String>
, а не наоборот. Действительно, если «все строки — объекты», то «всякий метод, оперирующий произвольными объектами, может выполнить операцию над строкой», но не наоборот. В таком случае говорят, что тип (в данном случае обобщенный делегат) Action<T>
контравариантен своему параметру-типу T.
Отсутствие наследования между производными типами называется инвариантностью.
Контравариантность позволяет корректно устанавливать тип при создании подтипов (subtyping), то есть, установить множество функций, позволяющее заменить другое множество функций в любом контексте. В свою очередь, ковариантность характеризует специализацию кода, то есть замену старого кода новым в определенных случаях. Таким образом, ковариантность и контравариантность являются независимыми механизмами типобезопасности, не исключающими друг друга, и могут и должны применяться в объектно-ориентированных языках программирования .
Хорошей визуализацией понятий вариантности является следующий рисунок:
Если у производных типов наблюдается ковариантность, говорят, что они ковариантны исходному типу. Если у производных типов наблюдается контравариантность, говорят, что они контравариантны исходному типу. Если у производных типов не наблюдается ни того, ни другого, говорят, что они инвариантны.
Рассмотрим конкретные примеры.
Какое назначение этого?
Вся суть вариантности состоит в использовании в производных типах преимуществ наследования. Известно, что если два типа связаны отношением "предок-потомок", то объект потомка может храниться в переменной типа предка. На практике это значит, что мы можем использовать для каких-либо операций объекты потомка вместо объектов предка. Тем самым, можно писать более гибкий и короткий код для выполнения действий поддерживаемых разными потомками с общим предком.
Исходная иерархия и производные типы
Для начала опишем иерархию типов, которой будем оперировать. Вверху иерархии у нас находится Device (устройство), потомками которого являются Mouse (мышь), Keyboard (клавиатура). У Mouse в свою очередь тоже есть потомки — WiredMouse (проводная мышь), WirelessMouse (беспроводная мышь).
Все любят контейнеры. На их примере наиболее просто объяснить, что подразумевается под производными типами. Если говорить о списках как производных типах, то для типа Device производным будет
List (список устройств). Аналогично, для типа Keyboard производным будет List (список клавиатур). Думаю, если и были сомнения, то теперь их нет.
Классическая ковариантность
Ковариантность также легче изучать на примере контейнеров. Для этого выделим часть иерархии (ветвь) — Keyboard : Device (клавиатура является устройством, клавиатура частный случай устройства). Опять возьмем списки и построим ковариантную производную ветвь — List : List (список клавиатур является частным случаем списка устройств). Как видим, наследование передалось в прямом порядке.
Рассмотрим пример кода. Есть функция, которая принимает список устройств List и совершает над ними какие-то манипуляции. Как вы уже догадались, в эту функцию можно передать список клавиатур List:
void DoSmthWithDevices(List devices) { /* действия с элементами списка */ } ... List keyboards = new List { /* заполнение списка */ }; DoSmthWithDevices(keyboards);
Классическая контравариантность
Каноническим для изучения контравариантности является рассмотрение ее на основе делегатов. Допустим, у нас есть обобщенный делегат:
delegate void Action(T something);
Для исходного типа Device производным будет Action, а для Keyboard — Action. Полученные делегаты могут представлять функции, которые выполняют какие-то действия над устройством или мышью соответственно. Для ветви Keyboard : Device построим производную контравариантную ветвь — Action : Action (действие над устройством является частным случаем действия над клавиатурой — звучит странно, но так и есть). Если можно нажать клавишу на клавиатуре, то это не значит, что и на устройстве можно нажать ее (оно может не иметь понятия о том, что такое клавиша). Но если можно подключить устройство, то можно этим же способом (методом, функцией) подключить и клавиатуру. Как видим, наследование передалось в обратном порядке.
Из выше сказанного логично, что если функция может выполнить, что-то над устройством, то она может выполнить это и над клавиатурой. Это значит, мы можем передать объект делегата Action в функцию, принимающую объект делегата Action. Рассмотрим в коде:
void DoSmthWithKeyboard(Action actionWithKeyboard) { /* выполнение actionWithKeyboard над клавиатурой */ } ... Action actionWithDevice = device => device.PlugIn(); DoSmthWithKeyboard(actionWithDevice);
Немного инвариантности
Если производные типы инвариантны к исходным типам, то для ветви Keyboard : Device не образуется ни ковариантной (List : List), ни контравариантной (Action : Action) ветви. Это значит, что нет никакой связи между производными типами. Как видим, наследование не переносится.
А что если?
Неочевидная ковариантность
Делегаты типа Action могут быть ковариантны. Это значит, что для ветви Keyboard : Device образуется ковариантная ветвь — Action : Action. Таким образом, в функцию, принимающую объект делегата Action, можно передавать объект делегата Action.
void DoSmthWithDevice(Action actionWithDevice) { /* выполнение actionWithDevice над устройством */ } ... Action actionWithKeyboard = keyboard => ((Device)keyboard).PlugIn(); DoSmthWithDevice(actionWithKeyboard);
Неочевидная контравариантность
Контейнеры могут быть контравариантны. Это значит, что для ветви Keyboard : Device образуется контравариантная ветвь — List : List. Таким образом, в функцию, принимающую List, можно передавать List:
void FillListWithKeyboards(List keyboards) { /* заполнение списка клавиатур */ } ... List devices = new List(); FillListWithKeyboards(devices);
Рассмотренные выше экзотические виды вариантности имеют, разве что, академическую ценность. Сложно придумать реальную задачу, которая легче решается при наличии такого рода возможностей. Стоит запомнить, что ковариантность и контравариантность могут вызывать ошибки времени выполнения. Для их устранения требуется вводить определенные ограничения. Компиляторы, как правило, такие ограничения не вводят.
Безопасность для контейнеров
Если производный тип ковариантен, то для обеспечения безопасности контейнер должен быть read only. В противном случае, остается возможность записать в List объект неверного типа (Device, Mouse и другие) через приведение к List:
List devices = new List(); devices.Add(new Device()); // ошибка времени выполнения
Если производный тип контравариантен, то для обеспечения безопасности контейнер должен быть write only. В противном случае, остается возможность считывания из List объекта неверного типа (Keyboard, Mouse и других) через приведение к соответствующему списку (List, List и другим):
List keyboards = new List(); keyboards.Add(new Keyboard()); keyboards .PressSpace(); // ошибка времени выполнения
Двойные стандарты для делегатов
Разумным для делегатов является ковариантность для выходного значения и контравариантность для входных параметров (исключая передачу по ссылке). В случае соблюдения данных условий ошибок времени выполнения не возникает.
Дебрифинг
Представленных примеров достаточно для понимания принципов работы вариантности. Данные о ее поддержке разными типами вашего любимого языка ищите в соответствующей спецификации.
В контейнерах, допускающих запись объектов, ковариантность считается нежелательной, поскольку она позволяет обходить контроль типов. В самом деле, рассмотрим ковариантные массивы. Пусть классы Cat
и Dog
наследуют от класса Animal
(в частности, переменной типа Animal
можно присвоить переменную типа Cat
или Dog
). Об этом говорит сайт https://intellect.icu . Создадим массив 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: если функция способна принимать любой объект, она может принимать и строку; а из того, что результатом функции является строка, следует, что функция возвращает объект.
Когда подкласс переопределяет метод в суперклассе, компилятор должен проверить, что метод переопределения имеет правильный тип. В то время как некоторые языки требуют, чтобы тип точно соответствовал типу в суперклассе (инвариантность ), также типобезопасно разрешить заменяющему методу иметь «лучший» тип. По обычному правилу выделения подтипов для типов функций это означает, что метод переопределения должен возвращать более конкретный тип (ковариация типа возвращаемого значения) и принимать более общий аргумент (контравариантность типа параметра). В нотации UML возможности следующие:
Вариант и переопределение метода: обзор
В качестве конкретного примера предположим, что мы пишем класс для моделирования приюта для животных . Мы предполагаем, что Cat
это подкласс Animal
, и что у нас есть базовый класс (с использованием синтаксиса Java)
Теперь возникает вопрос: если мы создаем подкласс AnimalShelter
, какие типы нам разрешено отдавать в getAnimalForAdoption
и putAnimal
?
В языке, который допускает ковариантные возвращаемые типы , производный класс может переопределить getAnimalForAdoption
метод для возврата более конкретного типа:
Среди основных объектно-ориентированных языков Java , C ++ и C # (начиная с версии 9.0 ) поддерживают ковариантные возвращаемые типы. Добавление ковариантного возвращаемого типа было одной из первых модификаций языка C ++, одобренной комитетом по стандартам в 1998 году. Scala и D также поддерживают ковариантные возвращаемые типы.
Точно так же безопасно по типу разрешить переопределяющему методу принимать более общий аргумент, чем метод в базовом классе:
Только несколько объектно-ориентированных языков действительно позволяют это (например, Python при проверке типов с помощью mypy). C ++, Java и большинство других языков, поддерживающих перегрузку и / или затенение , интерпретируют это как метод с перегруженным или затененным именем.
Однако Сатер поддерживал как ковариацию, так и контравариантность. Вызов конвенция о перекрытых методах ковариантна из параметров и возвращаемых значений, и контрвариантных с нормальными параметрами (с режимом в ).
Пара основных языков, Eiffel и Dart , позволяет параметрам замещающего метода иметь более конкретный тип, чем метод в суперклассе (ковариация типа параметра). Таким образом, следующий код Dart будет проверять типы с putAnimal
переопределением метода в базовом классе:
class CatShelter extends AnimalShelter {
void putAnimal ( ковариантный Кот- животное ) {
// ...
}
}
Это небезопасно. Повышающей Кастинг CatShelter
Ань AnimalShelter
, можно попытаться поместить собаку в кошки приюта. Это не соответствует CatShelter
ограничениям параметров и приведет к ошибке выполнения. Отсутствие безопасности типов (известное как «проблема перехвата» в сообществе Eiffel, где «кошка» или «CAT» - это измененная доступность или тип) было давней проблемой. За прошедшие годы для исправления этой проблемы были предложены различные комбинации глобального статического анализа, локального статического анализа и новых языковых функций , и они были реализованы в некоторых компиляторах Eiffel.
Несмотря на проблему безопасности типов, разработчики Eiffel считают ковариантные типы параметров решающими для моделирования требований реального мира. Приют для кошек иллюстрирует общий феномен: это своего рода приют для животных, но с дополнительными ограничениями , и кажется разумным использовать наследование и типы ограниченных параметров для моделирования этого. Предлагая такое использование наследования, разработчики Eiffel отвергают принцип подстановки Лискова , который гласит, что объекты подклассов всегда должны быть менее ограничены, чем объекты их суперкласса.
Еще один пример основного языка, допускающего ковариацию в параметрах метода, - это PHP в отношении конструкторов классов. В следующем примере принимается метод __construct (), несмотря на то, что параметр метода ковариантен параметру родительского метода. Если бы этот метод был любым, кроме __construct (), произошла бы ошибка:
Другой пример, в котором ковариантные параметры кажутся полезными, - это так называемые бинарные методы, то есть методы, в которых параметр должен иметь тот же тип, что и объект, для которого вызывается метод. Примером является compareTo
метод: проверяет, идет ли он до или после в некотором порядке, но способ сравнения, скажем, двух рациональных чисел будет отличаться от способа сравнения двух строк. Другие распространенные примеры бинарных методов включают проверку на равенство, арифметические операции и операции над множеством, такие как подмножество и объединение. a.compareTo(b)
a
b
В более старых версиях Java метод сравнения был указан как интерфейс Comparable
:
interface Comparable {
int compareTo ( Объект o );
}
Недостатком этого является то, что метод определен как принимающий аргумент типа Object
. В типичной реализации этот аргумент сначала будет понижен (выдается ошибка, если он не соответствует ожидаемому типу):
В языке с ковариантными параметрами аргументу compareTo
можно напрямую указать желаемый тип RationalNumber
, скрывая приведение типов. (Конечно, это все равно приведет к ошибке выполнения, если compareTo
затем будет вызвано, например, a String
.)
Другие языковые функции могут обеспечить очевидные преимущества ковариантных параметров при сохранении заменяемости Лискова.
На языке с универсальными шаблонами (также известным как параметрический полиморфизм ) и ограниченной квантификацией предыдущие примеры могут быть написаны безопасным для типов способом. [10] Вместо определения AnimalShelter
мы определяем параметризованный класс . (Одним из недостатков этого является то, что разработчик базового класса должен предвидеть, какие типы нужно будет специализировать в подклассах.) Shelter<T>
Точно так же в последних версиях Java Comparable
интерфейс был параметризован, что позволяет опускать приведение вниз безопасным для типов способом:
Еще одна языковая функция, которая может помочь, - это множественная отправка . Одна из причин, по которой двоичные методы неудобно писать, заключается в том, что в таких вызовах, как выбор правильной реализации, действительно зависит от типа среды выполнения обоих и , но в обычном объектно-ориентированном языке учитывается только тип среды выполнения . На языке с множественной диспетчеризацией в стиле Common Lisp Object System (CLOS) метод сравнения может быть записан как универсальная функция, в которой оба аргумента используются для выбора метода. a.compareTo(b)
compareTo
a
b
a
Джузеппе Кастанья [11]заметил, что в типизированном языке с множественной отправкой универсальная функция может иметь некоторые параметры, которые управляют отправкой, и некоторые «оставшиеся» параметры, которые этого не делают. Поскольку правило выбора метода выбирает наиболее конкретный применимый метод, если метод переопределяет другой метод, тогда у метода переопределения будут более конкретные типы для управляющих параметров. С другой стороны, для обеспечения безопасности типов язык по-прежнему должен требовать, чтобы оставшиеся параметры были как минимум такими же общими. Используя предыдущую терминологию, типы, используемые для выбора метода во время выполнения, являются ковариантными, в то время как типы, не используемые для выбора метода во время выполнения, являются контравариантными. Традиционные языки с единой диспетчеризацией, такие как Java, также подчиняются этому правилу: для выбора метода используется только один аргумент (объект-получатель,this
), и действительно, тип this
более специализирован внутри методов переопределения, чем в суперклассе.
Кастанья предлагает, чтобы примеры, в которых ковариантные типы параметров превосходят (в частности, бинарные методы), должны обрабатываться с использованием множественной диспетчеризации; что естественно ковариантно. Однако большинство языков программирования не поддерживают множественную отправку.
В следующей таблице приведены правила переопределения методов на языках, обсужденных выше.
Тип параметра | Тип возврата | |
---|---|---|
C ++ (с 1998 г.), Java (с J2SE 5.0 ), D | Инвариантный | Ковариантный |
C # | Инвариантный | Ковариантный (начиная с C # 9 - до инвариантного) |
Скала , Сатер | Контравариантный | Ковариантный |
Эйфелева | Ковариантный | Ковариантный |
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>
позволял бы нарушать контроль типов. Однако при помощи параметризованных конструкторов копирования и операторов присваивания можно создать умный указатель, ковариантный своему параметру-типу .
Ковариантность типов возврата методов реализована в Java начиная с J2SE 5.0. В параметрах методов ковариантности нет: для перекрытия виртуального метода типы его параметров должны совпадать с определением в родительском классе, иначе вместо перекрытия будет определен новый перегруженный метод с этими параметрами.
Массивы в Java ковариантны с самой первой версии, когда в языке еще не было обобщенных типов. (Если бы этого не было, то для использования, например, библиотечного метода, принимающего массив объектов Object[]
, для работы с массивом строк String[]
, требовалось бы его сначала скопировать в новый массив Object[]
.) Поскольку, как было сказано выше, при записи элемента в такой массив можно обойти контроль типов, в JVM существует дополнительный контроль во время выполнения, генерирующий исключение при записи некорректного элемента.
Обобщенные типы в Java инвариантны, поскольку вместо создания универсального метода, работающего с Object’ами, можно его параметризовать, превратив в обобщенный метод и сохранив контроль типов.
Вместе с тем в Java можно реализовать своего рода ко- и контравариантность обобщенных типов, используя символ-джокер и уточняющие спецификаторы: List<? extends Animal>
будет ковариантен подставляемому типу, а List<? super Animal>
— контравариантен.
В языке C#, начиная с первой его версии, массивы ковариантны. Это было сделано для совместимости с языком Java . При попытке записать в массив элемент неверного типа выбрасывается исключение во время выполнения.
Обобщенные классы и интерфейсы, появившиеся в 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>
, и т. п.
Исследование, описанное в статье про вариантность, подчеркивает ее значимость в современном мире. Надеюсь, что теперь ты понял что такое вариантность, ковариантность, контравариантность, инвариантность и для чего все это нужно, а если не понял, или есть замечания, то не стесняйся, пиши или спрашивай в комментариях, с удовольствием отвечу. Для того чтобы глубже понять настоятельно рекомендую изучить всю информацию из категории Объектно-ориентированное программирование ООП
Комментарии
Оставить комментарий
Объектно-ориентированное программирование ООП
Термины: Объектно-ориентированное программирование ООП