Статьи

Краткое руководство по Java Generics

Generics — это функция Java, которая была представлена ​​в Java SE 5.0, и через несколько лет после ее выпуска я клянусь, что каждый Java-программист не только слышал об этом, но и использовал его. Существует множество бесплатных и коммерческих ресурсов о дженериках Java, и лучшие источники, которые я использовал:

Несмотря на обилие информации, мне иногда кажется, что многие разработчики до сих пор не понимают смысла и значения обобщений Java. Вот почему я пытаюсь обобщить основную информацию, необходимую разработчикам о дженериках, как можно проще.

Мотивация для дженериков

Самый простой способ думать о дженериках Java — думать о некоем синтаксическом сахаре, который может избавить вас от некоторой операции приведения:

1
2
List<Apple> box = ...;
Apple apple = box.get(0);

Предыдущий код говорит само за себя: box — это ссылка на список объектов типа Apple. Метод get возвращает экземпляр Apple, приведения которого не требуется. Без обобщений этот код был бы:

1
2
List box = ...;
Apple apple = (Apple) box.get(0);

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

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

Generics Facility

Средство generics ввело понятие переменной типа. Переменная типа, согласно Спецификации языка Java, является неквалифицированным идентификатором, введенным:

  • Общие объявления классов
  • Общие объявления интерфейса
  • Общие объявления методов
  • Общие объявления конструктора.

Общие классы и интерфейсы

Класс или интерфейс являются общими, если они имеют одну или несколько переменных типа. Переменные типа разделяются угловыми скобками и следуют за именем класса (или интерфейса):

1
2
3
public interface List<T> extends Collection<T> {
...
}

Грубо говоря, переменные типа действуют как параметры и предоставляют информацию, необходимую компилятору для проверки.

Многие классы в библиотеке Java, такие как вся библиотека коллекций, были изменены, чтобы стать общими. Например, интерфейс List, который мы использовали в первом фрагменте кода, теперь является универсальным классом. В этом фрагменте box был ссылкой на объект List <Apple>, экземпляр класса, реализующего интерфейс List с одной переменной типа: Apple. Переменная типа — это параметр, который компилятор использует при автоматическом приведении результата метода get к ссылке Apple.

Фактически, новая общая сигнатура или метод get интерфейса List:

1
T get(int index);

Метод get действительно возвращает объект типа T, где T — это переменная типа, указанная в объявлении List <T>.

Общие методы и конструкторы

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

1
public static <t> T getFirst(List<T> list)

Этот метод примет ссылку на List <T> и вернет объект типа T.

Примеры

Вы можете воспользоваться универсальными шаблонами как в своих собственных классах, так и в общих классах библиотеки Java.

Введите Безопасность при написании …

Например, в следующем фрагменте кода мы создаем экземпляр List <String> или заполняем его некоторыми данными:

1
2
3
List<String> str = new ArrayList<String>();
str.add("Hello ");
str.add("World.");

Если бы мы попытались поместить какой-либо объект другого типа в List <String>, компилятор выдаст ошибку:

1
str.add(1); // won't compile

… И при чтении

Если мы передаем ссылку List <String>, мы всегда гарантируем получение объекта String из него:

1
String myString = str.get(0);

Итерация

Многие классы в библиотеке, такие как Iterator <T>, были улучшены и сделаны общими. Метод iterator () интерфейса List <T> теперь возвращает Iterator <T>, который можно легко использовать без приведения объектов, которые он возвращает через метод T next ().

1
2
3
4
for (Iterator<String> iter = str.iterator(); iter.hasNext();) {
String s = iter.next();
System.out.print(s);
}

Использование foreach

Для каждого синтаксиса также используются дженерики. Предыдущий фрагмент кода может быть записан как:

1
2
3
for (String s: str) {
System.out.print(s);
}

это даже легче читать и поддерживать.

Автобокс и Автобокс

Функции autoboxing / autounboxing языка Java автоматически используются при работе с обобщениями, как показано в следующем фрагменте кода:

1
2
3
4
5
6
7
8
List<Integer> ints = new ArrayList<Integer>();
ints.add(0);
ints.add(1);
int sum = 0;
for (int i : ints) {
sum += i;
}

Имейте в виду, однако, что бокс и распаковка приводят к снижению производительности, поэтому применяются обычные предостережения и предупреждения.

Подтипы

В Java, как и в других объектно-ориентированных типизированных языках, могут быть построены иерархии типов:

Java Generics

В Java подтип типа T — это либо тип, который расширяет T, либо тип, который реализует T (если T является интерфейсом) прямо или косвенно. Поскольку «быть подтипом» является транзитивным отношением, если тип A является подтипом B, а B является подтипом C, то A также будет подтипом C. На рисунке выше:

  • FujiApple является подтипом Apple
  • Яблоко является подтипом фруктов
  • FujiApple является подтипом фруктов.

