Думая о родовых типах в языках программирования, я испытываю смешанные чувства. С одной стороны, нам, разработчикам, нравится упрощать бизнес-задачи до инструкций в коде и пытаться найти сходства, чтобы избежать дублирования. С другой стороны, мы хотели бы, чтобы код был простым и понятным для всех членов команды, особенно если речь идет о новичках, чтобы быстро ввести их в курс дела и заставить их приносить пользу клиенту.
Generics — одна из тех вещей, которые могут быть действительно мощными и позволяют создавать многократно используемый код, который можно широко использовать во всем проекте или даже экспортировать в специальный репозиторий и сделать библиотекой. Представьте себе ситуацию, когда нет возможности использовать известные общие коллекции типа List<T>
или Map<K,V>
и для каждого типа, который мы хотим хранить в таком контейнере, мы должны создавать его самостоятельно с нуля.
Однако работа над полностью общим кодом может свести человека с ума. Она также может потребовать большой концентрации внимания, и это определенно не тот код, который можно читать просто как роман.
С большой властью приходит большая ответственность. [Кто-то, где-то].
При работе с типами мы можем услышать такие слова, как «ковариантный», «контравариантный» или «инвариантный». В самом начале это звучит очень математично и выглядит так, будто взято прямо из университета. Поэтому мы оставим в стороне формальное определение и сразу перейдем к примерам.
Ковариация
Рассмотрим два класса, написанных на языке Java
class Animal {
}
class Dog extends Animal {
}
И список Animals, в который добавлены экземпляры как собаки, так и животного
List<Animal> animals = new ArrayList<>();
animals.add(new Dog());
animals.add(new Animal());
Мы можем добавить тип Animal и подтип Dog в наш список Animals. Однако мы не можем объявить список животных и присвоить ему список собак.
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // compilation error
Также мы не можем передать список собак в функцию, которая принимает в качестве аргумента List<Animal>
.
public void processAnimals(List<Animal> animals) {
}
List<Dog> dogs = new ArrayList<>();
processAnimals(dogs); // compilation error
По определению, мы можем назвать определенный общий тип K<T>
ковариантным, если выполняются два условия:
Вспоминая наших собак и животных, мы можем сказать, что 1) истинно, но 2) ложно. Это может означать, что List
в Java не является ковариантным.
К счастью, есть способ добиться ковариантности списков в Java. Мы можем использовать подстановочный знак ? extends T
, который означает, что будет принят любой подтип T
.
List<Dog> dogs = new ArrayList<>();
List<? extends Animal> animals = dogs;
То же самое касается общих функций с типом, расширяющим подтип.
public void processAnimals(List<? extends Animal> animals) {
}
List<Dog> dogs = new ArrayList<>();
processAnimals(dogs);
Оба фрагмента полностью корректны с точки зрения компиляции.
Контравариантность
Что если мы хотим передать супертип T
некоторой коллекции или методу? Вернемся к нашему примеру с животными и собаками.
List<Animal> animals = new ArrayList<>();
List<Dog> dogs = animals; // compilation error
Следующий код не скомпилируется, поскольку мы не можем передать animals
список менее конкретных типов dogs
, которые являются более конкретными. То же самое относится и к аргументам методов:
public void processDogs(List<Dog> dogs) {
}
List<Animal> animals = new ArrayList<>();
processDogs(animals); // compilation error
Это именно то, что мы назвали бы контравариантностью, которая, согласно определению, должна удовлетворять двум условиям (для родового типа K<T>
):
1.A
является подтипом B
(то же самое, что и в ковариации).
Аналогично ковариации, Java протягивает нам руку помощи, предоставляя подстановочный знак для достижения контравариации: ? super T
. Он называется Lower Bounded Wildcard. Таким образом мы можем получить общий тип, который будет принимать супертип T
.
List<Animal> animals = new ArrayList<>();
List<? super Dog> dogs = animals;
То же самое относится и к методу:
public void processDogs(List<? super Dog> dogs) {
}
List<Animal> animals = new ArrayList<>();
processDogs(animals);
Инвариантность
Третья ситуация, которая может произойти, это случай, когда для нашего общего типа K<T>
не существует отношения подтипизации в любом случае. Опять же, по определению, есть два условия, которые должны быть удовлетворены:
Именно это и происходит, когда мы не делаем ни одного из перечисленных подстановочных знаков. Вернемся к примеру из самого начала:
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // compilation error
Мы получим ошибку компиляции, что означает, что первая часть условия 2) будет выполнена. Попробуем выполнить вторую:
List<Animal> animals = new ArrayList<>();
List<Dog> dogs = animals; // compilation error
Вуаля, также ошибка компиляции! Это означает, что следующий общий тип является инвариантным.
Резюме
Ковариантность, контравариантность и инвариантность могут показаться пугающими на первый взгляд, однако их можно легко объяснить, взглянув на дженерики в Java. Стоит отметить, что представленное поведение связано со стиранием типов в Java Generics, что вкратце означает, что наши типы живут только во время компиляции и стираются во время выполнения. Это проектное решение имеет множество других (иногда нежелательных) последствий, которые заслуживают отдельной статьи.
В следующей части мы рассмотрим, как младший, современный брат Java — Kotlin — справляется с этими тремя аспектами на платформе JVM.
Михал Кошалка, старший бэкенд-разработчик @ Bright Inventions