Поиск по сайту:

Что такое ковариация и контравариантность в программировании?


Ковариантность и контравариантность — термины, описывающие, как язык программирования обрабатывает подтипы. Дисперсия типа определяет, могут ли его подтипы использоваться взаимозаменяемо с ним.

Дисперсия — это концепция, которая может показаться непрозрачной, пока не будет приведен конкретный пример. Давайте рассмотрим базовый тип 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 и гарантируют согласованные стандарты поведения.




Все права защищены. © Linux-Console.net • 2019-2024