Статьи

Как и когда использовать Generics

Эта статья является частью нашего Академического курса под названием Advanced Java .

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

1. Введение

Идея обобщений представляет собой абстракцию над типами (хорошо известными разработчикам C ++ как шаблоны). Это очень мощная концепция (которая возникла довольно давно), которая позволяет разрабатывать абстрактные алгоритмы и структуры данных и предоставлять конкретные типы для дальнейшей работы. Интересно, что дженерики не присутствовали в ранних версиях Java и были добавлены только в версии Java 5. И с тех пор будет справедливо сказать, что дженерики революционизировали способ написания программ на Java, предоставив гораздо более строгие гарантии типов и сделав код значительно более безопасным.

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

2. Обобщения и интерфейсы

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

1
2
3
4
5
package com.javacodegeeks.advanced.generics;
 
public interface GenericInterfaceOneType< T > {
    void performAction( final T action );
}

GenericInterfaceOneType параметризован с одним типом T , который может быть немедленно использован объявлениями интерфейса. Интерфейс может быть параметризован с более чем одним типом, например:

1
2
3
4
5
package com.javacodegeeks.advanced.generics;
 
public interface GenericInterfaceSeveralTypes< T, R > {
    R performAction( final T action );
}

Всякий раз, когда какой-либо класс хочет реализовать интерфейс, он имеет возможность предоставить точные замены типов, например, класс ClassImplementingGenericInterface предоставляет String как параметр типа T универсального интерфейса:

1
2
3
4
5
6
7
8
9
package com.javacodegeeks.advanced.generics;
 
public class ClassImplementingGenericInterface
        implements GenericInterfaceOneType< String > {
    @Override
    public void performAction( final String action ) {
        // Implementation here
    }
}

Стандартная библиотека Java имеет множество примеров универсальных интерфейсов, прежде всего в библиотеке коллекций. Объявлять и использовать универсальные интерфейсы очень просто, однако мы собираемся вернуться к ним еще раз при обсуждении ограниченных типов ( универсальных, подстановочных знаков и ограниченных типов ) и общих ограничений ( ограничение универсальных ).

3. Дженерики и классы

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

1
2
3
4
5
6
7
package com.javacodegeeks.advanced.generics;
 
public class GenericClassOneType< T > {
    public void performAction( final T action ) {
        // Implementation here
    }
}

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

1
2
3
4
5
6
7
8
9
package com.javacodegeeks.advanced.generics;
 
public class GenericClassImplementingGenericInterface< T >
        implements GenericInterfaceOneType< T > {
    @Override
    public void performAction( final T action ) {
        // Implementation here
    }
}

Это очень удобный метод, который позволяет классам налагать дополнительные границы на универсальный тип, все еще соответствующий контракту интерфейса (или родительского класса), как мы увидим в разделе Общие, групповые символы и ограниченные типы .

4. Обобщения и методы.

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

1
2
3
4
5
public< T, R > R performAction( final T action ) {
    final R result = ...;
    // Implementation here
    return result;
}

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

1
2
3
4
5
6
7
protected abstract< T, R > R performAction( final T action );
 
static< T, R > R performActionOn( final Collection< T > action ) {
    final R result = ...;
    // Implementation here
    return result;
}

Если методы объявлены (или определены) как часть универсального интерфейса или класса, они могут (или не могут) использовать универсальные типы своего владельца. Они могут определять собственные универсальные типы или смешивать их с типами из своего объявления класса или интерфейса. Например:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
package com.javacodegeeks.advanced.generics;
 
public class GenericMethods< T > {
    public< R > R performAction( final T action ) {
        final R result = ...;
        // Implementation here
        return result;
    }
 
    public< U, R > R performAnotherAction( final U action ) {
        final R result = ...;
        // Implementation here
        return result;
    }
}

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

1
2
3
4
5
6
7
8
9
public class GenericMethods< T > {
    public GenericMethods( final T initialAction ) {
        // Implementation here
    }
 
    public< J > GenericMethods( final T initialAction, final J nextAction ) {
        // Implementation here
    }
}

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

5. Ограничение дженериков

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

Во-первых, примитивные типы (такие как int , long , byte , …) не допускаются к использованию в обобщениях. Это означает, что когда вам нужно параметризовать ваш универсальный тип примитивным, вместо него следует использовать соответствующий класс-оболочку ( Integer , Long , Byte , …).

