Дженерики в 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("только