← назад к разделу

Дженерики — это способ сказать компилятору, с каким типом данных работает ваш класс или метод, ещё до запуска программы. Благодаря этому ошибки вылавливаются на этапе компиляции, а не падают в рантайме, и из кода исчезают ручные приведения типов.

Зачем дженерики: проблема без них

Представьте список без дженериков — каждый элемент в нём хранится как 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.