Дженерики (Generics) в Java это механизм, который обеспечивает типобезопасность, позволяя создавать классы, интерфейсы и методы, работающие с различными типами данных. Их главное преимущество это перенос ошибок со стадии выполнения (runtime) на стадию компиляции, что избавляет от явных приведений типов и предотвращает ClassCastException.
Кратко
- Дженерик это указание типа в угловых скобках:
List<String> говорит компилятору, что в списке лежат только строки.
- Работает все это на этапе компиляции. В байт-коде типа-параметра уже нет, и такое поведение называется стиранием типов.
- Главная выгода в том, что ошибка типа ловится при сборке, а не в работающей программе, и приведения типов вручную писать не нужно.
- Дженерики инвариантны:
List<String> не является подтипом List<Object>, даже если String наследник Object.
- Смягчить инвариантность помогают границы (
<T extends Animal>) и подстановочные типы (<? extends Cat>, <? super Cat>).
- Типизировать можно не только классы, но и отдельные методы, а сырые типы (Raw Types) в новом коде использовать не стоит.
Чтобы в полной мере оценить, какую проблему решают дженерики, давайте начнем с основ, со строгой типизации, которая является фундаментальным принципом языка Java.
Строгая типизация: с чего все начинается
Согласно самому главному документу, т.е. спецификации языка Java (JLS, Java Language Specification), Java является строго типизированным языком. Что же это означает? Предположим, у нас есть главный метод:
public static void main(String[] args) {
String text = "Hello world!";
System.out.println(text);
}
Строгая типизация гарантирует, что при компиляции данного кода компилятор проверит, что если мы указали тип переменной text как String, то мы нигде не пытаемся использовать ее как переменную другого типа (например как число Integer).
Например, если мы вместо текста попытаемся сохранить значение
2L (т.е. long вместо String), то в момент компиляции получим ошибку:
Main.java:3: error: incompatible types: long cannot be converted to String
String text = 2L;
Т.е. строгая типизация позволяет обеспечить выполнение операций с объектами только тогда, когда эти операции правомерны для этих объектов. Это еще называется типобезопасностью или "Type safety".
Как сказано в JLS, в Java существует две категории типов: примитивные типы (primitive types) и ссылочные типы (reference types). Про примитивные типы можно вспомнить по обзорной статье:
Типы данных в Java.
Ссылочные типы могут быть представлены классом, интерфейсом или массивом. И сегодня нас будут интересовать именно ссылочные типы. И начнем с массивов:
class Main {
public static void main(String[] args) {
String[] text = new String[5];
text[0] = "Hello";
}
}
Такой код выполняется без ошибки. Как мы знаем, массив это контейнер, который хранит данные только одного типа. В данном случае только строки.
Попробуем добавить в массив long вместо String:
text[1] = 4L;
Выполним этот код (например, в
Online Java Compiler) и получим ошибку:
error: incompatible types: long cannot be converted to String
Массив и типобезопасность языка не дали нам сохранить в массив то, что не подходит по типу. Вот оно проявление type safety. Нам сказали: "Исправь ошибку, а до тех пор я не скомпилирую код". И самое главное тут в том, что это происходит в момент компиляции, а не запуска программы. То есть ошибки мы видим сразу, а не "когда-нибудь".
Проблема: коллекции до дженериков
А раз уж мы вспомнили про массивы, то вспомним и про
Коллекции в Java. Там у нас были разные структуры. Например, списки. Давайте перепишем пример:
import java.util.*;
class Main {
public static void main(String[] args) {
List text = new ArrayList(5);
text.add("Hello");
text.add(4L);
String test = text.get(0);
}
}
При компиляции его мы получим на строчке инициализации переменной
test ошибку:
incompatible types: Object cannot be converted to String
В нашем случае, List может хранить любой объект (т.е. объект типа Object). Поэтому, компилятор говорит, что он на себя такой груз ответственности брать не может. Поэтому, нам надо явно указать тип, который мы получим из списка:
String test = (String) text.get(0);
Такое указание называется приведением типов (type conversion или type casting). И все будет работать теперь замечательно, пока мы не попробуем достать элемент по индексу 1, т.к. он имеет тип Long. И мы получим справедливую ошибку, но уже во время работы программы (в Runtime):
Exception in thread "main" java.lang.ClassCastException: class java.lang.Long cannot be cast to class java.lang.String (java.lang.Long and java.lang.String are in module java.base of loader 'bootstrap')
Как мы видим, здесь сразу несколько важных минусов. Во-первых, мы вынуждены сами "кастить" значение, полученное из списка, к классу String. Согласитесь, это некрасиво. Во-вторых, в случае ошибки, мы ее увидим только тогда, когда программа будет выполняться. Будь наш код сложнее, мы могли бы обнаружить такую ошибку далеко не сразу.
И стали разработчики думать, как сделать работу в таких ситуациях проще, а код нагляднее. И на свет родились они, Дженерики (Java Generics).
Java Generics
Итак, дженерики. Что же это такое?
Generics в Java это особый способ описания используемых типов, который сможет использовать в своей работе компилятор кода для обеспечения типобезопасности.
Выглядит это примерно так:
А вот коротенький пример и объяснение его:
import java.util.*;
class Main {
public static void main(String[] args) {
List<String> text = new ArrayList<String>(5);
text.add("Hello");
text.add(4L);
String test = text.get(1);
}
}
В данном примере мы говорим, что у нас есть не просто
List, а
List, который работает ТОЛЬКО с объектами типа String. И никакими другими. Вот только что указано в скобках, то и можем хранить. Такие "скобки" называются "angle brackets", т.е. угловые (angle) скобки (brackets). Компилятор любезно за нас проверит, не допустили ли мы ошибок при работе со списком строк (список имеет имя text). Компилятор увидит, что мы пытаемся в список String положить наглым образом Long. И в момент компиляции выдаст ошибку:
error: no suitable method found for add(long)
Возможно, Вы вспомнили про то, что String это наследник CharSequence. И решите сделать что-нибудь вроде:
public static void main(String[] args) {
ArrayList<CharSequence> text = new ArrayList<String>(5);
text.add("Hello");
String test = text.get(0);
}
Но так нельзя и мы получим ошибку:
error: incompatible types: ArrayList<String> cannot be converted to ArrayList<CharSequence>
Кажется странным, т.к. строка
CharSequence sec = "test"; не содержит ошибок. Давайте разбираться.
Про такое поведение говорят: "Дженерики являются инвариантными". Что такое "инвариант"?
Мне нравится, как про это сказано на википедии в статье "
Covariance and contravariance":
Таким образом, инвариантность это отсутствие наследования между производными типами.
Если Кошка это подтип Животные, то Множество<Кошки> не является подтипом Множество<Животные> и Множество<Животные> не является подтипом Множество<Кошки>.
Кстати, стоит сказать, что начиная с Java SE 7 появился так называемый "
Diamond Operator". Потому что две угловые скобки <> похожи на бриллиант. Это позволяет нам использовать дженерики следующим образом:
public static void main(String[] args) {
List<String> lines = new ArrayList<>();
lines.add("Hello world!");
System.out.println(lines);
}
Компилятор по данному коду понимает, что если мы указали в левой части, что
List будет содержать объекты типа String, то в правой части мы подразумеваем, что хотим в переменную
lines сохранить новый ArrayList, который будет хранить тоже объект того типа, который указан в левой части. Таким образом, компилятор из левой части понимает или выводит тип для правой части. Именно поэтому такое поведение называется выведением типа или "Type Inference" на английском.
Стоит отметить еще такую интересную вещь, как RAW Types или же "сырые типы". Т.к. дженерики были не всегда, а Java старается по возможности поддерживать обратную совместимость, то дженерики вынуждены как-то работать с кодом, где не указан никакой дженерик.
Посмотрим пример:
List<CharSequence> lines = new ArrayList<String>();
Как мы помним, такая строчка не скомпилируется из-за инвариантности дженериков.
List<Object> lines = new ArrayList<String>();
И такая тоже не скомпилируется, по той же самой причине.
List lines = new ArrayList<String>();
List<String> lines2 = new ArrayList();
Такие строчки скомпилируются и будут работать. Именно в них используется Raw Types, т.е. не указанные типы. Еще раз стоит указать, что Raw Types в современном коде НЕ ДОЛЖНЫ быть использованы.
Типизированные классы
Итак, типизированные классы. Давайте увидим, как мы можем написать свой типизированный класс.
Например, у нас есть иерархия классов:
public static abstract class Animal {
public abstract void voice();
}
public static class Cat extends Animal {
public void voice(){
System.out.println("Meow meow");
}
}
public static class Dog extends Animal {
public void voice(){
System.out.println("Woof woof");
}
}
Мы хотим создать класс, реализующий контейнер для животных. Можно было бы написать класс, который будет содержать любых
Animal. Это просто, понятно, НО... мешать собак и кошек плохо, они не дружат друг с другом. Кроме того, если кто-то получит такой контейнер, то он по ошибке может запустить из контейнера кошек в стаю собак... и до добра это не доведет. И тут нам помогут дженерики.
Например, напишем реализацию так:
public static class Box<T> {
List<T> slots = new ArrayList<>();
public List<T> getSlots() {
return slots;
}
}
Наш класс будет работать с объектами типа, указанного дженериком с именем T. Это своего рода псевдоним. Т.к. дженерик указан у имени класса, то и получать его будем при объявлении класса:
public static void main(String[] args) {
Box<Cat> catBox = new Box<>();
Cat murzik = new Cat();
catBox.getSlots().add(murzik);
}
Как мы видим, мы указали что у нас
Box, который работает только с
Cat. Компилятор понял, что для
catBox вместо дженерика
T нужно подставить тип
Cat везде, где указано имя дженерика
T:
Т.е. именно благодаря
Box<Cat> компилятор понимает, что
slots на самом деле должен быть
List<Cat>.
Для
Box<Dog> внутри будет
slots, содержащий
List<Dog>.
Дженериков в объявлении типа может быть несколько, например:
public static class Box<T, V> {
Название дженерика может быть любым, хотя рекомендуется придерживаться некоторых негласных правил, они называются
"Type Parameter Naming Conventions":
| Буква |
Что обозначает |
E |
Element, элемент. Так подписаны параметры во всем Java Collections Framework |
K |
Key, ключ |
N |
Number, число |
T |
Type, просто тип |
V |
Value, значение |
S, U, V |
Второй, третий и четвертый типы, когда параметров несколько |
Кстати, помните мы с вами говорили, что дженерики инвариантны, т.е. не сохраняют иерархию наследования. На самом деле, мы можем на это повлиять. То есть у нас есть возможность сделать дженерики КОвариантными, т.е. сохраняющими наследования в том же порядке. Такое поведение называется "Bounded Type", т.е. ограниченные типы.
Например, наш класс
Box мог бы содержать всех животных, тогда бы мы объявили дженерик следующим образом:
public static class Box<T extends Animal> {
То есть мы ограничили верхнюю границу классом
Animal.
Так же мы можем указать несколько типов после ключевого слова
extends. Это будет означать, что тип, с которым мы будем работать, должен быть наследником некоторого класса и при этом реализовывать некоторый интерфейс. Например:
public static class Box<T extends Animal & Comparable> {
В этом случае, если мы попробуем положить в такой
Box что-то, что не является наследником
Animal и не реализует
Comparable, то во время компиляции мы получим ошибку:
error: type argument Cat is not within bounds of type-variable T
Типизация методов
Дженерики применяются не только в типах, но и в отдельных методах.
Применение в методах можно увидеть в официальном tutorial: "
Generics Methods".
Предыстория:
Посмотрим на эту картинку. Как видно, компилятор смотрит на сигнатуру метода и видит, что на вход мы принимаем какой-то неопределенный класс. И по ней же он определяет, что мы возвращаем какой-то объект, т.е. Object. Вот тот же метод текстом:
public static Object createObject(Class clazz) {
try {
return clazz.newInstance();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
Следовательно, если мы хотим создать, скажем, ArrayList, то нам надо делать так:
ArrayList<String> object = (ArrayList<String>) createObject(ArrayList.class);
Приходится самим явно писать, что на выходе будет ArrayList, что некрасиво и добавляет шанс сделать ошибку. Например, мы можем написать такой бред и это скомпилируется:
ArrayList object = (ArrayList) createObject(LinkedList.class);
Можем ли мы помочь компилятору? Да, дженерики нам это позволяют. Рассмотрим тот же пример:
И вот как он выглядит текстом:
public static <T> T createObject(Class<T> clazz) {
try {
return clazz.newInstance();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
Небольшая оговорка про версии. Метод
clazz.newInstance() с Java 9 помечен устаревшим: он умел пробрасывать наружу любое проверяемое исключение конструктора в обход компилятора. Замену предлагает
документация класса Class:
clazz.getDeclaredConstructor().newInstance(). На картинках выше он остался в прежнем виде, но на роль дженерика в этом примере это никак не влияет.
Тогда, мы можем создать объект просто вот так:
ArrayList<String> object = createObject(ArrayList.class);
WildCard
Подстановочным типам у нас посвящена
отдельная статья, а здесь разберем главное. Согласно Tutorial от Oracle по дженерикам, а именно разделу "
Wildcards", мы можем описать "неизвестный тип" символом вопроса, так называемого question mark.
Wildcard это удобный инструмент, чтобы смягчить некоторые ограничения дженериков. Например, как мы ранее разбирали, дженерики инвариантны. Это значит что хотя все классы являются наследниками (подтипами, subtypes) типа Object, но
List<любой тип> не является подтипом
List<Object>.
НО,
List<любой тип> является подтипом
List<?>. Таким образом, мы можем написать следующий код:
public static void printList(List<?> list) {
for (Object elem: list) {
System.out.print(elem + " ");
}
System.out.println();
}
Как и обычные дженерики (т.е. без использования wildcard'ов), дженерики с wildcard могут быть ограничены. Ограничение по верхней границе (Upper bounded wildcard) выглядит привычно:
public static void printCatList(List<? extends Cat> list) {
for (Cat cat: list) {
System.out.print(cat + " ");
}
System.out.println();
}
Но можно ограничить и по нижней границе (Lower bound wildcard):
public static void printCatList(List<? super Cat> list) {
Таким образом метод начнет принимать всех котов, а так же по иерархии выше (вплоть до Object).
Три варианта записи удобно держать рядом:
| Запись |
Что означает |
Что можно прочитать |
Что можно добавить |
List<?> |
Список чего угодно, конкретный тип неизвестен |
Элементы читаются только как Object |
Ничего, кроме null |
List<? extends Cat> |
Cat или любой его наследник |
Элементы читаются как Cat |
Ничего, кроме null: какой именно наследник внутри, компилятор не знает |
List<? super Cat> |
Cat или любой его предок вплоть до Object |
Элементы читаются только как Object |
Cat и любых его наследников |
Отсюда правило, которое в Java-мире знают как PECS, то есть Producer Extends Consumer Super. Если из коллекции только читают, она поставщик данных, и ей подходит
? extends. Если в коллекцию только пишут, она потребитель, и ей нужен
? super. Если нужно и то и другое, подстановочный тип не берут вовсе. Ровно так это сформулировано и в
руководстве Oracle, где то же самое описано через «in» и «out» переменные.
Стирание типов или Type Erasure
Говоря про дженерики стоит знать про
"Стирание типов". На самом деле стирание типов это про то, что дженерики это информация для компилятора. Во время выполнения программы информации о дженериках больше нет, это и называется "стирание". Это стирание имеет тот эффект, что тип дженерика заменяется конкретным типом. Если дженерик не имел границы, то будет подставлен тип Object. Если же граница была указана (например
<T extends Comparable>), то она и будет подставлена.
Вот пример из Tutorial от Oracle: "
Erasure of Generic Types":
Как выше и было сказано, в данном примере дженерик
T стерт до своей границы, т.е. до
Comparable.
Заключение
Java Generics очень интересная тема. Надеюсь, данная тема Вас заинтересовала. Подводя итоги, можно сказать, что дженерики это прекрасное средство, которое получили разработчики, чтобы подсказывать компилятору дополнительную информацию для обеспечения типобезопасности с одной стороны и гибкости с другой.
Вопросы и ответы
Что такое дженерики в Java простыми словами?
Это способ сказать компилятору, с какими типами работает класс, интерфейс или метод. Запись
List<String> означает, что в списке лежат только строки, и любая попытка положить туда что-то другое не пройдет компиляцию. Взамен вы получаете два удобства: ошибка находится при сборке, а не у пользователя, и доставать элементы можно без ручного приведения типов.
Зачем нужны дженерики, если можно просто привести тип?
Приведение типа это обещание компилятору, за которое отвечаете вы, а не он. Написав
(String) list.get(0), вы говорите «поверь мне, тут строка», и если там окажется
Long, программа упадет с
ClassCastException во время работы. С
List<String> проверку делает компилятор: неподходящее значение просто не попадет в список, а приведение писать не придется вовсе.
Что значит, что дженерики инвариантны?
То, что наследование типов внутрь угловых скобок не переносится.
String наследник
CharSequence, но
List<String> не является подтипом
List<CharSequence>, и присвоить одно другому нельзя. Сделано это ради безопасности: будь такое присваивание разрешено, в список строк можно было бы положить любой другой
CharSequence, и компилятор бы этого не заметил.
Чем отличаются <T extends Animal> и <? extends Animal>?
Первое это параметр типа с границей: вы объявляете свой класс или метод и говорите, что
T может быть только наследником
Animal. Внутри кода
T это конкретный известный тип, с ним можно работать как обычно. Второе это подстановочный тип в объявлении переменной или параметра метода: он означает «какой-то неизвестный наследник
Animal». Такой список можно читать, но добавлять в него нельзя, потому что конкретный тип неизвестен.
Что такое Raw Types и почему их не стоит использовать?
Сырой тип это дженерик-класс, у которого не указан параметр: просто
List вместо
List<String>. Такой код компилируется ради совместимости со старыми программами, написанными до Java 5, когда дженериков еще не существовало. Но все проверки при этом отключаются, и вы возвращаетесь к ручным приведениям и к
ClassCastException во время работы. В новом коде сырые типы использовать не нужно.
Можно ли узнать тип-параметр во время выполнения программы?
Штатными средствами нет: информация о нем стирается при компиляции. Поэтому
new ArrayList<String>().getClass() и
new ArrayList<Integer>().getClass() вернут один и тот же класс. Обойти это можно, только передав тип явно, например положив в объект
Class<T> через конструктор: сам класс
Class тоже дженерик, поэтому информация о типе сохранится в поле.
Читайте также