Generics и wildcards в Java. Конспект

Зачем вообще нужны Generics

В древние времена, до Java 5 типизированных коллекций и дженериков в языке не было, и тогда работа с коллекциями была не очень безопасной. Взглянем на пример:

        List list = new ArrayList();

        list.add("Hello");
        list.add(LocalDate.now());

        for (int i = 0; i <list.size(); i++) {
            System.out.println(list.get(i));
            System.out.println(list.get(i).getClass());
        }

Здесь я создаю список объектов и кладу в него строку и дату. Такое поведение разрешено, и даже вывод работает корректно, и мы даже видим, что класс объектов в списке определяется верно.

Но это создаёт некоторые неудобства. Например, приходится делать явное приведение типов, если хотим не просто вывести данные, как в примере выше:

String s1 = (String)list.get(0);
String s2 = (String)list.get(1);

Вот только такой код выкинет ClassCastException, потому что объект с индексом 1 вообще-то совсем не строка. Чтобы избежать таких ошибок, добавили явное объявление типа – тогда такой код даже не скомпилируется.

Но стоит понимать, что List<Integer> и List<String> это не разные классы. При компиляции происходит стирание типа (type erasure), и в итоге для JVM все списки становятся просто List. Поэтому то, что мы обсудим далее, не присутствует в скомпилированном коде – это инструменты для правильной архитектуры приложения и защиты от небезопасной работы с данными разных типов.

Generics

Generics (обобщения) — это механизм Java, который позволяет писать классы, интерфейсы и методы, работающие с разными типами данных, сохраняя при этом проверку типов на этапе компиляции.

Вместо конкретного типа (String, Integer, User) используется параметр, который позже заменяется реальным типом. Обычно параметр обозначается одной буквой (T, E, K, V), но можно в принципе писать и целое слово, даже не обязательно с большой буквы – ошибки не будет. Но обязательно в объявлении класса, интерфейса или метода указывать этот параметр после имени в угловых скобках <>. Но всё же лучше следовать соглашению:

T — Type

E — Element

K — Key

V — Value

R — Result

N — Number

Дженерики обеспечивают типобезопасность (ошибки обнаруживаются во время компиляции), избавляют от явного приведения типов (cast) и позволяют писать универсальный код, который работает с разными типами.

Generic-классы

Например, вместо двух отдельных классов:

class StringBox { ... }
class IntegerBox { ... }

теперь можно написать один универсальный:

class Box<T> {
private T value;
}

и использовать его с любым типом:

Box<String> names;
Box<Integer> numbers;
Box<User> users;

Как видно выше, класс используется не только в сигнатуре класса – его можно переиспользовать ниже для объявления методов класса и его полей.

Type Inference (вывод типов)

Компилятор может сам решать, какой тип задумал разработчик. Необязательно всегда явно указывать тип в угловых скобках. Иногда достаточно так:

List<String> list = new ArrayList<>();

Здесь и так понятно, что раз слева мы ожидаем список строк, то и справа создаётся список строк: компилятор сопоставляет T и String.

Компилятор также может угадать тип по типу аргументов:

List<String> list = List.of("A", "B");

Здесь в списке все объекты типа String, поэтому T опять же становится String. Но слева тип может быть не задан явно:

var list = List.of("A", "B");

Тут информация точно берётся с правой стороны. И если бы мы попытались положить в список объекты разных типов, то он найдёт их ближайшего общего родителя:

var list = List.of("A", new Date());

Intellij IDEA мне даже подсказывает, что в этом случае общий тип будет чем-то, реализующим интерфейсы Serializable и Comparable. Соответственно, доставать из такого списка мы можем только объекты, которые отвечают этим требованиям. То есть Object, а не конкретные String и Date.

Компилятор также использует информацию, из сигнатуры метода. Если метод ожидает список CharSequence, то созданный нами список будет иметь именно такой параметр:

    private static void listTest(List<CharSequence> list) {
        System.out.println(list.get(0).getClass());
    }
    
    listTest(List.of("A", "B"));

И хотя вывод даст нам String, вытащить объект типа String без явного приведения мы не можем.

Generic-методы

Это метод, который имеет собственный параметр типа, не зависящий от класса.

class Box<T> {
public <R> R convert(Function<T, R> mapper) {
...
}
}

Тип ставится перед возвращаемым типом метода.

Тип может быть тем же типом, что уже объявлен у класса. Тогда не нужно указывать его в угловых скобках. Но это возможно только для методов экземпляра, потому что статический метод ещё не знает, каким типом будет параметр T. Компилятор скажет Cannot make a static reference to the non-static type T.

Так что для статических методов обязательно делаем так:

class Box<T> {
public static <T> T first(List<T> list) {
return list.get(0);
}
}

Но здесь T в методе никак не зависит от T класса, и это по факту могут быть совсем разные типы.

Bounds (ограничения)

Джава позволяет обозначать не один тип, а целую категорию наследников определённого типа или реализации интерфейса. Для этого в обоих случаях используется слово extends:

<T extends Comparable<T>>
<T extends Number>

Это полезно, если нам нужно вызвать какой-то метод, например compareTo(), и мы хотим гарантировать, что он действительно есть в нашем «любом» классе T.

