Generics в Java: зачем нужны и как использовать на практике

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

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

Начать курс

Введение: Проблема, которую решают 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)

Используется, когда

Блоги

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