Каждый тип Java также будет подтипом Object.

Каждый подтип A типа B может быть присвоен ссылке типа B:

1
2
Apple a = ...;
Fruit f = a;

Подтипирование родовых типов

Если ссылка на экземпляр Apple может быть назначена ссылке на Fruit, как показано выше, то какова связь, скажем, между List <Apple> и List <Fruit>? Какой из них является подтипом какого? В более общем смысле, если тип A является подтипом типа B, как соотносятся C <A> и C <B>?

Удивительно, но ответ: ни в коем случае. В более формальных словах отношение подтипов между универсальными типами является инвариантным.

Это означает, что следующий фрагмент кода недействителен:

1
2
List<Apple> apples = ...;
List<Fruit> fruits = apples;

и так же следующее:

1
2
3
List<Apple> apples;
List<Fruit> fruits = ...;
apples = fruits;

Но почему? Это яблоко — это фрукт, ящик с яблоками (список) — это тоже ящик с фруктами.

В некотором смысле это так, но типы (классы) инкапсулируют состояние и операции. Что случилось бы, если бы коробка яблок была коробкой фруктов?

1
2
3
List<Apple> apples = ...;
List<Fruit> fruits = apples;
fruits.add(new Strawberry());

Если бы это было так, мы могли бы добавить другие разные подтипы Fruit в список, и это должно быть запрещено.

Обратный путь более интуитивен: коробка фруктов — это не коробка яблок, поскольку это может быть коробка (список) других видов (подтипов) фруктов (фрукты), например, клубника.

Это действительно проблема?

Так не должно быть. Самая веская причина для удивления Java-разработчика — несоответствие между поведением массивов и универсальными типами. Хотя отношения подтипов последнего являются инвариантными, отношение подтипов первого является ковариантным: если тип A является подтипом типа B, то A [] является подтипом B []:

1
2
Apple[] apples = ...;
Fruit[] fruits = apples;

Но ждать! Если мы повторим аргумент, представленный в предыдущем разделе, мы можем добавить клубнику к массиву яблок:

1
2
3
Apple[] apples = new Apple[1];
Fruit[] fruits = apples;
fruits[0] = new Strawberry();

Код действительно компилируется, но ошибка будет возникать во время выполнения как ArrayStoreException . Из-за этого поведения массивов во время операции хранения среда выполнения Java должна проверить совместимость типов. Проверка, очевидно, также добавляет снижение производительности, о котором вы должны знать.

Еще раз, дженерики безопаснее использовать и «исправлять» этот тип уязвимости безопасности Java-массивов.

В случае, если вам сейчас интересно, почему отношение подтипов для массивов ковариантно, я дам вам ответ, который дают Java Generics и Collections : если бы он был инвариантным, не было бы способа передать ссылку на массив объектов. неизвестного типа (без копирования каждый раз в Object []) в метод, такой как:

1
void sort(Object[] o);

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

Wildcards

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

  • Сужение ссылки (ковариация)
  • Расширение ссылки (контравариантность)

ковариации

Предположим, например, что у нас есть набор ящиков, каждый из которых имеет свой фрукт. Мы хотели бы иметь возможность писать методы, которые могут принимать любой из них. Более формально, учитывая подтип A типа B, мы хотели бы найти способ использовать ссылку (или параметр метода) типа C <B>, которая могла бы принимать экземпляры C <A>.

Для выполнения этой задачи мы можем использовать подстановочный знак с расширениями, как в следующем примере:

1
2
List<Apple> apples = new ArrayList<Apple>();
List<? extends Fruit> fruits = apples;

? расширяет, повторно вводит ковариантный подтип для обобщенных типов: Apple является подтипом Fruit, а List <Apple> является подтипом List <? расширяет фрукты>.

контрвариация

Давайте теперь введем еще один шаблон: супер. Учитывая супертип B типа A, тогда C <B> является подтипом C <? супер A>:

1
2
List<Fruit> fruits = new ArrayList<Fruit>();
List<? super Apple> = fruits;

Как можно использовать подстановочные знаки?

Пока достаточно теории: как мы можем воспользоваться этими новыми конструкциями?

? продолжается

Давайте вернемся к примеру, который мы использовали во второй части при представлении ковариации Java-массивов:

1
2
3
Apple[] apples = new Apple[1];
Fruit[] fruits = apples;
fruits[0] = new Strawberry();

Как мы видели, этот код компилируется, но приводит к исключению времени выполнения при попытке добавить Strawberry в массив Apple через ссылку на массив Fruit.

Теперь мы можем использовать подстановочные знаки для перевода этого кода в его общий аналог: поскольку Apple является подтипом Fruit, мы будем использовать? расширяет подстановочный знак, чтобы иметь возможность назначить ссылку списка <Apple> на ссылку списка <? расширяет Фрукты>:

