Дженерики — это способ сказать компилятору, с каким типом данных работает ваш класс или метод, ещё до запуска программы. Благодаря этому ошибки вылавливаются на этапе компиляции, а не падают в рантайме, и из кода исчезают ручные приведения типов.
Зачем дженерики: проблема без них
Представьте список без дженериков — каждый элемент в нём хранится как Object. Положить можно что угодно, но при чтении вы обязаны привести тип вручную, и компилятор вам не помогает:
List list = new ArrayList(); // «сырой» список, без типа
list.add("привет");
list.add(42); // компилятор молчит — а зря
String s = (String) list.get(1); // компилируется, но упадёт в рантайме:
// ClassCastException
Здесь две беды. Во-первых, в список случайно попало число, и никто это не заметил. Во-вторых, на чтении приходится писать (String) — приведение типа, которое легко ошибиться.
Дженерики убирают обе проблемы. Указываем тип в угловых скобках — и компилятор начинает следить за нами:
List<String> list = new ArrayList<>(); // только строки
list.add("привет");
// list.add(42); // ошибка компиляции — хорошо!
String s = list.get(0); // приведение не нужно
Короткая формула: дженерики переносят проверку типов из рантайма в компиляцию и избавляют от ручных приведений.
Обобщённые классы
Свой класс тоже можно сделать обобщённым. Тип-параметр пишут в угловых скобках после имени класса — по соглашению одной заглавной буквой: T (type), E (element), K/V (key/value).
// Контейнер, который хранит одно значение любого типа
class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
При использовании подставляем конкретный тип, и T внутри класса как будто становится им:
Box<String> textBox = new Box<>();
textBox.set("данные");
String text = textBox.get(); // тип уже String, приведения нет
Box<Integer> numberBox = new Box<>();
numberBox.set(100);
Иногда нужно ограничить, какие типы допустимы. Запись <T extends Number> означает «T — это Number или любой его наследник». Внутри класса тогда доступны методы Number:
class Calculator<T extends Number> {
private final T value;
Calculator(T value) { this.value = value; }
double half() {
return value.doubleValue() / 2; // doubleValue() есть у Number
}
}
Обобщённые методы
Метод может объявить собственный тип-параметр, даже если сам класс не обобщённый. Параметр пишется перед типом возвращаемого значения:
class Utils {
// <T> объявляет тип-параметр только для этого метода
static <T> T firstOrNull(List<T> items) {
return items.isEmpty() ? null : items.get(0);
}
}
Тип чаще всего выводится автоматически из аргументов, явно его указывать не нужно:
List<String> names = List.of("Анна", "Борис");
String first = Utils.firstOrNull(names); // T выведен как String
Wildcards: ? extends и ? super
Часто метод должен принять список «чего-то», не привязываясь к одному конкретному типу. Для этого есть знак вопроса ? — wildcard (подстановочный тип). У него два полезных варианта.
? extends Тип — «этот тип или его наследник». Из такой коллекции удобно читать: мы точно знаем, что любой элемент — как минимум Number.
// принимает List<Integer>, List<Double>, List<Number> — любой
static double sum(List<? extends Number> numbers) {
double total = 0;
for (Number n : numbers) { // читаем как Number — безопасно
total += n.doubleValue();
}
return total;
// numbers.add(...) здесь запрещён: точный тип неизвестен
}
? super Тип — «этот тип или его предок». В такую коллекцию удобно писать: мы точно можем положить Integer (или его наследника), потому что цель вмещает как минимум Integer.
// можно передать List<Integer>, List<Number>, List<Object>
static void addNumbers(List<? super Integer> target) {
target.add(1); // класть Integer безопасно
target.add(2);
}
Запомнить, какой когда нужен, помогает правило PECS — Producer Extends, Consumer Super: если коллекция отдаёт вам данные (producer) — берите extends; если вы кладёте данные в неё (consumer) — берите super.
Стирание типов (type erasure)
Главная особенность дженериков в Java: они существуют только на этапе компиляции. После проверки типов компилятор «стирает» их, и в байт-коде остаётся обычный Object (или граница из extends). Этот механизм называется стирание типов (type erasure).
Сделано так ради совместимости со старым кодом, который писали до появления дженериков (Java 5). Но у стирания есть практические следствия, о которых стоит знать.
Во время выполнения List<String> и List<Integer> — это один и тот же тип:
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
// в рантайме оба — просто ArrayList
System.out.println(strings.getClass() == integers.getClass()); // true
Нельзя проверить тип-параметр через instanceof и нельзя создать массив дженериков:
// if (obj instanceof List<String>) {} // ошибка компиляции
// T[] array = new T[10]; // нельзя — тип стёрт
Короткая формула: дженерики — это контракт для компилятора; в рантайме информации о конкретном типе уже нет.
Дженерики в коллекциях
Чаще всего с дженериками сталкиваются именно в коллекциях — там они везде. Объявляя коллекцию, вы фиксируете тип элементов, и весь дальнейший код становится безопасным и читаемым:
List<String> names = new ArrayList<>();
Map<String, Integer> ages = new HashMap<>(); // ключ String, значение Integer
Set<Long> ids = new HashSet<>();
ages.put("Анна", 30);
int age = ages.get("Анна"); // значение уже Integer, приведения нет
Интерфейсы из стандартной библиотеки тоже обобщённые, и при их реализации вы подставляете нужный тип — например, Comparable<T>:
record Person(String name, int age) implements Comparable<Person> {
@Override
public int compareTo(Person other) {
return Integer.compare(this.age, other.age);
}
}
С var тип всё равно остаётся: переменная получает полный обобщённый тип из правой части, просто его не нужно писать дважды.
var scores = new HashMap<String, Integer>(); // тип — HashMap<String, Integer>
Коротко
- Дженерики переносят проверку типов на этап компиляции и убирают ручные приведения (
(String)). - Обобщённый класс объявляет тип-параметр (
class Box<T>); ограничение задаётся через<T extends Number>. - Обобщённый метод объявляет свой тип-параметр перед типом результата; тип обычно выводится автоматически.
- Wildcards:
? extends— для чтения (producer),? super— для записи (consumer); правило PECS. - Стирание типов означает, что в рантайме конкретного типа уже нет: нельзя
instanceof List<String>илиnew T[]. - В коллекциях дженерики используются повсеместно — они и есть основная причина, по которой коллекции безопасны.
Что почитать дальше
- Коллекции в Java — где дженерики применяются чаще всего.
- Лямбды и Stream API — обобщённые типы лежат в основе функциональных интерфейсов и стримов.
- ООП в Java — наследование и интерфейсы, на которых строятся ограничения и wildcards.