Руководство по пониманию универсальных шаблонов в Java

Введение Java - это типобезопасный язык программирования. Безопасность типов обеспечивает уровень достоверности и надежности языка программирования. Это ключевая часть безопасности Java - гарантировать, что операции, выполняемые с объектом, выполняются только в том случае, если тип объекта поддерживает это. Безопасность типов значительно снижает количество ошибок программирования, которые могут возникнуть во время выполнения, включая все виды ошибок, связанных с несоответствием типов. Вместо этого эти типы ошибок перехватываются во время компиляции, когда

Вступление

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) компилируется и успешно выполняется.

Преимущества использования дженериков

В этом руководстве мы рассмотрели основное преимущество универсальных шаблонов - обеспечение дополнительного уровня безопасности типов для вашей программы. Помимо этого, дженерики предлагают много других преимуществ по сравнению с кодом, который их не использует.

  1. Ошибки времени выполнения, связанные с типами и приведением типов, обнаруживаются во время компиляции. Причина, по которой следует избегать приведения типов, заключается в том, что компилятор не распознает исключения приведения типов во время компиляции. При правильном использовании универсальные шаблоны полностью избегают использования приведения типов и, следовательно, избегают всех исключений времени выполнения, которые они могут вызвать.
  2. Классы и методы можно использовать повторно. С помощью дженериков классы и методы могут повторно использоваться разными типами без необходимости переопределять методы или создавать отдельный класс.

Заключение

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

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus