Menu

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

Что такое дженерики в Java, как писать обобщённые классы и методы, ограниченные параметры типа, подстановочные знаки и почему важно стирание типов.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Зачем нужны дженерики

Обобщённый тип позволяет написать код один раз и переиспользовать его для множества типов, не жертвуя типобезопасностью. Вместо того чтобы писать отдельно IntBox, StringBox и UserBox, вы пишете одну Box<T>, где T - заполнитель, который подставляет вызывающий код.

Вы уже использовали дженерики всякий раз, когда писали ArrayList<String> или HashMap<String, Integer>. Часть <...> - это аргумент типа. На этой странице показано, как писать собственные.

Альтернатива - хранить всё как Object - выбрасывает информацию о типе и вынуждает делать уродливые, подверженные ошибкам приведения:

Приведение в последней строке выбрасывает ClassCastException во время выполнения - именно такое исключение дженерики призваны сделать невозможным.

Обобщённый класс

Объявите параметр типа в угловых скобках после имени класса. По соглашению это одна заглавная буква: T для «type» (тип), E для «element» (элемент), K/V для ключа/значения.

Внутри Box каждый T становится тем, что предоставил вызывающий код. Box<String> - это коробка, которая хранит и возвращает только String. Компилятор отклоняет name.set(99) ещё до того, как программа запустится.

Пустые <> справа (ромбовидный оператор) позволяют компилятору вывести аргумент типа из левой части, так что вам не нужно повторять <String> дважды.

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

У отдельного метода может быть собственный параметр типа, независимый от класса. Поставьте параметр <T> перед типом возвращаемого значения:

Вы никогда не передаёте T явно - компилятор выводит его из аргумента. Обобщённые методы - это то, благодаря чему утилиты вроде Collections.sort или List.of остаются типобезопасными для любого типа элементов.

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

Иногда обобщённый тип имеет смысл только для некоторых типов. extends ограничивает параметр, чтобы вы могли вызывать методы границы. Здесь T extends Number означает, что T - это Number или любой его подкласс (Integer, Double, ...), поэтому doubleValue() доступен:

Обратите внимание, что extends здесь означает «является подтипом» и работает как для классов, так и для интерфейсов - <T extends Comparable<T>> крайне распространено, когда нужно сравнивать элементы.

Подстановочные знаки: ? extends и ? super

Тонкая ловушка: List<Integer> не является List<Number>, хотя Integer - это Number. Дженерики инвариантны. Подстановочные знаки ослабляют это, когда вам нужно только читать или только писать.

Используйте ? extends T для производителя, из которого вы читаете, и ? super T для потребителя, в который вы пишете (правило «PECS» - Producer Extends, Consumer Super):

Список ? extends Number позволяет читать элементы как Number, но не добавлять в него (компилятор не может знать точный тип элемента). Список ? super Integer позволяет добавлять Integer, но чтение возвращает Object. Выбирайте подстановочный знак в соответствии с тем, как движутся данные.

Стирание типов и его ограничения

Дженерики - это возможность этапа компиляции. После компиляции параметр типа стирается - во время выполнения Box<String> и Box<Integer> - это просто Box. Это сохраняет обратную совместимость дженериков со старым кодом, но накладывает реальные ограничения.

// Ничего из этого не компилируется - параметра типа во время выполнения не существует:
T value = new T();          // нельзя создать экземпляр параметра типа
T[] array = new T[10];      // нельзя создать обобщённый массив
if (list instanceof List<String>) { } // нельзя проверить аргумент типа

Поскольку во время выполнения тип исчезает, вы не можете спросить через рефлексию «каким был T?», и не можете перегружать методы, различающиеся только обобщённым аргументом (foo(List<String>) и foo(List<Integer>) стираются до одной и той же сигнатуры). Когда тип действительно нужен во время выполнения, передавайте токен Class<T> как параметр конструктора или метода.

Далее: лямбда-выражения

Вы увидели, что обобщённый метод принимает тип в качестве параметра. Следующий шаг - рассматривать поведение как параметр. Лямбда-выражения позволяют передать фрагмент кода - функцию - в метод, и именно так вы будете сортировать, фильтровать и преобразовывать обобщённые коллекции, которые только что научились типизировать безопасно.

Часто задаваемые вопросы

Что такое дженерики в Java?

Дженерики позволяют написать класс или метод, который работает с типом, указываемым позже, вместо того чтобы привязывать его к одному конкретному типу. Вы объявляете параметр типа в угловых скобках - class Box<T> - а вызывающий код подставляет реальный тип - Box<String>. Затем компилятор обеспечивает соблюдение этого типа повсюду, так что вы ловите несоответствия на этапе компиляции и обходитесь без ручного приведения типов.

Зачем использовать дженерики вместо Object?

Использование Object теряет всю информацию о типе: компилятор не помешает вам положить туда не то, и вам приходится приводить каждое извлекаемое значение (рискуя получить ClassCastException во время выполнения). Дженерики переносят эту проверку на этап компиляции. List<String> просто не примет Integer, а get() уже возвращает String - без приведения и без сюрпризов во время выполнения.

Что такое стирание типов в дженериках Java?

Стирание типов означает, что информация об обобщённом типе существует только на этапе компиляции. После компиляции List<String> и List<Integer> во время выполнения - это просто List, параметр типа стирается. Именно поэтому нельзя написать new T[10], вызвать list instanceof List<String> или прочитать параметр типа через рефлексию. Дженерики дают безопасность на этапе компиляции, а не данные о типе во время выполнения.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