Введение: Проблема, которую решают Generics
Представьте, что вы пишете метод для сортировки списка чисел. Вы пишете его для Integer, а через неделю вам нужно отсортировать список String. Приходится создавать новый метод или использовать Object и постоянно делать приведение типов. Это неудобно, небезопасно и ведёт к ошибкам во время выполнения.
Именно эту проблему решают Generics (дженерики) в Java. Они позволяют создавать классы, интерфейсы и методы, которые работают с разными типами данных, но при этом сохраняют строгую типизацию на этапе компиляции. Без дженериков современная Java была бы гораздо менее удобной и безопасной.
В этой статье мы разберём, зачем нужны Generics, как они устроены и, самое главное, как применять их на реальных примерах.
1. Основы Generics: что это и как работает
1.1. Проблема "сырых" типов (raw types)
До появления Java 5 (2004 год) разработчики использовали коллекции без указания типа:
List list = new ArrayList();
list.add("Hello");
list.add(123);
// При получении нужно помнить, какой тип лежит
String text = (String) list.get(0); // ОК
Integer num = (Integer) list.get(1); // ОК
// Но если ошибиться — ClassCastException в runtime
String error = (String) list.get(1); // Ошибка!
Такой код опасен: ошибка обнаруживается только при запуске программы.
1.2. Как работают Generics
Generics позволяют указать тип данных, с которым будет работать класс или метод, прямо в угловых скобках <T>. Компилятор проверяет типы на этапе компиляции и автоматически подставляет правильные приведения.
Базовый синтаксис:
// Объявление обобщённого класса
public class Box<T> {
private T content;
public void setContent(T content) {
this.content = content;
}
public T getContent() {
return content;
}
}
// Использование
Box<String> stringBox = new Box<>(); // <> — diamond operator (Java 7+)
stringBox.setContent("Привет");
String text = stringBox.getContent(); // Без приведения!
Box<Integer> intBox = new Box<>();
intBox.setContent(42);
int number = intBox.getContent(); // Без приведения!
// stringBox.setContent(123); // Ошибка компиляции!
1.3. Преимущества на практике
- Безопасность типов: ошибки выявляются на этапе компиляции, а не в runtime.
- Устранение явных приведений: код становится чище и читаемее.
- Повторное использование кода: один класс/метод работает с любым типом.
2. Generics на практике: классы, методы и интерфейсы
2.1. Обобщённые классы
Самый распространённый случай — коллекции. Но вы можете создавать свои обобщённые классы:
public class Pair<K, V> {
private K key;
private V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() { return key; }
public V getValue() { return value; }
}
// Использование
Pair<String, Integer> pair = new Pair<>("Возраст", 30);
String key = pair.getKey();
Integer value = pair.getValue();
2.2. Обобщённые методы
Метод может иметь свои собственные параметры типа, не связанные с классом:
public class Utils {
// Обобщённый метод для создания списка из массива
public static <T> List<T> arrayToList(T[] array) {
List<T> list = new ArrayList<>();
for (T element : array) {
list.add(element);
}
return list;
}
}
// Использование
String[] names = {"Анна", "Борис", "Виктор"};
List<String> nameList = Utils.arrayToList(names);
2.3. Обобщённые интерфейсы
Интерфейсы также могут быть параметризованы. Классический пример — Comparable<T>:
public interface Comparable<T> {
int compareTo(T o);
}
// Реализация для своего класса
public class Person implements Comparable<Person> {
private String name;
private int age;
@Override
public int compareTo(Person other) {
return Integer.compare(this.age, other.age);
}
}
3. Ограниченные типы (Bounded Type Parameters)
3.1. Зачем нужны ограничения
Иногда нужно, чтобы дженерик работал только с определённой группой типов. Например, если мы хотим складывать числа, нам нужно ограничить тип числовыми классами.
Синтаксис: <T extends Number>
public class Calculator<T extends Number> {
public double sum(T a, T b) {
return a.doubleValue() + b.doubleValue();
}
}
// Использование
Calculator<Integer> intCalc = new Calculator<>();
System.out.println(intCalc.sum(5, 10)); // 15.0
Calculator<Double> doubleCalc = new Calculator<>();
System.out.println(doubleCalc.sum(3.5, 2.5)); // 6.0
// Calculator<String> errorCalc = new Calculator<>(); // Ошибка! String не extends Number
3.2. Множественные ограничения
Можно указать несколько границ через &:
public class DataProcessor<T extends Comparable<T> & Serializable> {
public void process(T data) {
// data можно сравнивать и сериализовать
System.out.println("Обработка: " + data);
}
}
Важно: класс должен быть первым, затем интерфейсы.
4. Wildcards (маски): ? extends и ? super
4.1. Проблема ковариантности
В Java List<Integer> не является подтипом List<Number>. Это сделано для безопасности. Но как передать список чисел в метод, ожидающий список чисел любого типа?
4.2. Wildcard с верхней границей (? extends)
Используется, когда