Вступление
Java - это типобезопасный язык программирования. Безопасность типов обеспечивает уровень достоверности и надежности языка программирования. Ключевой частью безопасности Java является обеспечение того, чтобы операции, выполняемые с объектом, выполнялись только в том случае, если тип объекта поддерживает это.
Безопасность типов значительно снижает количество ошибок программирования, которые могут возникнуть во время выполнения, включая все виды ошибок, связанных с несоответствием типов. Вместо этого эти типы ошибок перехватываются во время компиляции, что намного лучше, чем перехват ошибок во время выполнения, что позволяет разработчикам иметь меньше неожиданных и незапланированных обращений к старому доброму отладчику.
Безопасность типов также называют строгой типизацией .
Java Generics - это решение, разработанное для усиления типовой безопасности, для которой была разработана Java. Дженерики позволяют типы параметрируемого на методы и классы и вводит новый уровень абстракции для формальных параметров. Это будет подробно объяснено позже.
Использование дженериков в Java дает множество преимуществ. Внедрение универсальных шаблонов в ваш код может значительно улучшить его общее качество, предотвращая беспрецедентные ошибки времени выполнения, связанные с типами данных и приведением типов.
Это руководство продемонстрирует объявление, реализацию, варианты использования и преимущества универсальных шаблонов в Java.
Зачем использовать дженерики?
Чтобы обеспечить контекст того, как универсальные шаблоны усиливают строгую типизацию и предотвращают ошибки времени выполнения, связанные с приведением типов, давайте взглянем на фрагмент кода.
Допустим, вы хотите сохранить кучу строковых переменных в списке. Кодирование этого без использования дженериков будет выглядеть так:
List stringList = new ArrayList();
stringList.add("Apple");
Этот код не будет вызывать никаких ошибок времени компиляции, но большинство IDE предупредит вас, что инициализированный вами список имеет необработанный тип и должен быть параметризован с помощью универсального.
IDE предупреждают о проблемах, которые могут возникнуть, если вы не
параметризуете список типом. Один из них - возможность добавлять в
список элементы любого типа данных. Списки по умолчанию принимают любой
Object
, который включает в себя все его подтипы:
List stringList = new ArrayList();
stringList.add("Apple");
stringList.add(1);
Добавление двух или более разных типов в одну коллекцию нарушает правила безопасности типов. Этот код будет успешно скомпилирован, но это определенно вызовет множество проблем.
Например, что произойдет, если мы попытаемся просмотреть список в цикле? Давайте использовать расширенный цикл for:
for (String string : stringList) {
System.out.println(string);
}
Нас встретят:
Main.java:9: error: incompatible types: Object cannot be converted to String
for (String string : stringList) {
На самом деле это не потому, что мы соединили String
и Integer
.
Если мы изменим пример и добавим два String
:
List stringList = new ArrayList();
stringList.add("Apple");
stringList.add("Orange");
for (String string : stringList) {
System.out.println(string);
}
Нас все равно встретят:
Main.java:9: error: incompatible types: Object cannot be converted to String
for (String string : stringList) {
Это потому, что без какой-либо параметризации List
имеет дело только с
Object
s. Вы можете технически обойти это, используя Object
в
расширенном цикле for:
List stringList = new ArrayList();
stringList.add("Apple");
stringList.add(1);
for (Object object : stringList) {
System.out.println(object);
}
Что бы распечатать:
Apple
1
Однако это очень противоречит интуиции и не является настоящим решением. Это просто неустойчивый способ избежать основной проблемы дизайна.
Другая проблема - необходимость приведения типов всякий раз, когда вы
обращаетесь к элементам в списке и назначаете их без универсальных
шаблонов. Чтобы присвоить новые ссылочные переменные элементам списка,
мы должны привести их к типу, поскольку метод get()
возвращает
Object
s:
String str = (String) stringList.get(0);
Integer num = (Integer) stringList.get(1);
В этом случае, как вы сможете определять тип каждого элемента во время
выполнения, чтобы знать, к какому типу его привести? Вариантов не так
много, и те, что в вашем распоряжении, непомерно усложняют ситуацию,
например, использование try
/ catch
для попытки преобразования
элементов в некоторые предопределенные типы.
Кроме того, если вы не можете преобразовать элемент списка во время присваивания, он отобразит такую ошибку:
Type mismatch: cannot convert from Object to Integer
В ООП следует по возможности избегать явного приведения типов, потому что это ненадежное решение проблем, связанных с ООП.
Наконец, поскольку List
является подтипом Collection
, он должен
иметь доступ к итераторам, использующим Iterator
, метод
iterator()
и циклы for-each
Если коллекция объявлена без
универсальных шаблонов, вы определенно не сможете использовать ни один
из этих итераторов разумным образом.
Вот почему появились Java Generics и почему они являются неотъемлемой частью экосистемы Java. Давайте посмотрим, как объявлять универсальные классы, и перепишем этот пример, чтобы использовать универсальные классы и избежать проблем, которые мы только что видели.
Общие классы и объекты
Объявим класс с универсальным типом. Чтобы указать тип параметра для
класса или объекта, мы используем символы угловых скобок <>
рядом с
его именем и назначаем ему тип в скобках. Синтаксис объявления
универсального класса выглядит следующим образом:
public class Thing<T> {
private T val;
public Thing(T val) { this.val = val;}
public T getVal() { return this.val; }
public <T> void printVal(T val) {
System.out.println("Generic Type" + val.getClass().getName());
}
}
Примечание: универсальным типам НЕ могут быть назначены
примитивные типы данных, такие как int
, char
, long
, double
или float
. Если вы хотите назначить эти типы данных, используйте
вместо них их классы-оболочки.
Буква T
внутри угловых скобок называется параметром типа . По
соглашению, параметры типа состоят из одной буквы (AZ) и прописных букв.
Некоторые другие используемые имена параметров общего типа: K
(ключ),
V
(значение), E
(элемент) и N
(число).
Хотя теоретически вы можете присвоить любое имя переменной параметру типа, который следует соглашениям о переменных в Java, есть веская причина следовать типичному соглашению о параметрах типа, чтобы отличать обычную переменную от параметра типа.
val
относится к универсальному типу. Это может быть String
,
Integer
или другой объект. Учитывая общий класс Thing
объявленный
выше, давайте создадим экземпляр класса как несколько разных объектов
разных типов:
public void callThing() {
// Three implementations of the generic class Thing with 3 different data types
Thing<Integer> thing1 = new Thing<>(1);
Thing<String> thing2 = new Thing<>("String thing");
Thing<Double> thing3 = new Thing<>(3.5);
System.out.println(thing1.getVal() + " " + thing2.getVal() + " " + thing3.getVal());
}
Обратите внимание, что мы не указываем тип параметра перед вызовом
конструктора. Java определяет тип объекта во время инициализации,
поэтому вам не нужно вводить его повторно во время инициализации. В этом
случае тип уже выводится из объявления переменной. Такое поведение
называется выводом типа . Если бы мы унаследовали этот класс в таком
классе, как SubThing
, нам также не нужно было бы явно устанавливать
тип при создании его экземпляра как Thing
, поскольку он выводит тип
из своего родительского класса.
Вы можете указать его в обоих местах, но это просто избыточно:
Thing<Integer> thing1 = new Thing<Integer>(1);
Thing<String> thing2 = new Thing<String>("String thing");
Thing<Double> thing3 = new Thing<Double>(3.5);
Если мы запустим код, это приведет к:
1 String thing 3.5
Использование дженериков позволяет абстракцию, безопасную для типов, без необходимости использовать приведение типов, что в конечном итоге гораздо более рискованно.
Аналогичным образом List
принимает универсальный тип:
public interface List<E> extends Collection<E> {
// ...
}
В наших предыдущих примерах мы не указали тип, в результате чего List
был List
Object
. Теперь давайте перепишем предыдущий пример:
List<String> stringList = new ArrayList<>();
stringList.add("Apple");
stringList.add("Orange");
for (String string : stringList) {
System.out.println(string);
}
Это приводит к:
Apple
Orange
Работает как шарм! Опять же, нам не нужно указывать тип в ArrayList()
, поскольку он выводит тип из определения List<String>
. Единственный
случай, когда вам нужно будет указать тип после вызова конструктора, -
это если вы используете функцию вывода типа локальной переменной в
Java 10+:
var stringList = new ArrayList<String>();
stringList.add("Apple");
stringList.add("Orange");
На этот раз, поскольку мы используем var
, которое само по себе не
является типобезопасным, ArrayList<>()
не может определить тип, и он
просто по умолчанию будет использовать Object
если мы не Сами
уточняем.
Общие методы
Java поддерживает объявления методов с универсальными параметрами и возвращаемыми типами. Универсальные методы объявляются точно так же, как обычные методы, но перед возвращаемым типом указываются угловые скобки.
Давайте объявим простой универсальный метод, который принимает 3 параметра, добавляет их в список и возвращает его:
public static <E> List<E> zipTogether(E element1, E element2, E element3) {
List<E> list = new ArrayList<>();
list.addAll(Arrays.asList(element1, element2, element3));
return list;
}
Теперь мы можем запустить это как:
System.out.println(zipTogether(1, 2, 3));
Что приводит к:
[1, 2, 3]
Но также мы можем добавить другие типы:
System.out.println(zipTogether("Zeus", "Athens", "Hades"));
Что приводит к:
[Zeus, Athens, Hades]
Для объектов и методов также поддерживаются несколько типов параметров. Если метод использует более одного параметра типа, вы можете предоставить список всех из них внутри оператора ромба и разделить каждый параметр запятыми:
// Methods with void return types are also compatible with generic methods
public static <T, K, V> void printValues(T val1, K val2, V val3) {
System.out.println(val1 + " " + val2 + " " + val3);
}
Здесь вы можете проявить творческий подход к тому, что вы передаете. Следуя соглашениям, мы передадим тип, ключ и значение:
printValues(new Thing("Employee"), 125, "David");
Что приводит к:
Thing{val=Employee} 125 David
Однако имейте в виду, что параметры универсального типа, которые могут
быть выведены, не нужно объявлять в универсальном объявлении перед
возвращаемым типом. Чтобы продемонстрировать это, давайте создадим еще
один метод, который принимает 2 переменные - универсальную Map
и
List
который может содержать исключительно String
значения:
public <K, V> void sampleMethod(Map<K, V> map, List<String> lst) {
// ...
}
Здесь универсальные типы K
и V
Map<K, V>
поскольку они являются
предполагаемыми типами. С другой стороны, поскольку List<String>
может
принимать только строки, нет необходимости добавлять универсальный тип в
список <K, V>
.
Мы рассмотрели общие классы, объекты и методы с одним или несколькими параметрами типа. Что, если мы хотим ограничить степень абстракции, которую имеет параметр типа? Это ограничение можно реализовать с помощью привязки параметров.
Параметры ограниченного типа
Привязка параметра позволяет ограничить параметр типа объектом и его подклассами. Это позволяет вам применять определенные классы и их подтипы, сохраняя при этом гибкость и абстракцию использования параметров универсального типа.
Чтобы указать, что параметр типа ограничен, мы просто используем
extends
для параметра типа - <N extends Number>
. Это гарантирует,
что параметр типа N
мы передаем классу или методу, имеет тип Number
.
Давайте объявим класс с именем InvoiceDetail
, который принимает
параметр типа, и убедимся, что этот параметр типа имеет тип Number
.
Таким образом, общие типы, которые мы можем использовать при создании
экземпляра класса, ограничены числами и десятичными знаками с плавающей
запятой, поскольку Number
является суперклассом всех классов,
включающих целые числа, включая классы-оболочки и примитивные типы
данных:
class InvoiceDetail<N extends Number> {
private String invoiceName;
private N amount;
private N discount;
// Getters, setters, constructors...
}
Здесь extends
может означать две вещи: extends
в случае классов и
implements
в случае интерфейсов. Поскольку Number
является
абстрактным классом, он используется в контексте расширения этого
класса.
Расширяя параметр типа N
как Number
, создание экземпляра amount
и
discount
теперь ограничивается Number
и его подтипами. Попытка
установить для них любой другой тип вызовет ошибку времени компиляции.
Попробуем ошибочно присвоить String
значения вместо Number
типа:
InvoiceDetail<String> invoice = new InvoiceDetail<>("Invoice Name", "50.99", ".10");
Поскольку String
не является подтипом Number
, компилятор улавливает
это и вызывает ошибку:
Bound mismatch: The type String is not a valid substitute for the bounded parameter <N extends Number> of the type InvoiceDetail<N>
Это отличный пример того, как использование дженериков обеспечивает безопасность типов.
Кроме того, один параметр типа может расширять несколько классов и
интерфейсов с помощью &
для впоследствии расширенных классов:
public class SampleClass<E extends T1 & T2 & T3> {
// ...
}
Также стоит отметить, что еще одно отличное использование параметров ограниченного типа - в объявлениях методов. Например, если вы хотите обеспечить, чтобы типы, передаваемые в метод, соответствовали некоторым интерфейсам, вы можете убедиться, что параметры типа расширяют определенный интерфейс.
Классическим примером этого является обеспечение того, чтобы два типа
были Comparable
, если вы сравниваете их с помощью такого метода, как:
public static <T extends Comparable<T>> int compare(T t1, T t2) {
return t1.compareTo(t2);
}
Здесь, используя дженерики, мы обеспечиваем, чтобы оба t1
и t2
Comparable
, и что их действительно можно сравнить с методом
compareTo()
Зная, что String
сопоставимы и переопределяют метод
compareTo()
, мы можем удобно использовать их здесь:
System.out.println(compare("John", "Doe"));
Код приводит к:
6
Однако, если мы попытаемся использовать тип, Comparable
Thing
,
который не реализует интерфейс Comparable
System.out.println(compare(new Thing<String>("John"), new Thing<String>("Doe")));
Помимо того, что среда IDE отмечает эту строку как ошибочную, если мы попытаемся запустить этот код, это приведет к следующему:
java: method compare in class Main cannot be applied to given types;
required: T,T
found: Thing<java.lang.String>,Thing<java.lang.String>
reason: inference variable T has incompatible bounds
lower bounds: java.lang.Comparable<T>
lower bounds: Thing<java.lang.String>
В этом случае, поскольку Comparable
является интерфейсом, extends
фактически обеспечивает реализацию интерфейса T
, а не расширением.
Подстановочные знаки в универсальных шаблонах
Подстановочные знаки используются для обозначения любого типа класса и
обозначаются ?
. В общем, вы захотите использовать подстановочные
знаки, когда у вас есть потенциальная несовместимость между различными
экземплярами универсального типа. Есть три типа подстановочных знаков:
ограниченные сверху, ограниченные снизу и неограниченные .
Выбор подхода, который вы будете использовать, обычно определяется принципом IN-OUT. Принцип IN-OUT определяет входящие и исходящие переменные , которые, проще говоря, представляют, используется ли переменная для предоставления данных или для их вывода.
Например, sendEmail(String body, String recipient)
имеет body
In-variable и recipient
Out-variable . body
предоставляет данные
о теле письма, которое вы хотите отправить, а recipient
предоставляет
адрес электронной почты, на который вы хотите его отправить.
Существуют также смешанные переменные , которые используются как для предоставления данных, так и для ссылки на сам результат, и в этом случае вам следует избегать использования подстановочных знаков .
Вообще говоря, вы захотите определить In-переменные с ограниченными
сверху подстановочными знаками, используя ключевое слово extends
и
Out-переменные с ограниченными снизу подстановочными знаками, используя
ключевое слово super
Для In-переменных, к которым можно получить доступ через метод объекта, вы должны предпочесть неограниченные подстановочные знаки.
Подстановочные знаки с верхним ограничением
Подстановочные знаки с верхней границей используются для предоставления универсального типа, который ограничивает переменную классом или интерфейсом и всеми его подтипами . Имя, ограниченное сверху, относится к тому факту, что вы связали переменную с верхним типом - и всеми его подтипами.
В некотором смысле переменные с ограничением сверху более расслаблены,
чем переменные с ограничением снизу, поскольку они допускают большее
количество типов. Они объявлены с использованием оператора подстановки
?
за которым следует ключевое слово extends
и класс супертипа или
интерфейс ( верхняя граница их типа):
<? extends SomeObject>
Здесь extends
, опять же, означает extends
классы и implements
интерфейсы.
Напомним, что подстановочные знаки с ограничением сверху обычно используются для объектов, которые предоставляют входные данные для использования в переменных.
Примечание. Между Class<Generic>
и Class<? extends Generic>
.
Первый позволяет использовать только Generic
тип. В последнем случае
также действительны все подтипы Generic
Сделаем верхний тип ( Employee
) и его подкласс ( Developer
):
public abstract class Employee {
private int id;
private String name;
// Constructor, getters, setters
}
А также:
public class Developer extends Employee {
private List<String> skillStack;
// Constructor, getters and setters
@Override
public String toString() {
return "Developer {" +
"\nskillStack=" + skillStack +
"\nname=" + super.getName() +
"\nid=" + super.getId() +
"\n}";
}
}
Теперь давайте printInfo()
, который принимает ограниченный сверху
список объектов Employee
public static void printInfo(List<? extends Employee> employeeList) {
for (Employee e : employeeList) {
System.out.println(e.toString());
}
}
List
сотрудников, которые мы предоставляем, имеет верхнюю границу для
Employee
, что означает, что мы можем отказаться от любого Employee
, а также его подклассов, таких как Developer
:
List<Developer> devList = new ArrayList<>();
devList.add(new Developer(15, "David", new ArrayList<String>(List.of("Java", "Spring"))));
devList.add(new Developer(25, "Rayven", new ArrayList<String>(List.of("Java", "Spring"))));
printInfo(devList);
Это приводит к:
Developer{
skillStack=[Java, Spring]
name=David
id=15
}
Developer{
skillStack=[Java, Spring]
name=Rayven
id=25
}
Подстановочные знаки с нижним пределом
Подстановочные знаки с ограничением снизу противоположны шаблонам с ограничением сверху. Это позволяет ограничить универсальный тип классом или интерфейсом и всеми его супертипами . Здесь класс или интерфейс - это нижняя граница :
{.ezlazyload}
Объявление подстановочных знаков с ограничением снизу следует тому же
шаблону, что и подстановочные знаки с ограничением сверху -
подстановочный знак ( ?
), За которым следуют super
и супертип:
<? super SomeObject>
Основываясь на принципе IN-OUT , для объектов, которые участвуют в выводе данных, используются подстановочные знаки с нижним пределом. Эти объекты называются из переменных.
Давайте вернемся к функциям электронной почты, которые использовались ранее, и составим иерархию классов:
public class Email {
private String email;
// Constructor, getters, setters, toString()
}
Теперь давайте создадим подкласс для Email
:
public class ValidEmail extends Email {
// Constructor, getters, setters
}
Мы также захотим иметь некоторый служебный класс, такой как MailSender
чтобы «отправлять» электронные письма и уведомлять нас о результатах:
public class MailSender {
public String sendMail(String body, Object recipient) {
return "Email sent to: " + recipient.toString();
}
}
Наконец, давайте напишем метод, который принимает body
и recipients
и отправляет им тело, уведомляя нас о результате:
public static String sendMail(String body, List<? super ValidEmail> recipients) {
MailSender mailSender = new MailSender();
StringBuilder sb = new StringBuilder();
for (Object o : recipients) {
String result = mailSender.sendMail(body, o);
sb.append(result+"\n");
}
return sb.toString();
}
Здесь мы использовали общий тип ValidEmail
ограничениями снизу,
который extends Email
. Итак, мы можем создавать Email
и добавлять
их в этот метод:
List<Email> recipients = new ArrayList<>(List.of(
new Email(" [email protected] "),
new Email(" [email protected] ")));
String result = sendMail("Hello World!", recipients);
System.out.println(result);
Это приводит к:
Email sent to: Email{email=' [email protected] '}
Email sent to: Email{email=' [email protected] '}
Неограниченные подстановочные знаки
Неограниченные подстановочные знаки - это подстановочные знаки без
какой-либо формы привязки. Проще говоря, это подстановочные знаки,
расширяющие каждый класс, начиная с базового класса Object
Неограниченные подстановочные знаки используются, когда к Object
обращаются или которым манипулируют, или если метод, в котором он
используется, не имеет доступа или манипулирования с использованием
параметра типа. В противном случае использование неограниченных
подстановочных знаков поставит под угрозу безопасность типа метода.
Чтобы объявить неограниченный подстановочный знак, просто используйте
оператор вопросительного знака, заключенный в угловые скобки <?>
.
Например, у нас может быть List
любого элемента:
public void print(List<?> elements) {
for(Object element : elements) {
System.out.println(element);
}
}
System.out.println()
принимает любой объект, так что мы готовы пойти
сюда. Если метод заключается в копировании существующего списка в новый
список, то более предпочтительны подстановочные знаки с ограничением
сверху.
Разница между ограниченными подстановочными знаками и параметрами ограниченного типа?
Возможно, вы заметили, что разделы для ограниченных подстановочных знаков и параметров ограниченного типа разделены, но имеют более или менее одинаковое определение и на поверхностном уровне выглядят взаимозаменяемыми:
<E extends Number>
<? extends Number>
Итак, в чем разница между этими двумя подходами? На самом деле есть несколько отличий:
- Параметры Кольцевые типа принимают множественный
extends
с помощью&
ключевым словом в то время как ограниченные подстановочными принимать только один единственный тип продлить. - Параметры ограниченного типа ограничены только верхними границами.
Это означает, что вы не можете использовать
super
для параметров ограниченного типа. - Ограниченные подстановочные знаки можно использовать только во время
создания экземпляра. Их нельзя использовать для объявления
(например, объявлений классов и вызовов конструкторов. Вот несколько
примеров недопустимого использования подстановочных знаков:
class Example<? extends Object> {...}
GenericObj<?> = new GenericObj<?>()
GenericObj<? extends Object> = new GenericObj<? extends Object>()
- Ограниченные подстановочные знаки не следует использовать в качестве возвращаемых типов. Это не вызовет никаких ошибок или исключений, но вызовет ненужную обработку и приведение типов, что полностью противоречит безопасности типов, достигаемой универсальными шаблонами.
- Оператор
?
не может использоваться как фактический параметр и может использоваться только как общий параметр. Например:public <?> void printDisplay(? var) {}
завершится ошибкой во время компиляции, аpublic <E> void printDisplay(E var)
компилируется и успешно выполняется.
Преимущества использования дженериков
В этом руководстве мы рассмотрели основное преимущество универсальных шаблонов - обеспечение дополнительного уровня безопасности типов для вашей программы. Помимо этого, дженерики предлагают много других преимуществ по сравнению с кодом, который их не использует.
- Ошибки времени выполнения, связанные с типами и приведением типов, обнаруживаются во время компиляции. Причина, по которой следует избегать приведения типов, заключается в том, что компилятор не распознает исключения приведения типов во время компиляции. При правильном использовании универсальные шаблоны полностью избегают использования приведения типов и, следовательно, избегают всех исключений времени выполнения, которые они могут вызвать.
- Классы и методы можно использовать повторно. С помощью дженериков классы и методы могут повторно использоваться разными типами без необходимости переопределять методы или создавать отдельный класс.
Заключение
Применение дженериков к вашему коду значительно улучшит возможность повторного использования кода, читаемость и, что более важно, безопасность типов. В этом руководстве мы рассмотрели, что такое дженерики, как их можно применять, различия между подходами и когда выбирать.