☕ Java

Дженерики и параметрические типы в Java: подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь себе обычную коробку. Ты можешь положить в неё что угодно: книгу, игрушку, яблоко. Но если ты подпишешь коробку «Книги», то будешь класть туда только книги. Если подпишешь «Игрушки» — только игрушки. А если оставить коробку без подписи — в неё можно положить всё подряд, но при этом легко запутаться и достать вместо книги яблоко.

Дженерики (generics) в Java — это как раз такие «умные коробки» с подписями. Они позволяют создать один класс или метод, который будет работать с разными типами данных, но при этом строго контролировать, какой именно тип используется в каждом конкретном случае.

Зачем это нужно? Представь, что ты пишешь программу для библиотеки. Тебе нужно хранить список книг. Без дженериков ты бы написал:

// Без дженериков — опасно и неудобно
ArrayList список = new ArrayList();
список.add("Война и мир");      // строка
список.add(42);                 // число — тоже можно!
String книга = (String) список.get(0); // нужно явное приведение
// А если достать число и привести к строке — программа упадёт!

С дженериками всё становится безопасно и понятно:

// С дженериками — безопасно и удобно
ArrayList<String> список = new ArrayList<>();
список.add("Война и мир");      // только строки
// список.add(42);              // ошибка компиляции — число не положить!
String книга = список.get(0);   // без приведения — тип известен
Важно: Дженерики работают на этапе компиляции. После компиляции информация о типах стирается — это называется «стирание типов» (type erasure). Но об этом позже.

Таким образом, дженерики дают нам три главные вещи:

  • Безопасность типов — компилятор не даст положить кота в коробку для книг
  • Устранение явных приведений — не нужно писать (String) каждый раз
  • Переиспользование кода — один класс для всех типов

Теория: как это работает изнутри

Давай заглянем «под капот» Java и разберёмся, как устроены дженерики на самом деле.

Параметрический полиморфизм

В программировании есть понятие полиморфизма — способность одного и того же кода работать с разными типами данных. Дженерики реализуют параметрический полиморфизм: класс или метод принимает тип как параметр (как число в математической функции).

Стирание типов (Type Erasure)

Это самое важное, что нужно понять про дженерики в Java. В отличие от C++ или C#, Java не хранит информацию о дженериках во время выполнения программы. Компилятор проверяет типы, а затем «стирает» их, заменяя на границы (или на Object, если границ нет).

// Исходный код с дженериками
public class Box<T> {
    private T content;
    
    public void set(T content) {
        this.content = content;
    }
    
    public T get() {
        return content;
    }
}

// После компиляции (стирание типов) — примерно так:
public class Box {
    private Object content;  // T заменился на Object
    
    public void set(Object content) {
        this.content = content;
    }
    
    public Object get() {
        return content;
    }
}
Важно: Из-за стирания типов нельзя создать массив дженериков (new ArrayList<String>[10] — ошибка!) и нельзя использовать instanceof с параметризованными типами.

Границы типов (Bounded Type Parameters)

Иногда нужно ограничить, какие типы можно использовать. Например, чтобы в коробку можно было класть только числа. Для этого существуют границы:

  • <T extends Number> — T может быть только Number или его подклассами
  • <T super Integer> — T может быть Integer или его суперклассами (используется реже)

Вариативность (Wildcards)

Иногда мы не знаем точный тип, но хотим работать с семейством типов. Для этого используются wildcards (символы подстановки):

  • ? — неизвестный тип (любой)
  • ? extends Number — любой тип, наследующий Number
  • ? super Integer — любой тип, являющийся предком Integer

Синтаксис в Java: все формы и варианты

Давай разберём все возможные способы использования дженериков в Java. Это как таблица умножения — нужно знать все варианты.

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<>("Возраст", 25);

2. Обобщённые методы

// Метод, который работает с любым типом
public class Utils {
    public static <T> T getMiddle(T... args) {
        return args[args.length / 2];
    }
}

// Вызов:
String middle = Utils.<String>getMiddle("один", "два", "три");
// Или проще (Java сам выведет тип):
String middle2 = Utils.getMiddle("один", "два", "три");

3. Обобщённые интерфейсы

// Интерфейс с параметром типа
public interface Repository<T> {
    void save(T item);
    T findById(int id);
    void delete(T item);
}

// Реализация для конкретного типа
public class UserRepository implements Repository<User> {
    @Override
    public void save(User user) { /* ... */ }
    
    @Override
    public User findById(int id) { /* ... */ }
    
    @Override
    public void delete(User user) { /* ... */ }
}