1
2
final List< Long > longs = new ArrayList<>();
final Set< Integer > integers = new HashSet<>();

Мало того, что из-за необходимости использовать обертки классов в обобщениях, это вызывает неявную упаковку и распаковку примитивных значений (эта тема будет подробно рассмотрена в части 7 руководства, « Общие рекомендации по программированию» ), например:

1
2
3
4
5
final List< Long > longs = new ArrayList<>();
longs.add( 0L ); // 'long' is boxed to 'Long'
 
long value = longs.get( 0 ); // 'Long' is unboxed to 'long'
// Do something with value

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

1
2
3
4
5
6
7
void sort( Collection< String > strings ) {
    // Some implementation over strings heres
}
 
void sort( Collection< Number > numbers ) {
    // Some implementation over numbers here
}

С точки зрения разработчика, это совершенно правильный код, однако из-за стирания типов эти два метода сужаются до одной и той же сигнатуры, и это приводит к ошибке компиляции (со странным сообщением типа «Стирание метода sort (Collection <String>») ) аналогичен другому методу… »

1
2
void sort( Collection strings )
void sort( Collection numbers )

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

01
02
03
04
05
06
07
08
09
10
11
public< T > void action( final T action ) {
    if( action instanceof T ) {
        // Do something here
    }
}
 
public< T > void action( final T action ) {
    if( T.class.isAssignableFrom( Number.class )  ) {
     // Do something here
    }
}

И, наконец, также невозможно создать экземпляры массива, используя параметры типа generics. Например, следующий код не компилируется (на этот раз с чистым сообщением об ошибке «Не удается создать универсальный массив T» ):

1
2
3
public< T > void performAction( final T action ) {
    T[] actions = new T[ 0 ];
}

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

6. Обобщения, шаблоны и ограниченные типы

До сих пор мы видели примеры, использующие дженерики с параметрами неограниченного типа. Чрезвычайно мощная способность обобщений налагает ограничения (или границы) на тип, для которого они параметризованы, используя ключевые слова extends и super .

Ключевое слово extends ограничивает параметр типа подклассом какого-либо другого класса или реализует один или несколько интерфейсов. Например:

1
2
3
public< T extends InputStream > void read( final T stream ) {
    // Some implementation here
}

Параметр типа T в объявлении метода read должен быть подклассом класса InputStream . Это же ключевое слово используется для ограничения реализации интерфейса. Например:

1
2
3
public< T extends Serializable > void store( final T object ) {
    // Some implementation here
}

Хранилище методов требует своего параметра типа T для реализации интерфейса Serializable чтобы метод выполнял желаемое действие. Также можно использовать другой параметр типа в качестве привязки для ключевого слова extends , например:

1
2
3
public< T, J extends T > void action( final T initial, final J next ) {
     // Some implementation here
}

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

1
2
3
4
5
6
7
public< T extends InputStream & Serializable > void storeToRead( final T stream ) {
    // Some implementation here
}
public< T extends Serializable & Externalizable & Cloneable > void persist(
        final T object ) {
    // Some implementation here
}

Прежде чем обсуждать super ключевое слово, нам нужно ознакомиться с концепциями подстановочных знаков. Если параметр типа не представляет интереса универсального класса, интерфейса или метода, он может быть заменен символом? подстановочные. Например:

1
2
3
public void store( final Collection< ? extends Serializable > objects ) {
    // Some implementation here
}

Хранилище методов на самом деле не заботится о том, с какими параметрами он вызывается, единственное, что нужно для обеспечения того, чтобы каждый тип реализовывал интерфейс Serializable . Или, если это не имеет никакого значения, вместо этого можно использовать подстановочный знак без границ:

1
2
3
public void store( final Collection< ? > objects ) {
    // Some implementation here
}

В отличие от extends , ключевое слово super ограничивает параметр типа суперклассом некоторого другого класса. Например:

1
2
3
public void interate( final Collection< ? super Integer > objects ) {
    // Some implementation here
}

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

7. Обобщения и типовые выводы

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

1
2
3
4
5
6
final Map< String, Collection< String > > map =
    new HashMap< String, Collection< String > >();
 
for( final Map.Entry< String, Collection< String > > entry: map.entrySet() ) {
    // Some implementation here
}

Релиз Java 7 несколько решил эту проблему, внеся изменения в компилятор и введя новый оператор diamond <>. Например:

1
final Map< String, Collection< String > > map = new HashMap<>();

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

1
2
3
4
5
6
7
public static < T > void performAction( final Collection< T > actions,
        final Collection< T > defaults ) {
    // Some implementation here
}
 
final Collection< String > strings = new ArrayList<>();
performAction( strings, Collections.emptyList() );

Компилятор Java 7 не может определить параметр типа для Collections. emptyList () Collections. emptyList () и как таковой требует, чтобы он был передан явно:

1
performAction( strings, Collections.< String >emptyList() );

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

8. Обобщения и аннотации

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

1
2
3
public< @Actionable T > void performAction( final T action ) {
    // Some implementation here
}

Или просто еще один пример применения аннотации при использовании универсального типа:

1
2
final Collection< @NotEmpty String > strings = new ArrayList<>();
// Some implementation here

В четвертой части урока « Как и когда использовать Enums и Annotations» мы рассмотрим несколько примеров того, как можно использовать аннотации, чтобы связать некоторые метаданные с параметрами универсального типа. Этот раздел просто дает вам ощущение, что можно дополнить дженерики аннотациями.

9. Доступ к параметрам общего типа

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

1
2
3
public< T > void performAction( final T action, final Class< T > clazz ) {
    // Some implementation here
}

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

Другой интересный вариант использования, который часто возникает при работе с обобщениями в Java, — это определение конкретного класса типа, с которым параметризован обобщенный экземпляр. Это не так просто и требует участия API отражения Java. Мы рассмотрим полный пример в части 11 учебного пособия « Поддержка отражений и динамических языков», а пока просто отметим, что экземпляр ParameterizedType является центральной точкой для размышлений над обобщениями.

10. Когда использовать дженерики

Несмотря на все ограничения, ценность, которую дженерики добавляют в язык Java, просто огромна. В настоящее время трудно представить, что было время, когда у Java не было поддержки дженериков. Обобщения должны использоваться вместо необработанных типов ( Collection< T > вместо Collection , Callable< T > вместо Callable ,…) или Object чтобы гарантировать безопасность типов, определять четкие ограничения типов для контрактов и алгоритмов и значительно упрощать обслуживание кода. и рефакторинг.

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

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

Пример 1. Давайте рассмотрим типичный пример метода, который выполняет действия с экземпляром класса, который реализует некоторый интерфейс (скажем, Serializable ) и возвращает обратно измененный экземпляр этого класса.

1
2
class SomeClass implements Serializable {
}

Без использования обобщений решение может выглядеть так:

1
2
3
4
5
6
7
8
public Serializable performAction( final Serializable instance ) {
    // Do something here
    return instance;
}
 
final SomeClass instance = new SomeClass();
// Please notice a necessary type cast required
final SomeClass modifiedInstance = ( SomeClass )performAction( instance );

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

1
2
3
4
5
6
7
public< T extends Serializable > T performAction( final T instance ) {
    // Do something here
    return instance;
}     
 
final SomeClass instance = new SomeClass();
final SomeClass modifiedInstance = performAction( instance );

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

Пример 2. Немного более сложный пример метода, который требует, чтобы экземпляр класса реализовал два интерфейса (скажем, Serializable и Runnable ).

1
2
3
4
5
6
class SomeClass implements Serializable, Runnable {
    @Override
    public void run() {
        // Some implementation
    }
}

Без использования обобщений, простое решение состоит в том, чтобы ввести промежуточный интерфейс (или использовать чистый Object в качестве крайней меры), например:

01
02
03
04
05
06
07
08
09
10
11
12
// The class itself should be modified to use the intermediate interface
// instead of direct implementations
class SomeClass implements SerializableAndRunnable {
    @Override
    public void run() {
        // Some implementation
    }
}
 
public void performAction( final SerializableAndRunnable instance ) {
    // Do something here
}

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

1
2
3
public< T extends Serializable & Runnable > void performAction( final T instance ) {
    // Do something here
}

Очень понятный и лаконичный кусок кода, никакой промежуточный интерфейс или другие хитрости не требуются.

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

11. Что дальше

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

12. Загрузите исходный код