1
2
3
List<Apple> apples = new ArrayList<Apple>();
List<? extends Fruit> fruits = apples;
fruits.add(new Strawberry());

На этот раз код не скомпилируется! Компилятор Java теперь запрещает нам добавлять клубнику в список фруктов. Мы обнаружим ошибку во время компиляции, и нам даже не понадобится проверка во время выполнения (например, в случае хранилищ массивов), чтобы убедиться, что мы добавляем в список совместимый тип. Код не скомпилируется, даже если мы попытаемся добавить экземпляр Fruit в список:

1
fruits.add(new Fruit());

Ни за что. Получается, что на самом деле вы ничего не можете поместить в структуру, тип которой использует? расширяет подстановочный знак.

Причина довольно проста, если подумать: подстановочный знак extends T сообщает компилятору, что мы имеем дело с подтипом типа T, но мы не можем знать, какой именно. Так как нет возможности узнать, и мы должны гарантировать безопасность типов, вам не разрешат помещать что-либо внутрь такой структуры. С другой стороны, поскольку мы знаем, какой бы тип он ни был, он будет подтипом T, мы можем получить данные из структуры с гарантией того, что это будет экземпляр T:

1
Fruit get = fruits.get(0);

? супер

Каково поведение типа, который использует? супер подстановочный знак? Давайте начнем с этого:

1
2
List<Fruit> fruits = new ArrayList<Fruit>();
List<? super Apple> = fruits;

Мы знаем, что фрукты — это ссылка на Список чего-то, что является супертипом Apple. Опять же, мы не можем знать, какой это супертип, но мы знаем, что Apple и любой из ее подтипов будут совместимы с ним по назначению. Действительно, поскольку такой неизвестный тип будет и Apple, и супертипом GreenApple, мы можем написать:

1
2
fruits.add(new Apple());
fruits.add(new GreenApple());

Если мы попытаемся добавить какой-нибудь супертип Apple, компилятор будет жаловаться:

1
2
fruits.add(new Fruit());
fruits.add(new Object());

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

Как насчет получения данных такого типа? Оказывается, что вы можете извлечь из него только экземпляры Object: поскольку мы не можем знать, какой это супертип, компилятор может гарантировать только то, что он будет ссылкой на Object, поскольку Object является супертипом любого объекта. Тип Java

Принцип получения и сдачи или правило PECS

Обобщая поведение? расширяется а то? супер подстановочные знаки, мы делаем следующий вывод:

  • Использовать ? расширяет подстановочный знак, если вам нужно извлечь объект из структуры данных
  • Использовать ? супер подстановочный знак, если вам нужно поместить объекты в структуру данных
  • Если вам нужно сделать обе вещи, не используйте подстановочные знаки.

Это то, что Морис Нафталин называет принципом «получи и положи» в своих обобщениях и коллекциях Java, и то, что Джошуа Блох называет правилом PECS в своей эффективной Java .

Мнемика Блоха, PECS, происходит от «Продюсер расширяется, потребительский супер» и, вероятно, ее легче запомнить и использовать.

Подстановочные знаки в сигнатурах методов

Как видно из части II этой серии, в Java (как и во многих других типизированных языках) действует принцип замещения: подтип может быть назначен для ссылки на любой из его супертипов.

Это применимо во время присвоения любой ссылки, то есть даже при передаче параметров в функцию или сохранении ее результата. Таким образом, одним из преимуществ этого принципа является то, что при определении иерархий классов можно написать методы «общего назначения» для обработки целых подиерархий независимо от класса обрабатываемого времени экземпляров конкретного объекта. В иерархии классов Fruit, которую мы использовали до сих пор, функция, которая принимает Fruit в качестве параметра, принимает любой из его подтипов (например, Apple или Strawberry).

Как видно из предыдущего поста, подстановочные знаки восстанавливают ковариантные и контравариантные подтипы для универсальных типов: затем, используя подстановочные знаки, позвольте разработчику написать функции, которые могут использовать преимущества, представленные до сих пор.

Если, например, разработчик хочет определить метод eat, который принимает список того или иного фрукта, он может использовать следующую сигнатуру:

1
void eat(List<? extends Fruit> fruits);

Поскольку список любого подтипа класса Fruit является подтипом списка <? расширяет Fruit>, предыдущий метод примет любой такой список в качестве параметра. Обратите внимание, что, как объяснено в предыдущем разделе, принцип Get and Put (или правило PECS) позволит вам извлекать объекты из такого списка и назначать их для ссылки на Fruit.

С другой стороны, если вы хотите поместить экземпляры в список, передаваемый в качестве параметра, вы должны использовать? супер подстановочный знак:

1
void store(List<? super Fruit> container);

