Автор оригинала: Rayven Yor Esplanada.
Вступление
Java-это типобезопасный язык программирования. Безопасность типов обеспечивает уровень достоверности и надежности в языке программирования. Ключевой частью безопасности Java является обеспечение того, чтобы операции, выполняемые с объектом, выполнялись только в том случае, если тип объекта поддерживает это.
Безопасность типов значительно снижает количество ошибок программирования, которые могут возникнуть во время выполнения, включая все виды ошибок, связанных с несоответствиями типов. Вместо этого эти типы ошибок обнаруживаются во время компиляции, что намного лучше, чем обнаружение ошибок во время выполнения, что позволяет разработчикам реже совершать неожиданные и незапланированные поездки к старому доброму отладчику.
Безопасность типов также взаимозаменяемо называется строгой типизацией .
Java Generics – это решение, разработанное для усиления безопасности типов, для которой была разработана Java. Универсальные методы позволяют параметризовать типы для методов и классов и вводят новый уровень абстракции для формальных параметров . Это будет подробно объяснено позже.
Есть много преимуществ использования дженериков в Java. Внедрение универсальных средств в ваш код может значительно улучшить его общее качество за счет предотвращения беспрецедентных ошибок во время выполнения, связанных с типами данных и их типизацией.
В этом руководстве будут продемонстрированы декларация, реализация, варианты использования и преимущества универсальных приложений в Java.
Зачем Использовать Дженерики?
Чтобы дать представление о том, как универсальные средства усиливают строгую типизацию и предотвращают ошибки во время выполнения, связанные с типизацией, давайте взглянем на фрагмент кода.
Допустим, вы хотите сохранить кучу строковых переменных в списке. Кодирование этого без использования дженериков выглядело бы так:
List stringList = new ArrayList(); stringList.add("Apple");
Этот код не вызовет никаких ошибок во время компиляции, но большинство IDE предупредят вас о том, что Список , который вы инициализировали, имеет необработанный тип и должен быть параметризован с помощью универсального.
Идеи предупреждают вас о проблемах, которые могут возникнуть, если вы не параметризуете список с помощью типа. Один из них-возможность добавлять в список элементы любого типа данных. Списки по умолчанию будут принимать любой тип объекта
, который включает в себя каждый из его подтипов:
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) {
На самом деле, это не , потому что мы собрали Строку
и Целое число
вместе. Если мы изменим пример и добавим две Строки
s:
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) {
Это связано с тем, что без какой-либо параметризации Список
имеет дело только с Объектом
s. Вы можете технически обойти это, используя Объект
в расширенном цикле for:
List stringList = new ArrayList(); stringList.add("Apple"); stringList.add(1); for (Object object : stringList) { System.out.println(object); }
Что бы распечатать:
Apple 1
Однако это очень противоречит интуиции и не является реальным решением проблемы. Это просто позволяет избежать основной проблемы проектирования неустойчивым способом.
Другая проблема заключается в необходимости типизации всякий раз, когда вы получаете доступ к элементам в списке и назначаете их без универсальных. Чтобы назначить новые ссылочные переменные элементам списка, мы должны их типизировать, так как метод get()
возвращает Объект
s:
String str = (String) stringList.get(0); Integer num = (Integer) stringList.get(1);
В этом случае, как вы сможете определить тип каждого элемента во время выполнения, чтобы знать, к какому типу его привести? Вариантов не так много, и те, которые есть в вашем распоряжении, сильно усложняют ситуацию, например, использование try
/ catch
блоков, чтобы попытаться привести элементы к некоторым предопределенным типам.
Кроме того, если вы не приведете элемент списка во время назначения, он отобразит ошибку, подобную этой:
Type mismatch: cannot convert from Object to Integer
В ООП следует по возможности избегать явного приведения, поскольку оно не является надежным решением проблем, связанных с ООП.
Наконец, поскольку класс List
является подтипом Коллекции
, он должен иметь доступ к итераторам с использованием объекта Итератор
, метода итератор()
и для каждого
цикла. Если коллекция объявлена без универсальных, то вы определенно не сможете разумно использовать ни один из этих итераторов.
Вот почему появились Java-дженерики и почему они являются неотъемлемой частью экосистемы Java. Давайте рассмотрим, как объявлять универсальные классы, и перепишем этот пример, чтобы использовать универсальные классы и избежать проблем, которые мы только что видели.
Универсальные классы и объекты
Давайте объявим класс с универсальным типом. Чтобы указать тип параметра для класса или объекта, мы используем символы угловых скобок <>
рядом с его именем и присваиваем ему тип внутри скобок. Синтаксис объявления универсального класса выглядит следующим образом:
public class Thing{ private T val; public Thing(T val) { this.val = val;} public T getVal() { return this.val; } public void printVal(T val) { System.out.println("Generic Type" + val.getClass().getName()); } }
Примечание: Общим типам НЕ МОГУТ быть назначены примитивные типы данных, такие как int
, char
, long
, double
или float
. Если вы хотите назначить эти типы данных, вместо этого используйте их классы-оболочки.
Буква T
внутри угловых скобок называется параметром типа . По соглашению параметры типа пишутся одинарными буквами (A-Z) и прописными буквами. Некоторые другие используемые имена параметров общего типа: K
(Ключ), V
(Значение), E
(Элемент) и N
(Число).
Хотя теоретически вы можете назначить любое имя переменной параметру типа, которое соответствует соглашениям о переменных Java, есть все основания следовать типичному соглашению о параметрах типа, чтобы отличать обычную переменную от параметра типа.
val
относится к универсальному типу. Это может быть Строка
, Целое число
или другой объект. Учитывая общий класс Вещь
, объявленный выше, давайте создадим экземпляр класса в виде нескольких разных объектов разных типов:
public void callThing() { // Three implementations of the generic class Thing with 3 different data types Thingthing1 = new Thing<>(1); Thing thing2 = new Thing<>("String thing"); Thing thing3 = new Thing<>(3.5); System.out.println(thing1.getVal() + " " + thing2.getVal() + " " + thing3.getVal()); }
Обратите внимание, что мы не указываем тип параметра перед вызовом конструктора. Java определяет тип объекта во время инициализации, поэтому вам не нужно будет вводить его повторно во время инициализации. В этом случае тип уже выводится из объявления переменной. Такое поведение называется вывод типа . Если бы мы унаследовали этот класс в таком классе, как SubThing
, нам также не нужно было бы явно задавать тип при его создании в качестве Вещь
, так как она выводит тип из своего родительского класса.
Вы можете указать его в обоих местах, но это просто излишне:
Thingthing1 = new Thing (1); Thing thing2 = new Thing ("String thing"); Thing thing3 = new Thing (3.5);
Если мы запустим код, это приведет к:
1 String thing 3.5
Использование дженериков позволяет типобезопасно абстрагироваться без необходимости использовать типизацию, что в долгосрочной перспективе намного рискованнее.
Аналогичным образом, конструктор List
принимает универсальный тип:
public interface Listextends Collection { // ... }
В наших предыдущих примерах мы не указали тип, в результате чего Список
является Списком
объекта s. Теперь давайте перепишем пример из предыдущего:
ListstringList = new ArrayList<>(); stringList.add("Apple"); stringList.add("Orange"); for (String string : stringList) { System.out.println(string); }
Это приводит к:
Apple Orange
Работает как заклинание! Опять же, нам не нужно указывать тип в вызове ArrayList ()
, поскольку он выводит тип из определения List
. Единственный случай, в котором вам придется указать тип после вызова конструктора, – это если вы используете функцию вывод типа локальной переменной Java 10+:
var stringList = new ArrayList(); stringList.add("Apple"); stringList.add("Orange");
На этот раз, поскольку мы используем ключевое слово var
, которое само по себе не является типобезопасным, вызов ArrayList<>()
не может определить тип, и по умолчанию будет просто Объект
тип, если мы сами его не определим.
Общие методы
Java поддерживает объявления методов с общими параметрами и типами возвращаемых данных. Универсальные методы объявляются точно так же, как обычные методы, но имеют обозначения в угловых скобках перед типом возвращаемого значения.
Давайте объявим простой универсальный метод, который принимает 3 параметра, добавляет их в список и возвращает его:
public staticList zipTogether(E element1, E element2, E element3) { List 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]
Для объектов и методов также поддерживается несколько типов параметров. Если метод использует более одного параметра типа, вы можете предоставить список всех из них внутри оператора diamond и отделить каждый параметр запятыми:
// Methods with void return types are also compatible with generic methods public staticvoid 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 переменные – общую Карту
и Список
, которые могут содержать только Строковые
значения:
publicvoid sampleMethod(Map map, List lst) { // ... }
Здесь общие типы K
и V
сопоставляются с Map V>
, поскольку они являются выводимыми типами. С другой стороны, поскольку Список<Строка>
может принимать только строки, нет необходимости добавлять универсальный тип в V>
список. V>
Теперь мы рассмотрели универсальные классы, объекты и методы с одним или несколькими параметрами типа. Что делать, если мы хотим ограничить степень абстракции, которой обладает параметр типа? Это ограничение может быть реализовано с помощью привязки параметров.
Параметры Ограниченного Типа
Привязка параметров позволяет ограничить параметр типа объектом и его подклассами. Это позволяет применять определенные классы и их подтипы, сохраняя при этом гибкость и абстрактность использования параметров универсального типа.
Git Essentials
Ознакомьтесь с этим практическим руководством по изучению Git, содержащим лучшие практики и принятые в отрасли стандарты. Прекратите гуглить команды Git и на самом деле изучите это!
Чтобы указать, что параметр типа ограничен, мы просто используем ключевое слово extends
для параметра типа – расширяет число>
. Это гарантирует, что параметр типа N
, который мы предоставляем классу или методу, имеет тип Число
. расширяет число>
Давайте объявим класс с именем Сведения о счете
, который принимает параметр типа , и убедитесь, что этот параметр типа имеет тип Номер
. Таким образом, универсальные типы, которые мы можем использовать при создании экземпляра класса, ограничены числами и десятичными знаками с плавающей запятой, поскольку Число
является суперклассом всех классов, включающих целые числа, включая классы-оболочки и примитивные типы данных:
class InvoiceDetail{ private String invoiceName; private N amount; private N discount; // Getters, setters, constructors... }
Здесь расширяет
может означать две вещи – расширяет
в случае классов и реализует
в случае интерфейсов. Поскольку Number
является абстрактным классом, он используется в контексте расширения этого класса.
Расширяя параметр типа N
в качестве подкласса Number
, экземпляры сумма
и скидка
теперь ограничены Номером
и его подтипами. Попытка установить для них любой другой тип приведет к ошибке во время компиляции.
Давайте попробуем ошибочно присвоить Строковые
значения вместо Числа
типа:
InvoiceDetailinvoice = new InvoiceDetail<>("Invoice Name", "50.99", ".10");
Поскольку Строка
не является подтипом Числа
, компилятор улавливает это и вызывает ошибку:
Bound mismatch: The type String is not a valid substitute for the bounded parameterof the type InvoiceDetail
Это отличный пример того, как использование дженериков обеспечивает безопасность типов.
Кроме того, один параметр типа может расширять несколько классов и интерфейсов с помощью оператора &
для последующих расширенных классов:
public class SampleClass{ // ... }
Также стоит отметить, что еще одно замечательное использование параметров ограниченного типа используется в объявлениях методов. Например, если вы хотите обеспечить соответствие типов, передаваемых в метод, некоторым интерфейсам, вы можете убедиться, что параметры типа расширяют определенный интерфейс.
классический пример этого-обеспечение того , чтобы два типа были Сопоставимы
, если вы сравниваете их в таком методе, как:
public static> int compare(T t1, T t2) { return t1.compareTo(t2); }
Здесь, используя универсальные методы, мы утверждаем , что t1
и t2
оба Сопоставимы
и что их действительно можно сравнить с методом compareTo ()
. Зная, что String
сопоставимы и переопределяют метод compareTo ()
, мы можем с комфортом использовать их здесь:
System.out.println(compare("John", "Doe"));
Код приводит к:
6
Однако, если бы мы попытались использовать не Сопоставимый
тип, такой как Вещь
, который не реализует Сопоставимый
интерфейс:
System.out.println(compare(new Thing("John"), new Thing ("Doe")));
Помимо того, что IDE помечает эту строку как ошибочную, если мы попытаемся запустить этот код, это приведет к:
java: method compare in class Main cannot be applied to given types; required: T,T found: Thing,Thing reason: inference variable T has incompatible bounds lower bounds: java.lang.Comparable lower bounds: Thing
В этом случае, поскольку Сопоставимый
является интерфейсом, ключевое слово extends
фактически гарантирует , что интерфейс реализован T
, а не расширен.
Подстановочные знаки в общих выражениях
Подстановочные знаки используются для обозначения любого типа класса и обозначаются символом ?
. Как правило, вы захотите использовать подстановочные знаки, если у вас есть потенциальные несовместимости между различными экземплярами универсального типа. Существует три типа подстановочных знаков: ограниченные сверху , ограниченные снизу и неограниченные .
Выбор подхода, который вы будете использовать, обычно определяется принципом ВХОД-ВЫХОД . Принцип ВХОД-ВЫХОД определяет Входящие переменные и Исходящие переменные , которые, проще говоря, представляют, используется ли переменная для предоставления данных или для обслуживания в ее выводе.
Например, метод SendEmail(тело строки, получатель строки)
имеет Неизменяемое | тело и
Выходную переменную | получатель . Переменная body
предоставляет данные о теле письма, которое вы хотите отправить, в то время как переменная получатель
предоставляет адрес электронной почты, на который вы хотите его отправить.
Существуют также смешанные переменные , которые используются как для предоставления данных, так и для ссылки на сам результат, и в этом случае вы захотите избегать использования подстановочных знаков .
Вообще говоря, вы захотите определить Входящие переменные с верхними ограниченными подстановочными знаками, используя ключевое слово extends
и Исходящие переменные с нижними ограниченными подстановочными знаками, используя ключевое слово super
.
Для In-переменных , доступ к которым можно получить с помощью метода объекта, вы должны предпочесть неограниченные подстановочные знаки.
Подстановочные знаки с Верхней Границей
Подстановочные знаки с верхней границей используются для предоставления универсального типа, который ограничивает переменную классом или интерфейсом и всеми его подтипами . Имя с верхней границей относится к тому факту, что вы привязали переменную к типу верхний -и все это подтипы.
В некотором смысле переменные с верхней границей более расслаблены, чем переменные с нижней границей, поскольку они допускают большее количество типов. Они объявляются с помощью оператора подстановочных знаков ?
за которым следует ключевое слово extends
и класс или интерфейс супертипа ( верхняя граница их типа):
extends SomeObject>
Здесь расширяет
, опять же, означает расширяет
классы и реализует
интерфейсы.
Напомним, что подстановочные знаки с верхней границей обычно используются для объектов, которые предоставляют входные данные для использования в переменных.
Примечание: Существует явная разница между Class
и Class расширяет общие>
. Первый позволяет использовать только тип Generic
. В последнем случае все подтипы | общего также допустимы. расширяет общие>
. Первый позволяет использовать
Давайте сделаем верхний тип ( Сотрудник
) и его подкласс ( Разработчик
):
public abstract class Employee { private int id; private String name; // Constructor, getters, setters }
И:
public class Developer extends Employee { private ListskillStack; // Constructor, getters and setters @Override public String toString() { return "Developer {" + "\nname=" + super.getName() + super.getId() + "\n}"; } }
Теперь давайте создадим простой метод printInfo ()
, который принимает ограниченный сверху список объектов Сотрудник
:
public static void printInfo(List extends Employee> employeeList) { for (Employee e : employeeList) { System.out.println(e.toString()); } }
Список
сотрудников , которых мы предоставляем, ограничен сверху Сотрудником
, что означает, что мы можем добавить любой экземпляр Сотрудника
, а также его подклассы, такие как Разработчик
:
ListdevList = new ArrayList<>(); devList.add(new Developer(15, "David", new ArrayList (List.of("Java", "Spring")))); devList.add(new Developer(25, "Rayven", new ArrayList (List.of("Java", "Spring")))); printInfo(devList);
Это приводит к:
Developer{ skillStack=[Java, Spring] name=David id=15 } Developer{ skillStack=[Java, Spring] name=Rayven id=25 }
Подстановочные знаки с Нижними Границами
Подстановочные знаки с нижней границей противоположны подстановочным знакам с верхней границей. Это позволяет ограничить универсальный тип классом или интерфейсом и всеми его супертипами . Здесь класс или интерфейс является нижней границей :
Объявление подстановочных знаков с нижними ограничениями следует тому же шаблону, что и подстановочные знаки с верхними ограничениями-подстановочный знак ( ?
) за которым следует супер
и супертип:
super SomeObject>
На основе принципа ВХОД-ВЫХОД для объектов, участвующих в выводе данных, используются подстановочные знаки с нижними ограничениями. Эти объекты называются переменными out .
Давайте вернемся к прежним функциям электронной почты и построим иерархию классов:
public class Email { private String email; // Constructor, getters, setters, toString() }
Теперь давайте создадим подкласс для электронной почты
:
public class ValidEmail extends Email { // Constructor, getters, setters }
Мы также захотим иметь какой-нибудь служебный класс, например Отправитель почты
, чтобы “отправлять” электронные письма и уведомлять нас о результатах:
public class MailSender { public String sendMail(String body, Object recipient) { return "Email sent to: " + recipient.toString(); } }
Наконец, давайте напишем метод, который принимает тело
и список получателей
и отправляет им тело, уведомляя нас о результате:
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(); }
Здесь мы использовали общий тип с нижними ограничениями Действительного электронного письма
, который расширяет адрес электронной почты
. Итак, мы можем свободно создавать экземпляры Email
и использовать их в этом методе:
Listrecipients = 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]'}
Неограниченные Подстановочные знаки
Неограниченные подстановочные знаки-это подстановочные знаки без какой-либо формы привязки. Проще говоря, это подстановочные знаки, которые расширяют каждый отдельный класс, начиная с базового Объекта
класса.
Неограниченные подстановочные знаки используются, когда Объект
класс является объектом доступа или манипулирования или если метод, в котором он используется, не имеет доступа или не управляет с помощью параметра типа. В противном случае использование неограниченных подстановочных знаков поставит под угрозу безопасность типа метода.
Чтобы объявить неограниченный подстановочный знак, просто используйте оператор вопросительного знака, заключенный в угловые скобки .
Например, у нас может быть Список
из любого элемента:
public void print(List> elements) { for(Object element : elements) { System.out.println(element); } }
System.out.println()
принимает любой объект, так что мы готовы перейти сюда. Если бы метод состоял в копировании существующего списка в новый список, то подстановочные знаки с верхней границей были бы более благоприятными.
Разница между Ограниченными подстановочными знаками и Параметрами Ограниченного типа?
Возможно, вы заметили, что разделы для ограниченных подстановочных знаков и параметров ограниченного типа разделены, но более или менее имеют одинаковое определение и на поверхностном уровне выглядят как взаимозаменяемые:
extends Number>
Итак, в чем разница между этими двумя подходами? На самом деле есть несколько отличий:
- Параметры ограниченного типа допускают несколько
расширений
с использованием ключевого слова&
, в то время как ограниченные подстановочные знаки допускают расширение только одного типа. - Параметры ограниченного типа ограничены только верхними границами. Это означает, что вы не можете использовать ключевое слово
super
для параметров ограниченного типа. Ограниченные подстановочные знаки можно использовать только во время создания экземпляра. Они не могут использоваться для объявления (например, объявления классов и вызовы конструктора. Несколько примеров недопустимого использования подстановочных знаков:
пример класса расширяет объект> {...} расширяет объект> {...}
Универсальный Объект Универсальный Объект()
Общий объект расширяет Универсальный Объект расширяет объект>() расширяет Универсальный Объект расширяет объект>() расширяет объект>()
- Ограниченные подстановочные знаки не должны использоваться в качестве возвращаемых типов. Это не приведет к каким-либо ошибкам или исключениям, но приведет к ненужной обработке и типизации, что полностью противоречит безопасности типов, обеспечиваемой универсальными средствами.
Оператор
?
не может использоваться в качестве фактического параметра и может использоваться только в качестве общего параметра. Например:публичное Отображение недействительной печати(? var) {}
произойдет сбой во время компиляции, в то время какпубличный пустой дисплей печати(Evar)
компилируется и успешно запускается.
Преимущества использования дженериков
На протяжении всего руководства мы рассмотрели основное преимущество дженериков – обеспечить дополнительный уровень безопасности типов для вашей программы. Кроме того, дженерики предлагают множество других преимуществ по сравнению с кодом, который их не использует.
- Ошибки во время выполнения, связанные с типами и приведением, обнаруживаются во время компиляции. Причина, по которой следует избегать типизации, заключается в том, что компилятор не распознает исключения приведения во время компиляции. При правильном использовании дженерики полностью избегают использования типизации и впоследствии избегают всех исключений во время выполнения, которые это может вызвать.
- Классы и методы более многоразовы. С помощью универсальных средств классы и методы могут быть повторно использованы различными типами без необходимости переопределять методы или создавать отдельный класс.
Вывод
Применение универсальных средств к вашему коду значительно улучшит возможность повторного использования кода, удобочитаемость и, что более важно, безопасность ввода. В этом руководстве мы рассмотрели, что такое универсальные препараты, как их можно применять, различия между подходами и когда выбирать, какие именно.