4. Ограниченные параметры типа

// Только числа и их подклассы
public class NumberBox<T extends Number> {
    private T value;
    
    public double doubleValue() {
        return value.doubleValue(); // можно вызывать методы Number
    }
}

// Использование:
NumberBox<Integer> intBox = new NumberBox<>();
NumberBox<Double> doubleBox = new NumberBox<>();
// NumberBox<String> stringBox = new NumberBox<>(); // Ошибка!

5. Множественные границы

// Тип должен наследовать ClassA и реализовывать InterfaceB
public class MultiBound<T extends ClassA & InterfaceB> {
    private T item;
    
    public void process() {
        item.methodFromA();     // метод из ClassA
        item.methodFromB();     // метод из InterfaceB
    }
}

6. Wildcards (символы подстановки)

// ? extends — ковариантность (можно читать)
public void printNumbers(List<? extends Number> list) {
    for (Number n : list) {
        System.out.println(n);
    }
    // list.add(42); // Ошибка! Нельзя добавлять
}

// ? super — контравариантность (можно писать)
public void addNumbers(List<? super Integer> list) {
    list.add(42);           // можно добавить Integer
    list.add(100);          // можно добавить Integer
    // Integer i = list.get(0); // Ошибка! Нельзя читать как Integer
}

// ? — неизвестный тип
public void printList(List<?> list) {
    for (Object elem : list) {
        System.out.print(elem + " ");
    }
}

7. Наследование обобщённых типов

// Важно: List<String> не является подтипом List<Object>!
List<String> strings = new ArrayList<>();
// List<Object> objects = strings; // Ошибка компиляции!

// Но List<String> является подтипом Collection<String>
Collection<String> coll = strings; // Работает!

Примеры кода — от простого к сложному

Теперь давай посмотрим на реальные примеры, которые ты можешь использовать в своих проектах.

Пример 1: Простая обобщённая коробка

// Самая простая реализация — коробка для одного предмета
public class SimpleBox<T> {
    private T item;  // Храним предмет любого типа
    
    // Положить предмет в коробку
    public void put(T item) {
        this.item = item;
    }
    
    // Достать предмет из коробки
    public T get() {
        return item;
    }
    
    // Проверить, пуста ли коробка
    public boolean isEmpty() {
        return item == null;
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        // Коробка для строк
        SimpleBox<String> stringBox = new SimpleBox<>();
        stringBox.put("Привет, мир!");
        String message = stringBox.get();  // Без приведения!
        System.out.println(message);       // Выведет: Привет, мир!
        
        // Коробка для чисел
        SimpleBox<Integer> intBox = new SimpleBox<>();
        intBox.put(42);
        int number = intBox.get();         // Без приведения!
        System.out.println(number);        // Выведет: 42
        
        // stringBox.put(123); // Ошибка! В коробку для строк нельзя положить число
    }
}

Пример 2: Обобщённый метод для поиска максимума

// Метод находит максимальный элемент в массиве
// T должен реализовывать Comparable (чтобы можно было сравнивать)
public class ArrayUtils {
    public static <T extends Comparable<T>> T findMax(T[] array) {
        if (array == null || array.length == 0) {
            throw new IllegalArgumentException("Массив не может быть пустым");
        }
        
        T max = array[0];  // Предполагаем, что первый элемент — максимальный
        
        // Проходим по всем элементам
        for (int i = 1; i < array.length; i++) {
            // compareTo возвращает:
            // положительное число, если текущий больше
            // 0, если равны
            // отрицательное, если текущий меньше
            if (array[i].compareTo(max) > 0) {
                max = array[i];  // Нашли новый максимум
            }
        }
        
        return max;
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        // Массив строк
        String[] words = {"яблоко", "банан", "вишня", "арбуз"};
        String maxWord = ArrayUtils.findMax(words);
        System.out.println("Самое длинное слово: " + maxWord);
        // Выведет: Самое длинное слово: яблоко (по алфавиту)
        
        // Массив чисел
        Integer[] numbers = {10, 5, 8, 20, 3};
        Integer maxNumber = ArrayUtils.findMax(numbers);
        System.out.println("Максимальное число: " + maxNumber);
        // Выведет: Максимальное число: 20
    }
}

Пример 3: Обобщённый стек

// Реализация стека (LIFO — последний пришёл, первый ушёл)
import java.util.ArrayList;
import java.util.List;

public class GenericStack<T> {
    private List<T> elements;  // Внутреннее хранилище
    
    // Конструктор
    public GenericStack() {
        this.elements = new ArrayList<>();
    }
    
