Generics в Java: пишем гибкий код без (ClassCastException)

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Дженерики в Java: как перестать бояться угловых скобок и начать писать надёжный код

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

Представьте: вы пишете метод, который должен работать и с числами, и со строками, и с собственными классами. Без дженериков пришлось бы плодить десятки перегруженных методов или использовать Object и постоянно проверять типы вручную. Обобщения Java решают эту проблему элегантно и безопасно.

Почему без дженериков код превращается в минное поле?

Давайте вспомним, как жили программисты до появления generics (до Java 5). Если вы когда-нибудь писали ArrayList без указания типа, вы знаете этот кошмар:

List list = new ArrayList();
list.add("Привет");
list.add(42);
list.add(new Object());

// Где-то через 100 строк кода:
String text = (String) list.get(1); // ClassCastException в рантайме!

Классическая проблема: ClassCastException — ошибка, которая проявляется только во время выполнения. Вы могли передать список в другой метод, а там кто-то уверен, что в нём только строки. И бам! Приложение падает. Дженерики в Java переносят проверку типов на этап компиляции — это как страховка, которая срабатывает до того, как вы сели за руль.

Generics в Java: базовый синтаксис, который нужно знать

В основе дженериков лежит простая идея: мы параметризуем класс или метод типом. Вместо конкретного типа пишем параметр типа (обычно одна заглавная буква: T, E, K, V).

Самый популярный пример — коллекции Java:

// Без дженерика — опасно
List rawList = new ArrayList();

// С дженериком — безопасно
List<String> safeList = new ArrayList<>();
safeList.add("Java");
safeList.add("Generics");
// safeList.add(123); // Ошибка компиляции!

Компилятор теперь знает: внутри safeList могут быть только строки. Попытка добавить число вызовет ошибку на этапе компиляции, а не в рантайме. Это и есть безопасность типов Java в действии.

Создаём свой первый generic-класс

Допустим, мы пишем простой контейнер для хранения одного значения. Без дженериков пришлось бы использовать Object:

public class Box {
    private Object value;

    public void set(Object value) { this.value = value; }
    public Object get() { return value; }
}

А теперь — с дженериком:

public class Box<T> {
    private T value;

    public void set(T value) { this.value = value; }
    public T get() { return value; }
}

// Использование:
Box<Integer> intBox = new Box<>();
intBox.set(100);
int value = intBox.get(); // Без каста!

Обратите внимание: нам больше не нужно писать (Integer) intBox.get(). Компилятор сам знает, что get() вернёт Integer. Это и есть магия обобщений Java.

Типизация в Java: ограничения и wildcards

Дженерики были бы слишком простыми, если бы не было нюансов. Главный из них — bounded type parameters (ограниченные параметры типа). Иногда нужно сказать: "Тип T должен быть числом" или "T должен реализовывать интерфейс Comparable".

// T может быть только Number или его подклассом
public class Calculator<T extends Number> {
    private T value;

    public double square() {
        return value.doubleValue() * value.doubleValue();
    }
}

// Работает:
Calculator<Integer> intCalc = new Calculator<>();
Calculator<Double> doubleCalc = new Calculator<>();
// Ошибка: Calculator<String> — не скомпилируется

А что, если мы хотим принимать коллекцию, которая содержит числа ИЛИ их потомков? Тут в игру вступают wildcards (маски) — ?:

public void printNumbers(List<? extends Number> list) {
    for (Number n : list) {
        System.out.println(n);
    }
}

// Можно передать:
List<Integer> ints = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.1, 2.2);
printNumbers(ints);   // Работает
printNumbers(doubles); // Работает

PECS: правило, которое спасёт вас на собеседовании

В Java generics tutorial есть одно золотое правило — PECS (Producer Extends, Consumer Super). Оно помогает понять, когда использовать ? extends, а когда ? super:

  • Producer Extends — если вы только читаете данные из коллекции (она производит элементы), используйте ? extends T.
  • Consumer Super — если вы только записываете данные в коллекцию (она потребляет элементы), используйте ? super T.
  • Ни то, ни другое — если и читаете, и пишете, используйте точный тип T.
// Producer — только читаем
public void copy(List<? extends Number> source, List<? super Number> dest) {
    for (Number n : source) {
        dest.add(n); // source — producer, dest — consumer
    }
}

Запомните PECS — и вы будете уверенно работать с wildcards Java в любом проекте.

Raw types и стирание типов: под капотом дженериков

В Java есть понятие raw types — это использование generic-класса без указания типа. Например, List list = new ArrayList(). В современных проектах так писать не стоит, но legacy-код полон таких примеров.

Почему raw types существуют до сих пор? Из-за стирания типов (type erasure). Java-компилятор удаляет информацию о generic-типах на этапе компиляции, заменяя их на Object или на границу (bound). Это сделано для обратной совместимости с кодом, написанным до Java 5.

// Raw type — компилятор выдаст предупреждение
List rawList = new ArrayList();
rawList.add("строка");
rawList.add(123);

// Generic — безопасно
List<String> stringList = new ArrayList<>();
stringList.add("только                

Блоги

Рекомендаций по книгам