Таким образом, список любого супертипа Fruit может быть передан в функцию store, и вы можете безопасно добавить в нее любой подтип Fruit.

Переменные ограниченного типа

Тем не менее, гибкость дженериков выше. Переменные типа могут быть ограничены, почти так же, как и подстановочные знаки (как мы видели во второй части). Однако переменные типа не могут быть ограничены с помощью super, а только с помощью extends. Посмотрите на следующую подпись:

1
public static <T extends I<T>> void name(Collection<T> t);

Он принимает наборы объектов, тип которых ограничен: он должен удовлетворять условию T extends I <T>. Использование переменных ограниченного типа на первый взгляд может показаться не более мощным, чем подстановочные знаки, но мы подробно рассмотрим различия.

Давайте предположим, что некоторые, но не все, фрукты в вашей иерархии могут быть сочными, как в:

1
2
3
public interface Juicy<T> {
    Juice<T> squeeze();
}

Сочные фрукты будут реализовывать этот интерфейс и публиковать метод сжатия.

Теперь вы пишете библиотечный метод, который берет кучу фруктов и сжимает их все. Первая подпись, которую вы можете написать, может быть:

1
<T> List<Juice<T>> squeeze(List<Juicy<T>> fruits);

Используя переменные ограниченного типа, вы бы написали следующее (которое, действительно, получило то же стирание, что и предыдущий метод):

1
<T extends Juicy<T>> List<Juice<T>> squeeze(List<T> fruits);

Все идет нормально. Но ограничено. Мы могли бы использовать те же аргументы, что и в тех же публикациях, и обнаружить, что метод squeeze не будет работать, например, со списком красных апельсинов, когда:

1
2
class Orange extends Fruit implements Juicy<Orange>;
class RedOrange extends Orange;

Поскольку мы уже узнали о принципе PECS, мы собираемся изменить метод с помощью:

1
<T extends Juicy<? super T>> List<Juice<? super T>> squeezeSuperExtends(List<? extends T> fruits);

Этот метод принимает список объектов, тип которых расширяет Juicy <? super T>, то есть, другими словами, должен существовать тип S, такой что T расширяет Juicy <S> и S super T.

Рекурсивные границы

Может быть, вы чувствуете, что расслабляет T расширяет Juicy <? супер T> связанный. Этот вид границы называется рекурсивной границей, поскольку граница, которой должен удовлетворять тип T, зависит от T. При необходимости вы можете использовать рекурсивные границы, а также смешивать и сопоставлять их с другими видами границ.

Таким образом, вы можете, например, написать универсальные методы с такими границами:

1
<A extends B<A,C>, C extends D<T>>

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

Использование нескольких типов переменных

Предположим, вы хотите ослабить рекурсивную границу, которую мы поместили в последнюю версию метода сжатия. Давайте теперь предположим, что тип T может расширять Juicy <S>, хотя сам T не расширяет S. Подпись метода может быть такой:

1
<T extends Juicy<S>, S> List<Juice<S>> squeezeSuperExtendsWithFruit(List<? extends T> fruits);

Эта сигнатура в значительной степени эквивалентна предыдущей (поскольку мы используем только T в аргументах метода), но имеет одно небольшое преимущество: поскольку мы объявили универсальный тип S, метод может вернуть List <Juice <S> вместо списка <? super T>, что может быть полезно в некоторых ситуациях, так как компилятор поможет вам определить, какой тип S соответствует переданным аргументам метода. Поскольку вы возвращаете список, скорее всего, вы хотите, чтобы вызывающая сторона могла получить что-то из него, и, как вы узнали в предыдущей части, вы можете получать только экземпляры объектов из списка, например List <? супер T>.

Очевидно, что вы можете добавить больше границ для S, если они вам нужны, например:

1
<T extends Juicy<S>, S extends Fruit> List<Juice<S>> squeezeSuperExtendsWithFruit(List<? extends T> fruits);

Несколько границ

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

1
<T extends A, T extends B> // illegal

Компилятор потерпит неудачу с сообщением, таким как:

Т уже определено в…

Несколько границ должны быть выражены с другим синтаксисом, который оказывается довольно знакомой нотацией:

1
<T extends A & B>

Предыдущие границы означают, что T расширяет и A, и B. Пожалуйста, примите во внимание, что в соответствии с разделом языка Java , глава 4.4 , говорится, что граница — это либо:

  • Переменная типа.
  • Класс.
  • Тип интерфейса, за которым следуют другие типы интерфейса.

Это означает, что множественные границы могут быть выражены только с использованием типов интерфейса. Нет возможности использовать переменные типа в нескольких границах, и компилятор завершится с таким сообщением, как:

Переменная типа не может сопровождаться другими границами.

Это не всегда ясно в документации, которую я прочитал.

Рекомендации :

Удачного кодирования! Не забудь поделиться!

Byron

Статьи по Теме: