Дженерики — это способ сказать компилятору, с каким типом данных работает ваш класс или метод, ещё до запуска программы. Благодаря этому ошибки вылавливаются на этапе компиляции, а не падают в рантайме, и из кода исчезают ручные приведения типов.
Угловые скобки живут до конца компиляции: по ним компилятор отбраковывает list.add(42) и сам вставляет приведение при чтении. В байт-коде остаётся обычный ArrayList, а элементы в нём — Object.
Зачем дженерики: проблема без них
Представьте список без дженериков — каждый элемент в нём хранится как Object. Положить можно что угодно, но при чтении вы обязаны привести тип вручную, и компилятор вам не помогает:
живой пример
import java.util.ArrayList;
import java.util.List;
public class RawList {
public static void main(String[] args) {
List list = new ArrayList(); // «сырой» список, без типа
list.add("привет");
list.add(42); // компилятор молчит — а зря
try {
String s = (String) list.get(1); // ждём строку, а там число
System.out.println(s);
} catch (ClassCastException e) {
System.out.println("упало: " + e.getMessage());
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Здесь две беды. Во-первых, в список случайно попало число, и никто это не заметил. Во-вторых, на чтении приходится писать (String) — приведение типа, которое легко ошибиться.
Такой список без типа называется сырым (raw), и совсем исчезнуть он не может: в старых библиотеках и унаследованном коде сырые типы встречаются до сих пор. Компилятор в этих местах не молчит, а предупреждает: unchecked call при работе с сырым типом и unchecked cast при приведении к обобщённому типу, который он проверить не в силах. Предупреждение это не ошибка, сборка проходит, но именно за ним прячется будущий ClassCastException. Если вы уверены, что приведение безопасно (например, тип гарантирован контрактом библиотеки), его помечают @SuppressWarnings("unchecked") ровно на той строке или методе, где оно происходит, и не шире: аннотация на классе прячет вместе с этим и все будущие ошибки.
Дженерики убирают обе проблемы. Указываем тип в угловых скобках — и компилятор начинает следить за нами:
живой пример
import java.util.ArrayList;
import java.util.List;
public class TypedList {
public static void main(String[] args) {
List<String> list = new ArrayList<>(); // только строки
list.add("привет");
// list.add(42); // ошибка компиляции — хорошо!
String s = list.get(0); // приведение не нужно
System.out.println(s.toUpperCase()); // ПРИВЕТ
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Короткая формула: дженерики переносят проверку типов из рантайма в компиляцию и избавляют от ручных приведений.
Обобщённые классы
Свой класс тоже можно сделать обобщённым. Тип-параметр пишут в угловых скобках после имени класса — по соглашению одной заглавной буквой: T (type), E (element), K/V (key/value). При использовании подставляем конкретный тип, и T внутри класса как будто становится им:
живой пример
public class BoxDemo {
public static void main(String[] args) {
Box<String> textBox = new Box<>();
textBox.set("данные");
String text = textBox.get(); // тип уже String, приведения нет
System.out.println(text);
Box<Integer> numberBox = new Box<>();
numberBox.set(100);
System.out.println(numberBox.get() + 1); // 101 — сложили числа, а не строки
}
}
// контейнер, который хранит одно значение любого типа
class Box<T> {
private T value;
void set(T value) { this.value = value; }
T get() { return value; }
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Заметьте запись new Box<>(): в угловых скобках справа ничего нет. Это алмаз (diamond, с Java 7): компилятор выводит тип-аргумент из левой части, и писать new Box<String>() дважды не нужно. Он стоит в каждом примере фазы, new ArrayList<>(), new HashMap<>(), и работает везде, где тип понятен из объявления переменной или из параметра метода. С var алмаз не сочетается: var box = new Box<>() даст Box<Object>, потому что выводить тип не из чего.
Иногда нужно ограничить, какие типы допустимы. Запись <T extends Number> означает «T — это Number или любой его наследник». Внутри класса тогда доступны методы Number:
живой пример
public class Bounded {
static class Calculator<T extends Number> {
private final T value;
Calculator(T value) { this.value = value; }
double half() {
return value.doubleValue() / 2;
}
}
public static void main(String[] args) {
System.out.println(new Calculator<>(10).half()); // 5.0
System.out.println(new Calculator<>(2.5).half()); // 1.25
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
doubleValue() можно вызвать потому, что он есть у Number, а T ничем другим быть не может; с Calculator<String> компилятор откажет ещё до запуска.
Обобщённые методы
Метод может объявить собственный тип-параметр, даже если сам класс не обобщённый. Параметр пишется перед типом возвращаемого значения, а при вызове чаще всего выводится автоматически из аргументов — указывать его явно не нужно:
живой пример
import java.util.List;
public class FirstOrNull {
public static void main(String[] args) {
List<String> names = List.of("Анна", "Борис");
String first = firstOrNull(names); // T выведен как String
System.out.println(first);
List<String> empty = List.of();
System.out.println(firstOrNull(empty)); // пустой список — null
}
// <T> объявляет тип-параметр только для этого метода
static <T> T firstOrNull(List<T> items) {
return items.isEmpty() ? null : items.get(0);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Wildcards: ? extends и ? super
Сначала одна вещь, без которой wildcards выглядят капризом языка. Integer — наследник Number, но List<Integer> не является List<Number>:
List<Integer> ints = List.of(1, 2, 3);
// List<Number> numbers = ints; // ошибка компиляции
Запрет не придирка. Разреши такое присвоение — и через переменную List<Number> в тот же список можно было бы положить Double, а потом читающий его код, уверенный, что там Integer, свалился бы. Поэтому дженерики инвариантны: List<Integer> и List<Number> — два разных несовместимых типа, и родство элементов их никак не роднит. Wildcards и придуманы, чтобы обойти эту строгость там, где она мешает.
Часто метод должен принять список «чего-то», не привязываясь к одному конкретному типу. Для этого есть знак вопроса ? — wildcard (подстановочный тип). Вариантов два, и какой брать, решает один вопрос: коллекция отдаёт вам данные или вы в неё кладёте? Отдаёт — ? extends, кладёте — ? super. Это правило называют PECS, Producer Extends, Consumer Super; ниже оба варианта по очереди.
Верхняя строка - список, из которого метод только читает, он объявлен через ? extends; нижняя - список, в который метод только кладёт, он объявлен через ? super.
? extends Тип — «этот тип или его наследник». Из такой коллекции удобно читать: мы точно знаем, что любой элемент — как минимум Number.
живой пример
import java.util.List;
public class Pecs {
public static void main(String[] args) {
System.out.println(sum(List.of(1, 2, 3))); // List<Integer> — 6.0
System.out.println(sum(List.of(0.5, 1.5))); // List<Double> — 2.0
}
// принимает 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);
}
Стирание типов (type erasure)
Главная особенность дженериков в Java: они существуют только на этапе компиляции. После проверки типов компилятор «стирает» их, и в байт-коде остаётся обычный Object (или граница из extends). Этот механизм называется стирание типов (type erasure). Приведение при этом никуда не делось — просто его вставил компилятор, а не вы.
Сделано так ради совместимости со старым кодом, который писали до появления дженериков (Java 5). Но у стирания есть практические следствия, о которых стоит знать.
Во время выполнения List<String> и List<Integer> — это один и тот же тип:
живой пример
import java.util.ArrayList;
import java.util.List;
public class Erasure {
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
// в рантайме оба — просто ArrayList
System.out.println(strings.getClass() == integers.getClass()); // true
System.out.println(strings.getClass().getName()); // java.util.ArrayList
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Отсюда же запрет спрашивать тип-аргумент у объекта и создавать массив дженериков:
Object obj = List.of("привет");
// if (obj instanceof List<String> l) {} // ошибка: Object нельзя безопасно привести к List<String>
if (obj instanceof List<?> list) { // а так можно: спрашиваем только «список ли это»
System.out.println(list.size());
}
// T[] array = new T[10]; // нельзя — тип стёрт
Запрет тут точечный, а не «с дженериками instanceof нельзя». List<?> спросить можно всегда, а с Java 16 можно и list instanceof ArrayList<String> arr — там, где компилятор сам видит, что приведение безопасно. Нельзя ровно то, что он проверить не в состоянии: вытащить <String> из Object.
И стирание не полное — на этом легко попасться. Стираются аргументы типа у самих объектов: new ArrayList<String>() в памяти — просто список. А вот в объявлениях — у полей, параметров и результата метода, у наследования — generic-сигнатура остаётся в class-файле, и её можно прочитать рефлексией. Поэтому библиотеки вроде Jackson умеют разобрать JSON в List<User>, глядя на тип поля, хотя «в рантайме типа нет».
Короткая формула: дженерики — это контракт для компилятора; у готового объекта в памяти его тип-аргумент не спросить, а в объявлении поля или метода — можно.
Ещё два следствия стирания, с которыми сталкиваются в библиотечном коде.
Переменное число аргументов и дженерики. Метод static <T> List<T> listOf(T... items) под капотом получает массив T[], а массивы дженериков создавать нельзя, поэтому компилятор создаёт Object[] и предупреждает о возможном засорении кучи (heap pollution): через такой массив в List<String> теоретически может попасть не строка. Если метод только читает свои аргументы и не отдаёт массив наружу, предупреждение снимают аннотацией @SafeVarargs; так помечены List.of и Arrays.asList.
Как узнать тип, если он стёрт. Внутри обобщённого метода T не спросить: new T() и T.class не компилируются. Обход два. Первый — попросить вызывающего передать токен типа, объект Class<T>: <T> T parse(String json, Class<T> type) умеет и создать экземпляр, и проверить приведение, и ровно так устроены objectMapper.readValue(json, User.class) и context.getBean(UserService.class). Второй нужен там, где Class не поможет, потому что нужен List<User>, а List.class про User не знает: библиотека просит анонимный подкласс new TypeReference<List<User>>() {}. Хитрость в том, что у подкласса обобщённая сигнатура родителя записана в class-файле, и Jackson читает её рефлексией, как читает тип поля. Так и отвечают на вопрос из предыдущего абзаца: «как же он читает List<User>, если тип стёрт» — из объявления, которого стирание не касается.
Дженерики в коллекциях
Чаще всего с дженериками сталкиваются именно в коллекциях — там они везде. Объявляя коллекцию, вы фиксируете тип элементов, и весь дальнейший код становится безопасным и читаемым:
живой пример
import java.util.HashMap;
import java.util.Map;
public class Ages {
public static void main(String[] args) {
Map<String, Integer> ages = new HashMap<>(); // ключ String, значение Integer
ages.put("Анна", 30);
ages.put("Борис", 25);
int age = ages.get("Анна"); // значение уже Integer, приведения нет
System.out.println(age + 1);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Интерфейсы из стандартной библиотеки тоже обобщённые, и при их реализации вы подставляете нужный тип — например, Comparable<T>:
Comparable<Person> здесь значит, что Person умеет сравнивать себя с другим Person, и этим пользуются все, кому нужен порядок: Collections.sort(list), list.sort(null), TreeMap и TreeSet без компаратора, Stream.sorted(). Когда порядков нужно несколько или менять класс нельзя, порядок задают снаружи объектом Comparator<T>: это тоже обобщённый интерфейс с одним методом compare(T a, T b), а его статические фабрики умеют собрать сравнение по ключу, Comparator.comparing(Person::age), и дальше уточнять его через thenComparing, reversed и nullsLast. Как это применяют на практике, вместе с ловушками reversed и сортировки с null, разобрано в статье про коллекции, в разделе про сортировку.
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. List<Integer>— неList<Number>: дженерики инвариантны, и именно поэтому нужны wildcards.- Стирание типов означает, что у объекта в рантайме тип-аргумент не спросить:
obj instanceof List<String>не скомпилируется (аList<?>— пожалуйста),new T[]не создать. В объявлениях полей и методов сигнатура остаётся — на ней живёт Jackson. - В коллекциях дженерики используются повсеместно — они и есть основная причина, по которой коллекции безопасны.
new Box<>()это алмаз (тип выводится из левой части, сvarдаётBox<Object>); сырой тип из старого кода даёт предупреждениеunchecked, которое гасят@SuppressWarningsна строке, не на классе.- Обобщённые varargs создают
Object[](засорение кучи,@SafeVarargsдля методов, которые массив не отдают); стёртый тип узнают через токенClass<T>илиTypeReferenceс сигнатурой подкласса. Comparable<T>даёт естественный порядок дляsort,TreeMapиsorted(),Comparator<T>задаёт порядок снаружи черезcomparingиthenComparing.
Что почитать дальше
- Коллекции в Java — где дженерики применяются чаще всего.
- Лямбды и Stream API — обобщённые типы лежат в основе функциональных интерфейсов и стримов.
- ООП в Java — наследование и интерфейсы, на которых строятся ограничения и wildcards.