Можно также писать сразу несколько типов, если нам нужно сразу несколько ограничений. Тогда типы разделяются амперсандом &.

<T extends Animal & Serializable & Comparable<T>>

Но в этом случае класс должен быть только один (множественное наследование всё ещё запрещено), и в списке класс должен идти первым, до всех интерфейсов.

Wildcards (подстановочные типы)

Wildcard (?) – это специальная конструкция Generics, обозначающая неизвестный тип.

Если параметр вроде T говорит, что у нас есть какой-то класс, но мы не знаем, какой именно, то wildcard говорит, что этот тип нам даже не особо интересен.

Например, методу печати не нужно знать, какой там тип объектов в списке:

void print(List<?> list)

Таким образом, Generics используются, когда один и тот же тип используется в нескольких местах: например, нужно создать объект этого типа или вернуть его. А wildcards позволяют принимать любой тип, но не объявлять объекты этого типа.

Если wildcard неограниченная, то мы просто ставим знак вопроса, как на примере выше. Но мы также можем ограничивать классы.

Верхняя граница обозначается словом extends:

List<? extends Animal>

Неизвестный тип, но обязательно Animal или его наследник. Используется в основном для чтения (источник данных). Таким образом мы гарантируем, что все объекты безопасно рассматривать как Animal, и у них всех можно вызвать, например, метод eat(), независимо от того, кошка там или собака.

Нижняя граница обозначается словом super:

List<? super Dog>

Неизвестный тип, но обязательно Dog или его родитель. Используется в основном для записи. Это гарантирует, что в список можно безопасно добавить объект типа Dog или его наследников, независимо от того, является ли список List<Dog>, List<Animal> или List<Object>.

PECS

И тут мы подобрались к самому непонятному моменту в этой всей истории. Принцип PECS (Producer Extends Consumer Super). Он говорит, что продюсер (источник данных) должен использовать extends, то есть это должен быть список объектов какого-то типа или его наследников. А вот консьюмер (назначение) должен использовать super, то есть принимать объекты какого-то типа или его родителей. В итоге эти два ограничения сходятся ровно на нужном типе.

Если коллекция отдаёт объекты (Producer), важно, чтобы можно было безопасно читать их как общий тип → extends.

Если коллекция принимает объекты (Consumer), важно, чтобы можно было безопасно класть туда элементы → super.

Всё это делается для безопасной компиляции кода.

Рассмотрим пример с копированием объектов из списков:

public static <T> void copy(
List<? super T> dest,
List<? extends T> src) {
for (T item : src) {
dest.add(item);
}
}
List<Cat> cats = List.of(new Cat(), new Cat());
List<Dog> dogs = List.of(new Dog(), new Dog());
List<Animal> animals = new ArrayList<>();
copy(animals, cats);
copy(animals, dogs);

Здесь мы копируем данные из списка котов и другого списка собак в один список животных. Таким образом, из частных случаев переходим к общему списку.

Это буквально сигнатура метода copy в классе Collections:

Collections.copy(List<? super T> dest, List<? extends T> src)

Мы можем скопировать коллекцию любых детей T в коллекцию T или его родителей.

Рассмотрим другие реальные примеры использования wildcards:

Comparator<? super T>

Используется для сортировки нашего типа и его родителей.

Function<? super T, ? extends R>

Очень часто встречается в Stream API. Функция принимает T или его родителя, а возвращает R или наследника.

List<Dog> dogs = List.of(new Dog(), new Dog());
List<String> names = dogs.stream().map(Dog::getName).toList();

Для метода:

<R> Stream<R> map(Function<? super T, ? extends R> mapper)

Маппер принимает функцию, которая ждёт объект типа T или его родителя и возвращает что-то типа R или его наследника. Ссылка на метод Dog::getName преобразуется в Function<Dog, String>, что удовлетворяет сигнатуре Function<? super T, ? extends R>.

Меня долго смущали продюсеры и консьюмеры в названии PECS, но вот настоящий консьюмер.

Consumer<? super T>

Он должен уметь принимать объекты от Object вплоть до T.

Consumer<Object> printer = System.out::println;
List<Dog> dogs = List.of(new Dog(), new Dog());
dogs.forEach(printer);

Для метода:

void forEach(Consumer<? super T> action)

Этот код выведет информацию по всем собакам из списка. Тип консьюмера мы при этом ставим Object, что идеально удовлетворяет требованию super.

А это встречается в reflection: какой-то класс, который является аннотацией:

Class<? extends Annotation>

Важно понимать, что wildcard’ы чаще используются не для работы с коллекциями разных типов, а для проектирования универсальных API. Именно поэтому они широко применяются в стандартной библиотеке Java (Collections, Stream, Comparator, Function, Consumer).

Инвариантность Generics

Хотя Dog наследуется от Animal, это не означает, что List<Dog> наследуется от List<Animal>.

List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs;

Будет ошибка компиляции. Но почему?

Представим, что это было бы разрешено:

List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs;
animals.add(new Cat());
Dog dog = dogs.get(0);

Так была бы нарушена типобезопасность. Именно поэтому Generics в Java инвариантны.

Для решения этой проблемы и были придуманы wildcard’ы (? extends и ? super), которые позволяют безопасно работать с совместимыми generic-типами.

Оставить комментарий