Дженерики в Java: что это такое и для чего они нужны

Дженерики в Java: что это такое и для чего они нужны Полезное

Дженерики (generics) в Java — это механизм параметризации типов: класс, интерфейс или метод объявляется с параметром типа (<T>), а конкретный тип (String, Integer) подставляется при использовании. Нужны они для того, чтобы ошибку «положили в коллекцию не тот тип» ловил компилятор, а не пользователь во время работы программы, и чтобы код не был заставлен ручными приведениями (String) obj.

Важная граница: дженерики работают на этапе компиляции. В байт-коде параметры типов в основном стираются (type erasure), поэтому List<String> и List<Integer> во время выполнения — один и тот же класс ArrayList. Из этого следуют почти все ограничения дженериков, о которых ниже. Примеры проверены на OpenJDK 25.0.4 (Java 25 LTS) 24.09.2026.

Мини-словарь: пять терминов, которые путают

Термин Пример Что это
Параметр типа T в class Box<T> «переменная» для типа в объявлении
Аргумент типа String в Box<String> конкретный тип при использовании
Параметризованный тип List<String> тип с подставленным аргументом
Raw type (сырой тип) List без <...> обобщенный тип без аргумента, наследие до Java 5
Wildcard List<? extends Number> «какой-то тип» с границей, только в местах использования

Зачем нужны дженерики: ошибка до и после

Дженерики появились в Java 5 (2004). До этого коллекции хранили Object, и тип проверялся только приведением при чтении. Вот как это выглядит (raw type — так писали до Java 5):

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List names = new ArrayList();      // raw type: без параметра
        names.add("Анна");
        names.add(42);                     // компилятор не возражает
        for (Object o : names) {
            String s = (String) o;         // приведение вручную
            System.out.println(s.toUpperCase());
        }
    }
}

Компиляция проходит, javac лишь предупреждает Note: Main.java uses unchecked or unsafe operations. (при запуске без компиляции java Main.java вместо Note сразу печатаются подробные предупреждения [unchecked] unchecked call to add(E) as a member of the raw type List). При запуске первая строка печатается, а на второй программа падает:

АННА
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
    at Main.main(Main.java:10)

Исправление — указать тип элементов: List<String> names = new ArrayList<>();. Теперь та же строка names.add(42); не компилируется:

Main.java:8: error: incompatible types: int cannot be converted to String
        names.add(42);
                  ^

Ошибка переехала из рантайма (у пользователя) в компиляцию (у разработчика), а приведение (String) o больше не нужно: for (String s : names). Это и есть главный ответ на вопрос, для чего нужны дженерики: типобезопасность без ручных приведений плюс один код для многих типов.

Минимальный рабочий пример: класс, метод и ограничение

Один файл показывает три формы дженериков сразу. Сохраните как Main.java, запустите java Main.java (Java 11+ умеет запускать один файл без отдельного javac).

import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;

public class Main {

    // Обобщенный класс: T - параметр типа
    static class Box<T> {
        private final T value;
        Box(T value) { this.value = value; }
        T get() { return value; }
        <R> Box<R> map(Function<? super T, ? extends R> f) {
            return new Box<>(f.apply(value));
        }
    }

    // Обобщенный метод: свой параметр <T> перед типом результата
    static <T> T firstOrDefault(List<T> list, T fallback) {
        return list.isEmpty() ? fallback : list.get(0);
    }

    // Ограничение сверху: T умеет сравниваться сам с собой
    static <T extends Comparable<? super T>> T max(List<T> list) {
        T best = list.get(0);
        for (T x : list) {
            if (x.compareTo(best) > 0) best = x;
        }
        return best;
    }

    public static void main(String[] args) {
        Box<String> box = new Box<>("generics");
        String s = box.get();                       // без приведения
        Box<Integer> len = box.map(String::length);
        System.out.println(s + " -> " + len.get());

        List<String> empty = new ArrayList<>();
        System.out.println(firstOrDefault(empty, "нет данных"));

        System.out.println(max(List.of(3, 17, 5)));
        System.out.println(max(List.of("груша", "яблоко", "апельсин")));
    }
}

В консоли будет:

generics -> 8
нет данных
17
яблоко