    // Положить элемент на стек
    public void push(T element) {
        elements.add(element);  // Добавляем в конец списка
    }
    
    // Взять элемент со стека (удаляет его)
    public T pop() {
        if (isEmpty()) {
            throw new IllegalStateException("Стек пуст!");
        }
        // Берём последний элемент и удаляем его
        return elements.remove(elements.size() - 1);
    }
    
    // Посмотреть верхний элемент (не удаляя)
    public T peek() {
        if (isEmpty()) {
            throw new IllegalStateException("Стек пуст!");
        }
        return elements.get(elements.size() - 1);
    }
    
    // Проверить, пуст ли стек
    public boolean isEmpty() {
        return elements.isEmpty();
    }
    
    // Узнать размер стека
    public int size() {
        return elements.size();
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        GenericStack<String> bookStack = new GenericStack<>();
        
        // Кладём книги на стек
        bookStack.push("Война и мир");
        bookStack.push("Преступление и наказание");
        bookStack.push("Мастер и Маргарита");
        
        System.out.println("Верхняя книга: " + bookStack.peek());
        // Выведет: Верхняя книга: Мастер и Маргарита
        
        System.out.println("Достаём: " + bookStack.pop());
        // Выведет: Достаём: Мастер и Маргарита
        
        System.out.println("Теперь верхняя: " + bookStack.peek());
        // Выведет: Теперь верхняя: Преступление и наказание
        
        System.out.println("Размер стека: " + bookStack.size());
        // Выведет: Размер стека: 2
    }
}

Пример 4: Обобщённый кэш с ограничением по времени

// Кэш, который хранит объекты с временем жизни
import java.util.HashMap;
import java.util.Map;

public class TimedCache<K, V> {
    // Внутренний класс для хранения значения и времени
    private static class CacheEntry<V> {
        V value;
        long expirationTime;  // Время в миллисекундах
        
        CacheEntry(V value, long ttlMillis) {
            this.value = value;
            this.expirationTime = System.currentTimeMillis() + ttlMillis;
        }
        
        boolean isExpired() {
            return System.currentTimeMillis() > expirationTime;
        }
    }
    
    private Map<K, CacheEntry<V>> cache = new HashMap<>();
    private long defaultTtl;  // Время жизни по умолчанию
    
    public TimedCache(long defaultTtlMillis) {
        this.defaultTtl = defaultTtlMillis;
    }
    
    // Положить значение в кэш
    public void put(K key, V value) {
        cache.put(key, new CacheEntry<>(value, defaultTtl));
    }
    
    // Положить значение с собственным временем жизни
    public void put(K key, V value, long ttlMillis) {
        cache.put(key, new CacheEntry<>(value, ttlMillis));
    }
    
    // Получить значение из кэша
    public V get(K key) {
        CacheEntry<V> entry = cache.get(key);
        if (entry == null) {
            return null;  // Нет такого ключа
        }
        if (entry.isExpired()) {
            cache.remove(key);  // Удаляем просроченное
            return null;
        }
        return entry.value;
    }
    
    // Очистить просроченные записи
    public void cleanExpired() {
        cache.entrySet().removeIf(entry -> entry.getValue().isExpired());
    }
    
    public int size() {
        return cache.size();
    }
}

// Использование:
public class Main {
    public static void main(String[] args) throws InterruptedException {
        // Кэш с временем жизни 2 секунды
        TimedCache<String, String> sessionCache = new TimedCache<>(2000);
        
        sessionCache.put("user1", "Анна");
        sessionCache.put("user2", "Борис");
        
        System.out.println("user1: " + sessionCache.get("user1"));  // Анна
        
        Thread.sleep(3000);  // Ждём 3 секунды
        
        System.out.println("user1 после 3с: " + sessionCache.get("user1"));  // null
        System.out.println("Размер кэша: " + sessionCache.size());  // 1 (user2 ещё не проверили)
    }
}

Пример 5: Обобщённый билдер (строитель)

// Паттерн Builder с дженериками — для создания сложных объектов
public class GenericBuilder<T> {
    private T instance;
    
    // Конструктор принимает экземпляр объекта
    public GenericBuilder(T instance) {
        this.instance = instance;
    }
    
    // Метод для установки значения через сеттер
    public GenericBuilder<T> with(java.util.function.Consumer<T> setter) {
        setter.accept(instance);
        return this;  // Возвращаем this для цепочки вызовов
    }
    
    // Получить готовый объект
    public T build() {
        return instance;
    }
}

// Класс, который будем строить
class User {
    private String name;
    private int age;
    private String email;
    
