Что такое ковариация и контравариантность в программировании?
Ковариантность и контравариантность — термины, описывающие, как язык программирования обрабатывает подтипы. Дисперсия типа определяет, могут ли его подтипы использоваться взаимозаменяемо с ним.
Дисперсия — это концепция, которая может показаться непрозрачной, пока не будет приведен конкретный пример. Давайте рассмотрим базовый тип Animal
с подтипом Dog
.
interface Animal { public function walk() : void; } interface Dog extends Animal { public function bark() : void; }
Все «Животные» могут ходить, но только «Собаки» могут лаять. Теперь давайте рассмотрим, что происходит, когда эта иерархия объектов используется в нашем приложении.
Соединение интерфейсов вместе
Поскольку каждое Животное
может ходить, мы можем создать общий интерфейс, который тренирует любое Животное
.
interface AnimalController { public function exercise(Animal $Animal) : void; }
AnimalController
имеет метод exercise()
, который типизирует интерфейс Animal
.
interface DogRepository { public function getById(int $id) : Dog; }
Теперь у нас есть DogRepository
с методом, который гарантированно возвращает Dog
.
Что произойдет, если мы попытаемся использовать это значение с AnimalController
?
$AnimalController -> exercise($DogRepository -> getById(1));
Это допустимо в языках, где поддерживаются ковариантные параметры. AnimalController
должен получить Animal.
То, что мы передаем, на самом деле является Dog
, но оно по-прежнему удовлетворяет Animal
договор.
Такие отношения особенно важны, когда вы расширяете классы. Нам может понадобиться универсальный AnimalRepository
, который извлекает любое животное без сведений о его виде.
interface AnimalRepository { public function getById(int $id) : Animal; } interface DogRepository extends AnimalRepository { public function getById(int $id) : Dog; }
DogRepository
изменяет контракт AnimalRepository
, так как вызывающие абоненты получат Dog
вместо Animal
, но не принципиально изменить его. Он просто более конкретен в отношении типа возвращаемого значения. Dog
по-прежнему является Animal.
Типы ковариантны, поэтому определение DogRepository
приемлемо.
Глядя на контравариантность
Теперь рассмотрим обратный пример. Может быть желательно иметь DogController
, который изменяет способ тренировки «собак». Логически, это все еще может расширить интерфейс AnimalController
. Однако на практике большинство языков не позволяют вам переопределить exercise()
необходимым образом.
interface AnimalController { public function exercise(Animal $Animal) : void; } interface DogController extends AnimalController { public function exercise(Dog $Dog) : void; }
В этом примере DogController
указал, что exercise()
принимает только Dog
. Это противоречит вышестоящему определению в AnimalController
, которое разрешает передачу любого «животного». Следовательно, для выполнения контракта DogController
также должен принимать любое Animal
.
На первый взгляд это может показаться запутанным и бесполезным. Причина этого ограничения становится более понятной, когда вы указываете тип для AnimalController
:
function exerciseAnimal( AnimalController $AnimalController, AnimalRepository $AnimalRepository, int $id) : void { $AnimalController -> exercise($AnimalRepository -> getById($id)); }
Проблема в том, что AnimalController
может быть AnimalController
или DogController
— наш метод не должен знать, какую реализацию интерфейса он использует. Это сводится к тем же правилам ковариации, которые были полезны ранее.
Поскольку AnimalController
может быть DogController
, теперь существует серьезная ошибка во время выполнения, ожидающая обнаружения. AnimalRepository
всегда возвращает Animal
, поэтому, если $AnimalController
является DogController
, приложение вылетит. Тип Animal
слишком расплывчатый, чтобы передать его методу DogController
exercise()
.
Стоит отметить, что языки, поддерживающие перегрузку методов, будут принимать DogController
. Перегрузка позволяет определить несколько методов с одним и тем же именем при условии, что они имеют разные сигнатуры (у них разные параметры и/или возвращаемые типы). DogController
будет иметь дополнительный метод exercise()
, который принимает только «собаки». Однако также потребуется реализовать восходящую подпись, принимающую любое «Животное».
Решение проблем с отклонениями
Все вышесказанное можно резюмировать, сказав, что типы возвращаемых функций могут быть ковариантными, а типы аргументов должны быть контравариантными. Это означает, что функция может возвращать более конкретный тип, чем определяет интерфейс. Он также может принимать в качестве аргумента более абстрактный тип (хотя в большинстве популярных языков программирования это не реализовано).
Чаще всего вы сталкиваетесь с проблемами дисперсии при работе с дженериками и коллекциями. В этих сценариях вам часто нужны AnimalCollection
и DogCollection
. Следует ли DogCollection
расширять AnimalCollection
?
Вот как могут выглядеть эти интерфейсы:
interface AnimalCollection { public function add(Animal $a) : void; public function getById(int $id) : Animal; } interface DogCollection extends AnimalCollection { public function add(Dog $d) : void; public function getById(int $id) : Dog; }
Взглянув сначала на getById()
, Dog
является подтипом Animal
. Типы являются ковариантными, и допускаются ковариантные возвращаемые типы. Это приемлемо. Однако мы снова наблюдаем проблему дисперсии с add()
— DogCollection
должен разрешать добавление любого Animal
, чтобы удовлетворить AnimalCollection
договор.
Обычно эту проблему лучше всего решить, сделав коллекции неизменяемыми. Разрешить добавление новых элементов только в конструктор коллекции. Затем вы можете полностью исключить метод add()
, сделав AnimalCollection
действительным кандидатом для DogCollection
для наследования.
Другие формы вариации
Помимо ковариантности и контравариантности, вы также можете встретить следующие термины:
- Бивариантность. Система типов является бивариантной, если ковариантность и контравариантность одновременно применяются к отношению типов. Бивариантность использовалась TypeScript для своих параметров до TypeScript 2.6
- Вариант. Типы являются вариантными, если применяется ковариантность или контравариантность.
- Инвариантные: любые типы, не являющиеся вариантными.
Обычно вы будете работать с ковариантными или контравариантными типами. С точки зрения наследования классов, тип B является ковариантным с типом A, если он расширяет A. Тип B является контравариантным с типом A, если он является предком B.
Заключение
Вариантность — это концепция, которая объясняет ограничения в системах типов. Обычно вам нужно только помнить, что ковариация принимается в возвращаемых типах, тогда как контравариантность используется для параметров.
Правила вариации возникают из принципа замещения Лискова. Это означает, что вы должны иметь возможность заменять экземпляры класса экземплярами его подклассов без изменения каких-либо свойств более широкой системы. Это означает, что если тип B расширяет тип A, экземпляры A
могут быть заменены экземплярами B
.
Использование нашего примера выше означает, что мы должны иметь возможность заменить Animal
на Dog
или AnimalController
на DogController
. Здесь мы снова видим, почему DogController
не может переопределить exercise()
, чтобы принимать только собак — мы больше не сможем заменить AnimalController
на DogController
, так как потребители, которые в настоящее время передают Animal
, теперь должны вместо этого предоставить Dog
. Ковариантность и контравариантность обеспечивают выполнение LSP и гарантируют согласованные стандарты поведения.