Разбор по частям:

  • Обобщенный класс Box<T>: T можно использовать как тип поля, параметра и результата. new Box<>("generics") — «ромбовидный оператор» (diamond, с Java 7): аргумент типа компилятор выводит сам из левой части.
  • Обобщенный метод <T> T firstOrDefault(...): параметр <T> объявлен у самого метода, класс при этом может быть необобщенным. Тип выводится из аргументов; явно его можно указать так: Main.<String>firstOrDefault(empty, "x").
  • Ограничение T extends Comparable<? super T>: в max можно передать только то, что умеет compareTo. Внутри метода у T доступны методы границы. Строки сравниваются по кодам символов, а не по правилам локали, поэтому «яблоко» оказалось максимумом. Граница примера: на пустом списке max упадет с IndexOutOfBoundsException на list.get(0), в рабочем коде пустой ввод проверяют заранее или возвращают Optional<T>.

У extends в дженериках смысл «является подтипом» — и для классов, и для интерфейсов. Границ может быть несколько: <T extends Number & Comparable<T>>, класс идет первым.

Инвариантность: почему List<Integer> — не List<Number>

Integer — наследник Number, но List<Integer> не наследник List<Number>. Параметризованные типы инвариантны. Проверка компилятором:

Main.java:11: error: incompatible types: List<Integer> cannot be converted to List<Number>
        List<Number> nums = ints;
                            ^

Причина практическая: если бы присваивание прошло, через nums.add(3.14) в список целых можно было бы положить Double. Массивы в Java, наоборот, ковариантны (Object[] arr = new String[1] компилируется), и та же ошибка всплывает только при запуске как ArrayStoreException. Дженерики закрывают эту дыру на этапе компиляции.

Wildcard: ? extends, ? super и правило PECS

Если метод должен принимать и List<Integer>, и List<Double>, нужен wildcard. Направление зависит от того, читает метод из коллекции или пишет в нее.

import java.util.ArrayList;
import java.util.List;

public class Main {

    // ? extends: только читаем (производитель)
    static double sum(List<? extends Number> nums) {
        double total = 0;
        for (Number n : nums) total += n.doubleValue();
        return total;
    }

    // ? super: только кладем (потребитель)
    static void fillWithOnes(List<? super Integer> target, int count) {
        for (int i = 0; i < count; i++) target.add(1);
    }

    public static void main(String[] args) {
        List<Integer> ints = List.of(1, 2, 3);
        List<Double> doubles = List.of(0.5, 1.5);
        System.out.println(sum(ints) + " " + sum(doubles));

        List<Number> numbers = new ArrayList<>();
        List<Object> objects = new ArrayList<>();
        fillWithOnes(numbers, 2);
        fillWithOnes(objects, 3);
        System.out.println(numbers + " " + objects);
    }
}

Результат: 6.0 2.0 и [1, 1] [1, 1, 1]. А попытка записать в List<? extends Number> не компилируется, потому что реальный тип элементов неизвестен (это может быть и List<Double>):

Main.java:6: error: incompatible types: int cannot be converted to CAP#1
        nums.add(1);
                 ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number

Правило PECS (Producer Extends, Consumer Super, формулировка Джошуа Блоха из Effective Java) сводит выбор к таблице:

Что делает метод с коллекцией Wildcard Можно Нельзя
Только читает (коллекция — производитель) ? extends T получать элементы как T добавлять (кроме null)
Только пишет (коллекция — потребитель) ? super T добавлять T читать как T (только как Object)
И читает, и пишет T без wildcard все —
Тип не важен (размер, очистка) ? методы, не зависящие от типа добавлять элементы

Живой пример из JDK: Collections.copy(List<? super T> dest, List<? extends T> src) — источник производит, приемник потребляет.

Стирание типов и его последствия

Компилятор проверяет типы, а затем стирает параметры: T заменяется на границу (Object или, например, Comparable), в местах чтения вставляются приведения. Так дженерики сохранили совместимость с кодом до Java 5. Проверка в Java 25:

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true
System.out.println(a.getClass().getName());       // java.util.ArrayList

Что из этого следует на практике (ошибки javac 25 из реального прогона):

Что пытаемся сделать Ошибка javac Как обойти
new T() unexpected type ... found: type parameter T передать фабрику Supplier<T> или Class<T>
new T[n] generic array creation List<T> или массив от переданного Class<T>
o instanceof List<String> (o — Object) Object cannot be safely cast to List<String> o instanceof List<?>, элементы проверять отдельно
List<int> unexpected type ... required: reference found: int List<Integer> (автоупаковка)
два print(List<String>) и print(List<Integer>) name clash: ... have the same erasure разные имена методов