    // Сеттеры возвращают this для цепочки
    public User setName(String name) { this.name = name; return this; }
    public User setAge(int age) { this.age = age; return this; }
    public User setEmail(String email) { this.email = email; return this; }
    
    @Override
    public String toString() {
        return "User{name='" + name + "', age=" + age + ", email='" + email + "'}";
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        User user = new GenericBuilder<>(new User())
            .with(u -> u.setName("Иван"))
            .with(u -> u.setAge(25))
            .with(u -> u.setEmail("ivan@example.com"))
            .build();
        
        System.out.println(user);
        // Выведет: User{name='Иван', age=25, email='ivan@example.com'}
    }
}

Пример 6: Обобщённый EventBus (шина событий)

// Простая реализация шины событий с дженериками
import java.util.*;
import java.util.function.Consumer;

public class EventBus {
    // Храним подписчиков по типу события
    private Map<Class<?>, List<Consumer<?>>> subscribers = new HashMap<>();
    
    // Подписаться на событие определённого типа
    public <T> void subscribe(Class<T> eventType, Consumer<T> handler) {
        subscribers.computeIfAbsent(eventType, k -> new ArrayList<>())
                  .add(handler);
    }
    
    // Опубликовать событие
    @SuppressWarnings("unchecked")
    public <T> void publish(T event) {
        Class<?> eventType = event.getClass();
        List<Consumer<?>> handlers = subscribers.get(eventType);
        
        if (handlers != null) {
            for (Consumer<?> handler : handlers) {
                // Безопасное приведение, так как мы знаем тип
                ((Consumer<T>) handler).accept(event);
            }
        }
    }
}

// Классы событий
class UserLoginEvent {
    String username;
    UserLoginEvent(String username) { this.username = username; }
}

class UserLogoutEvent {
    String username;
    UserLogoutEvent(String username) { this.username = username; }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        EventBus bus = new EventBus();
        
        // Подписываемся на события входа
        bus.subscribe(UserLoginEvent.class, event -> {
            System.out.println("Пользователь " + event.username + " вошёл в систему");
        });
        
        // Подписываемся на события выхода
        bus.subscribe(UserLogoutEvent.class, event -> {
            System.out.println("Пользователь " + event.username + " вышел из системы");
        });
        
        // Публикуем события
        bus.publish(new UserLoginEvent("Анна"));
        bus.publish(new UserLoginEvent("Борис"));
        bus.publish(new UserLogoutEvent("Анна"));
        
        // Выведет:
        // Пользователь Анна вошёл в систему
        // Пользователь Борис вошёл в систему
        // Пользователь Анна вышел из системы
    }
}

Пример 7: Обобщённый DAO (Data Access Object)

// Абстрактный класс для работы с базой данных
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

// Базовый класс для всех сущностей
abstract class Entity {
    private int id;
    
    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
}

// Конкретная сущность
class Product extends Entity {
    private String name;
    private double price;
    
    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }
    
    public String getName() { return name; }
    public double getPrice() { return price; }
    
    @Override
    public String toString() {
        return "Product{id=" + getId() + ", name='" + name + "', price=" + price + "}";
    }
}

// Обобщённый DAO
public class GenericDAO<T extends Entity> {
    private Map<Integer, T> storage = new ConcurrentHashMap<>();
    private int nextId = 1;
    
    // Сохранить сущность
    public void save(T entity) {
        if (entity.getId() == 0) {
            entity.setId(nextId++);  // Присваиваем новый ID
        }
        storage.put(entity.getId(), entity);
    }
    
    // Найти по ID
    public Optional<T> findById(int id) {
        return Optional.ofNullable(storage.get(id));
    }
    
    // Найти все сущности
    public List<T> findAll() {
        return new ArrayList<>(storage.values());
    }
    
    // Удалить по ID
    public boolean delete(int id) {
        return storage.remove(id) != null;
    }
    
    // Обновить сущность
    public void update(T entity) {
        if (storage.containsKey(entity.getId())) {
            storage.put(entity.getId(), entity);
        } else {
            throw new IllegalArgumentException("Сущность с ID " + entity.getId() + " не найдена");
        }
    }
    
    public int count() {
        return storage.size();
    }
}

