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

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

исходный код компилятор выполнение List<String> listlist.add("привет")list.add(42)String s = list.get(0) <String> что можно класть "привет" — ок 42 — ошибка ArrayList <String> "привет"элементы — Objectскобок больше нет get() вернул String: приведение вставил компилятор

Угловые скобки живут до конца компиляции: по ним компилятор отбраковывает 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; ниже оба варианта по очереди.

producer метод читает ? extends Number элемент как Number consumer метод кладёт ? super Integer кладём Integer

Верхняя строка - список, из которого метод только читает, он объявлен через ? 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.