Обход через фабрику, проверенный прогоном:

static <T> List<T> fill(Supplier<T> factory, int n) {
    List<T> out = new ArrayList<>();
    for (int i = 0; i < n; i++) out.add(factory.get());
    return out;
}
// fill(StringBuilder::new, 2).size() -> 2

Raw types и загрязнение кучи

Raw type (List без аргумента) оставлен ради старого кода. Смешивание сырого и параметризованного типа отключает проверку и дает загрязнение кучи (heap pollution): в List<String> оказывается Integer, а падает программа не в месте ошибки, а позже, при чтении.

List<String> a = new ArrayList<>();
List raw = a;                // warning: [rawtypes]
raw.add(42);                 // warning: [unchecked], но компилируется
System.out.println(a.size()); // 1
String first = a.get(0);     // ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

Практическое правило: не писать raw types в новом коде, собирать проект с -Xlint:unchecked,rawtypes и не подавлять предупреждение @SuppressWarnings("unchecked"), пока не доказано, что приведение безопасно.

Соглашение об именах параметров

Имена параметров типа по соглашению — одна заглавная буква, чтобы их не путать с именами классов:

Буква Смысл Где встречается
T Type, тип Box<T>, Optional<T>
E Element, элемент List<E>, Set<E>
K, V Key, Value Map<K, V>
N Number числовые утилиты
R Result Function<T, R>
S, U второй, третий тип BiFunction<T, U, R>

Это соглашение, не правило языка: имя <Item> скомпилируется, но читатель кода примет его за класс.

Если не компилируется: симптом -> причина

Симптом Вероятная причина Что сделать
List<Integer> cannot be converted to List<Number> инвариантность параметр метода List<? extends Number>
cannot be converted to CAP#1 запись в ? extends ? super T или конкретный T
Note: ... uses unchecked or unsafe operations raw type или непроверенное приведение пересобрать с -Xlint:unchecked и найти строку
ClassCastException там, где приведений не видно heap pollution через raw type искать raw types выше по потоку данных
generic array creation массив из T List<T>

Выводы

  • Дженерики — параметризация типов: Box<T>, List<String>, <T> T method(...). Главная польза — ошибки типов ловит компилятор, приведения не нужны.
  • Параметризованные типы инвариантны: List<Integer> не List<Number>. Гибкость дают wildcard ? extends (чтение) и ? super (запись) по правилу PECS.
  • Ограничение T extends X открывает методы X внутри обобщенного кода; границ может быть несколько.
  • Из-за стирания типов нельзя new T(), new T[], instanceof List<String>, List<int> и перегрузка по аргументу типа.
  • Raw types оставлены для совместимости и ведут к heap pollution; в новом коде — только параметризованные типы и сборка с -Xlint.

Где применяется / связь с практикой

Дженерики — повседневный инструмент Java-разработчика: на них построены коллекции, Optional, Stream API, функциональные интерфейсы, репозитории Spring Data и типизированные DTO. На собеседованиях про PECS и стирание типов спрашивают почти всегда, потому что без них не понять сигнатуры JDK. Как устроены сами коллекции, на которых дженерики применяются чаще всего, разобрано в статье о коллекциях в Java.

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

Если хочется системно пройти обобщенное программирование, коллекции, Stream API и многопоточность на реальных задачах, посмотрите курс «Java разработчик. Продвинутый уровень». Разобрать отдельную тему с преподавателем бесплатно можно на открытых уроках Otus.

FAQ

Чем дженерики Java отличаются от шаблонов C++?
Шаблон C++ порождает отдельный код для каждого аргумента типа, а в Java один класс после стирания обслуживает все параметризации. Поэтому в Java нельзя параметризовать примитивом и узнать T во время выполнения.

Можно ли узнать тип T во время выполнения?
Напрямую нет. Обычно передают Class<T> в конструктор; тип из сигнатуры наследника (class Repo extends Base<User>) можно прочитать через рефлексию getGenericSuperclass().

Что такое List<?> и чем он отличается от raw List?
List<?> — список неизвестного, но конкретного типа: компилятор не даст добавить в него элемент, кроме null. Raw List проверку отключает и позволяет положить что угодно.

Есть ли дженерики у статических полей?
Нет: параметр типа класса принадлежит экземпляру, а статическое поле общее для всех параметризаций, поэтому static T value не компилируется. У статических методов свой параметр объявляется отдельно: static <T> ....

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)