// Использование:
public class Main {
    public static void main(String[] args) {
        GenericDAO<Product> productDAO = new GenericDAO<>();
        
        // Создаём продукты
        Product laptop = new Product("Ноутбук", 75000);
        Product phone = new Product("Смартфон", 45000);
        
        // Сохраняем
        productDAO.save(laptop);
        productDAO.save(phone);
        
        System.out.println("Все продукты: " + productDAO.findAll());
        // Выведет: Все продукты: [Product{id=1, name='Ноутбук', price=75000.0}, Product{id=2, name='Смартфон', price=45000.0}]
        
        // Ищем по ID
        Optional<Product> found = productDAO.findById(1);
        found.ifPresent(p -> System.out.println("Найден: " + p));
        // Выведет: Найден: Product{id=1, name='Ноутбук', price=75000.0}
        
        // Удаляем
        productDAO.delete(2);
        System.out.println("После удаления: " + productDAO.count());
        // Выведет: После удаления: 1
    }
}

Сравнение с Python: чем похоже и чем отличается

Python — это язык с динамической типизацией, где типы проверяются во время выполнения. Java — статически типизированный язык. Это приводит к разным подходам к обобщённому программированию.

Характеристика Java (дженерики) Python (типизация)
Проверка типов На этапе компиляции На этапе выполнения (или с mypy/Pyright)
Синтаксис List<String> list[str] (с Python 3.9+)
Стирание типов Да, типы стираются после компиляции Нет, типы существуют во время выполнения (но не проверяются)
Производительность Нет накладных расходов (стирание типов) Есть накладные расходы на проверку типов
Ограничения Нельзя создать массив дженериков Нет ограничений (можно всё)
Wildcards ? extends T, ? super T TypeVar с bound/covariant/contravariant
Type hints Обязательны для дженериков Опциональны (PEP 484)

Пример на Python для сравнения:

# Python с type hints (начиная с Python 3.5)
from typing import TypeVar, Generic, List

T = TypeVar('T')

class Box(Generic[T]):
    def __init__(self):
        self._item = None
    
    def put(self, item: T) -> None:
        self._item = item
    
    def get(self) -> T:
        return self._item

# Использование (типы не проверяются во время выполнения!)
box = Box[str]()
box.put("Привет")
message = box.get()  # Тип str, но Python не проверяет

# Можно положить что угодно — Python не остановит
box.put(42)  # Ошибки не будет! Типы только для документации
Важно: В Python type hints — это только подсказки для разработчика и инструментов вроде mypy. Java же заставляет соблюдать типы на этапе компиляции. Это делает Java более строгим, но и более безопасным языком.

Типичные ошибки и подводные камни

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

Ошибка 1: Создание массива дженериков

// Ошибка! Нельзя создать массив параметризованного типа
List<String>[] arrayOfLists = new List<String>[10]; // Ошибка компиляции!

// Правильное решение:
List<String>[] arrayOfLists = new List[10]; // Сырой тип (raw type) — предупреждение
// Или лучше использовать ArrayList:
List<List<String>> listOfLists = new ArrayList<>();

Ошибка 2: Использование instanceof с дженериками

// Ошибка! Нельзя проверить параметризованный тип
if (obj instanceof List<String>) { // Ошибка компиляции!
    // ...
}

// Правильное решение:
if (obj instanceof List) { // Проверяем сырой тип
    List<?> list = (List<?>) obj; // Затем приводим с wildcard
    // ...
}

Ошибка 3: Путаница между extends и super

// Частая ошибка: использование extends для записи
List<? extends Number> numbers = new ArrayList<Integer>();
// numbers.add(42); // Ошибка! Нельзя добавлять через ? extends

// Правильно: для записи используем ? super
List<? super Integer> integers = new ArrayList<Number>();
integers.add(42); // Работает!

Ошибка 4: Игнорирование raw types (сырых типов)

// Плохо: использование сырого типа
List list = new ArrayList(); // Raw type — теряем все преимущества дженериков
list.add("строка");
list.add(42); // Можно добавить что угодно

// Хорошо: всегда указываем тип
List<String> list2 = new ArrayList<>();
list2.add("строка");
// list2.add(42); // Ошибка компиляции

Ошибка 5: Перегрузка методов с разными дженериками

// Ошибка! Из-за стирания типов методы становятся одинаковыми
public void process(List<String> list) { /* ... */ }
public void process(List<Integer> list) { /* ... */ } // Ошибка компиляции!

// Правильное решение: разные имена методов
public void processStrings(List<String> list) { /* ... */ }
public void processIntegers(List<Integer> list) { /* ... */ }

Ошибка 6: Неправильное использование wildcard в return type

// Плохо: возвращаем wildcard — вызывающий код не знает тип
public List<?> getList() {
    return new ArrayList<String>();
}

// Хорошо: возвращаем конкретный тип или используем bounded wildcard
public List<String> getList() {
    return new ArrayList<String>();